Documentation for larsasub v0.12.0.
On an older version? Your release notes ship with the package (CHANGELOG.md).
LarsaSub - Full Documentation
A comprehensive Laravel package for managing SaaS subscriptions and recurring payments with Mollie integration and multi-tenant support.
Table of Contents
- Requirements
- Installation
- Configuration
- Models
- Traits
- Helpers
- Events
- Notifications
- Multilang Plan Content
- Payment Provider (Mollie)
- Multi-Tenant / Mollie Connect
- Nova Integration
- Artisan Commands
- Routes & Webhooks
- Database Schema
- Testing
Requirements
- PHP: ^8.3
- Laravel: ^12.0 | ^13.0
- Mollie API credentials (for payment processing)
Installation
Step 1: Install via Composer
LarsaSub is not on Packagist. Configure our repository and your license token first — see Installation; without it composer reports the package as not found.
composer require peters-development/larsasub
Step 2: Run Installation Command
php artisan larsasub:install
Installation Options:
| Option | Description |
|---|---|
--seed |
Creates sample subscription plans for testing (fresh install only) |
--active |
Creates active BillableSubscriptions and RecurringCharges for existing billable models (fresh install only) |
--active-payment-methods |
Set all seeded payment methods to active (fresh install only) |
All three are demo-data tools and only act on a fresh install. On an installation that already has plans they are skipped with an explanation — re-running them used to duplicate purchasable plans and re-enable payment methods an operator had switched off. Reseed through your own seeder instead.
What the installation does:
- Publishes configuration file to
config/larsasub.php - Publishes Vue components and pages (if using standalone dashboard)
- Publishes built standalone assets
- Runs database migrations
- Seeds payment methods via
LarsaSubSeeder - Optionally seeds sample subscription plans
Step 3: Configure Your Billable Model
Add the BillableModel interface and IsBillableModel trait to your User model (or other billable model):
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use PetersDevelopment\LarsaSub\Contracts\BillableModel;
use PetersDevelopment\LarsaSub\Traits\IsBillableModel;
class User extends Authenticatable implements BillableModel
{
use IsBillableModel;
public function getBillableNameAttribute(): string
{
return $this->name;
}
public function getBillableEmailAttribute(): string
{
return $this->email;
}
public function getBillableIdentifierAttribute(): string
{
return (string) $this->id;
}
}
How it works internally: LarsaSub uses a
Billableintermediary model (inlarsasub_billables) between your User/Team model and all billing entities. This is created automatically the first time a billing operation occurs — you don't need to manage it manually. All subscriptions, charges, credits, and payments reference the Billable, not your model directly.
Subscription convenience methods: The
IsBillableModeltrait provides core billing relations (credit, mandates, single payments). For subscription methods likeactiveSubscription(),hasFeature(), andfeatureValue(), also add theHasSubscriptionstrait — see the Traits section.
Publishing Assets Separately
If you need to publish specific assets:
# Configuration
php artisan vendor:publish --tag=larsasub-config
# Database migrations
php artisan vendor:publish --tag=larsasub-migrations
# Blade views (published to resources/views/vendor/LarsaSub — the path the
# view finder reads LarsaSub:: overrides from; edits there win over the package)
php artisan vendor:publish --tag=larsasub-views
# Translation files
php artisan vendor:publish --tag=larsasub-translations
# Vue components
php artisan vendor:publish --tag=larsasub-vue-components
# Standalone built assets
php artisan vendor:publish --tag=larsasub-assets
Standalone dashboard assets (public/vendor/larsasub/ after publishing larsasub-assets) are fully self-contained:
larsasub.css— a complete compiled Tailwind stylesheet covering every dashboard component, including light/dark theme tokens. The dashboards are fully styled without any host Tailwind or Vite setup.vue.global.prod.js— the bundled Vue 3 runtime. No CDN is contacted at runtime.standalone-*-app.umd.js— the dashboard apps, with component-scoped styles inlined.
Theming by the host app is optional and works in two layers:
- Theme view —
config('larsasub.standalone.theme_view')(defaultnull) names a Blade view that the dashboard views include afterlarsasub.css. Put your stylesheet markup there, whatever your asset pipeline is —@vite(['resources/css/app.css']),<link href="{{ mix('css/app.css') }}">, or a plain<link>tag. Because the dashboards read shadcn-style tokens (--background,--primary,--card, ...), a host stylesheet that defines those tokens on:root/.darkre-themes the dashboards automatically. Example: createresources/views/larsasub-theme.blade.phpcontaining your@vitecall and set'theme_view' => 'larsasub-theme'. - Tailwind source scanning (optional) — only needed if the host stylesheet should restyle package markup with its own utilities. Tailwind v4 hosts can add
@source '../../public/vendor/larsasub/*.js';to their CSS entry so package classes are included in the host build. With the bundledlarsasub.cssthis is not required for correct rendering.
Security note for published PDF templates (resources/views/vendor/LarsaSub/pdf/*): the built-in invoice/credit-note PDFs are rendered by dompdf, which is safe as long as the template only receives escaped data. When customizing these templates:
- Keep all user/customer data inside
{{ }}(Blade escaping). If you need line breaks, escape first:{!! nl2br(e($value)) !!}— never output raw user input through{!! !!}. - Leave dompdf's
isRemoteEnabledat its default (false) — do not load fonts, images, or CSS from URLs in PDF templates. Historical dompdf CVEs all involved rendering untrusted HTML or fetching remote assets. - Prefer inline CSS and the bundled DejaVu fonts, as the stock templates do.
dompdf is a suggested dependency, not a required one: composer require barryvdh/laravel-dompdf to use the built-in PDFs. Consumers that render invoices through an external service can skip it entirely by binding their own InvoiceRenderer (see the Invoicing section); the built-in renderer then throws a LarsaSubException naming the missing package rather than a class-not-found mid-stream.
Configuration
The configuration file is located at config/larsasub.php after publishing.
Core Settings
// Database table prefix
'table_prefix' => env('LARSASUB_TABLE_PREFIX', 'larsasub_'),
// Payment provider (currently only 'mollie' supported)
'provider' => env('LARSASUB_PROVIDER', 'mollie'),
// Currency settings. `allowed` is an enforced allow-list: the one entry
// point where a caller supplies a currency (SinglePaymentService::
// createSinglePayment) rejects currencies outside it. Comma-separated env.
'currency' => [
'default' => env('LARSASUB_CURRENCY_DEFAULT', 'EUR'),
'allowed' => array_filter(array_map('trim', explode(',', env('LARSASUB_CURRENCY_ALLOWED', 'EUR')))),
],
// ngrok domain for local webhook testing
'ngrok' => env('LARSASUB_NGROK_DOMAIN', ''),
Billable Model Configuration
'billable' => [
// Your billable model class
'model' => \App\Models\User::class,
// Primary key field name
'key' => 'id',
// Billable Relation (for Team/Company scenarios)
// How to get the billable from the authenticated user.
// - null: User IS the billable (default, backward compatible)
// - 'team': $user->team() returns the billable Team
// - 'company': $user->company() returns the billable Company
'relation' => null,
// Platform Billing Model (Dual Billing)
// For setups where the platform charges tenants AND tenants charge their users.
// This model uses the platform's global Mollie key (not Connect).
// The primary 'model' above uses the tenant's Connect account.
// Both models must implement BillableModel interface.
// null = no dual billing (default)
'platform_model' => null,
],
Notification Recipients
'notifications' => [
// Sales team notifications
'sales' => ConfigHelpers::parseNotificationRecipients(
env('LARSASUB_NOTIFICATIONS_SALES', '')
),
// User notification toggles
'user' => [
'subscription_activated' => true,
'subscription_cancelled' => true,
'subscription_ended' => true,
'subscription_renewed' => true,
'subscription_renewal_announced' => true,
'payment_failed' => true,
'payment_canceled' => true,
'payment_expired' => true,
'first_payment_failed' => true,
'chargeback' => true,
'refund' => true,
'charge_announced' => true,
'credit_added' => true,
'credit_expiring' => true,
'credit_expired' => true,
],
// Company/Sales notification toggles
'company' => [
'subscription_activated' => env('LARSASUB_NOTIFY_COMPANY_SUBSCRIPTION_ACTIVATED', true),
'subscription_cancelled' => env('LARSASUB_NOTIFY_COMPANY_SUBSCRIPTION_CANCELLED', true),
'payment_failed' => env('LARSASUB_NOTIFY_COMPANY_PAYMENT_FAILED', true),
'chargeback' => env('LARSASUB_NOTIFY_COMPANY_CHARGEBACK', true),
'refund' => env('LARSASUB_NOTIFY_COMPANY_REFUND', true),
'recurring_charge_canceled' => env('LARSASUB_NOTIFY_COMPANY_CHARGE_CANCELED', true),
'recurring_charge_expired' => env('LARSASUB_NOTIFY_COMPANY_CHARGE_EXPIRED', true),
'credit_added' => env('LARSASUB_NOTIFY_COMPANY_CREDIT_ADDED', false),
'credit_expiring' => env('LARSASUB_NOTIFY_COMPANY_CREDIT_EXPIRING', false),
'credit_expired' => env('LARSASUB_NOTIFY_COMPANY_CREDIT_EXPIRED', false),
],
// Disable specific listeners
'disabled_listeners' => [],
],
Recipient format options:
- Role name:
'sales'(requiresspatie/laravel-permission) - Email list:
'admin@example.com,sales@example.com' - Single email:
'admin@example.com' - Disabled: empty string or
null
Notification Channels
'notification_channels' => [
// Default channels for all notifications
'default' => ConfigHelpers::parseNotificationChannels(
env('LARSASUB_NOTIFICATION_CHANNELS', 'mail')
),
// Per-notification overrides
'overrides' => [
// 'NotificationClassName' => ['mail', 'slack'],
],
],
Available channels:
mail- Email notificationsdatabase- Database storageslack- Slack channel (requires additional package)
Disabled Listeners
Disable specific event listeners without affecting others:
'disabled_listeners' => [
\PetersDevelopment\LarsaSub\Listeners\BillableSubscription\SendSubscriptionActivatedToUserListener::class,
],
Pass fully qualified listener class names to skip them during event dispatching. This is useful when you want to replace a default listener with your own implementation.
Features Configuration
Define feature sets for subscription plans:
'features' => [
// Defaults for plans WITHOUT a tenant (single-tenant, or platform to tenant)
'default' => [
'max_users' => 5,
'api_access' => false,
],
// Defaults for tenant-scoped plans (tenant to their own users)
'tenant' => [
'max_users' => 25,
],
// Human-readable names for the free-form feature keys above
'labels' => [
'max_users' => 'Team members',
'api_access' => 'API access',
],
],
Only these three keys are read: default, tenant and labels. Feature keys
themselves are free-form and application-specific — a plan overrides them
through its features column, a subscription through feature_overrides.
Standalone Dashboard Settings
'standalone' => [
'page_size' => env('LARSASUB_STANDALONE_PAGE_SIZE', 50),
'list_limit' => env('LARSASUB_STANDALONE_LIST_LIMIT', 500),
// All twelve default to true. Per-dashboard env vars:
// LARSASUB_DASHBOARD_{SUBSCRIPTION,PAYMENTS,PAYMENT_METHODS,CREDIT,
// PROFILE,INVOICES,TENANT_CONNECT,TENANT_SUBSCRIPTIONS,
// TENANT_SUBSCRIBERS,TENANT_CHARGES,TENANT_FEES,TENANT_INVOICES}
'dashboards' => [
'subscription' => env('LARSASUB_DASHBOARD_SUBSCRIPTION', true),
'payments' => env('LARSASUB_DASHBOARD_PAYMENTS', true),
'payment_methods' => env('LARSASUB_DASHBOARD_PAYMENT_METHODS', true),
// ... nine more, see config/larsasub.php
],
'url' => [
'subscription-dashboard' => env('LARSASUB_STANDALONE_SUBSCRIPTION_URL', 'billing/dashboard'),
'payments-dashboard' => env('LARSASUB_STANDALONE_PAYMENTS_URL', 'billing/payments'),
'payment-methods-dashboard' => env('LARSASUB_STANDALONE_METHODS_URL', 'billing/payment-methods'),
'tenant-connect-dashboard' => env('LARSASUB_STANDALONE_TENANT_CONNECT_URL', 'billing/admin/connect'),
// ... one prefix per dashboard, see config/larsasub.php
],
'theme_view' => env('LARSASUB_STANDALONE_THEME_VIEW'), // host stylesheet include
'show_back' => env('LARSASUB_STANDALONE_SHOW_BACK', true),
'back' => env('LARSASUB_STANDALONE_BACK_URL', '/'),
],
Scheduled Commands Configuration
'scheduling' => [
// Enable/disable all scheduled commands
'enabled' => env('LARSASUB_SCHEDULING_ENABLED', true),
// Auto-register commands with Laravel scheduler
'auto_register' => env('LARSASUB_SCHEDULING_AUTO_REGISTER', true),
// Individual command frequencies
'plan_charges_frequency' => env('LARSASUB_PLAN_CHARGES_FREQUENCY', 'daily'),
'execute_charges_frequency' => env('LARSASUB_EXECUTE_CHARGES_FREQUENCY', 'daily'),
'end_subscriptions_frequency' => env('LARSASUB_END_SUBSCRIPTIONS_FREQUENCY', 'daily'),
'end_billable_subscriptions_frequency' => env('LARSASUB_END_BILLABLE_SUBSCRIPTIONS_FREQUENCY', 'daily'),
'cleanup_unused_billable_subscriptions_frequency' => env('LARSASUB_CLEANUP_UNUSED_BILLABLE_SUBSCRIPTIONS_FREQUENCY', 'twiceDaily'),
'cleanup_unused_billable_subscriptions_hours' => env('LARSASUB_CLEANUP_UNUSED_BILLABLE_SUBSCRIPTIONS_HOURS', 12),
// Renewal and announcement frequencies
'renew_billable_subscriptions_frequency' => env('LARSASUB_RENEW_BILLABLE_SUBSCRIPTIONS_FREQUENCY', 'daily'),
'announce_renewal_billable_subscriptions_frequency' => env('LARSASUB_ANNOUNCE_RENEWAL_BILLABLE_SUBSCRIPTIONS_FREQUENCY', 'daily'),
// Charge notification settings
'notify_charges_frequency' => env('LARSASUB_NOTIFY_CHARGES_FREQUENCY', 'daily'),
'notify_charges_days_before' => env('LARSASUB_NOTIFY_CHARGES_DAYS_BEFORE', 10),
// Credit expiration settings
'process_expired_credits_frequency' => env('LARSASUB_PROCESS_EXPIRED_CREDITS_FREQUENCY', 'daily'),
'notify_expiring_credits_frequency' => env('LARSASUB_NOTIFY_EXPIRING_CREDITS_FREQUENCY', 'daily'),
'notify_credit_expiration_days_before' => env('LARSASUB_NOTIFY_CREDIT_EXPIRATION_DAYS_BEFORE', 14),
// Validate mandates command
'validate_mandates_frequency' => env('LARSASUB_VALIDATE_MANDATES_FREQUENCY', 'weekly'),
'validate_mandates_delay_ms' => env('LARSASUB_VALIDATE_MANDATES_DELAY_MS', 100),
// Timezone for scheduled commands
'timezone' => env('LARSASUB_SCHEDULING_TIMEZONE', null),
// Max charges per execute-charges run (null = no limit)
'execute_charges_limit' => env('LARSASUB_EXECUTE_CHARGES_LIMIT'),
],
execute_charges_limit bounds one larsasub:execute-charges run. It is one
budget for the whole run, shared by the subscription and standalone loops,
and it counts every charge the run takes up — including skipped ones, which
still cost queries and writes. Leftover charges keep their past planned_on
and are picked up by the next run, so a backlog drains by itself. Non-positive
values mean no limit; the default is no limit.
Do not set a cap without sizing it. Capacity is
limit × runs per day, andlarsasub:end-billable-subscriptionsis not limited: if execution lags past a fixed-term subscription'send_date, that subscription is deactivated first and its last charge is then parked withneeds_attentioninstead of collected — and a charge on an inactive subscription is not payable by the customer either. A too-low limit is a money bug, not a performance knob.
Full explanation, including how to size the limit and which subscriptions the race affects: Scheduled commands.
Policy System Configuration
'policies' => [
// Always enforced; customize per model/action below, or bind your own
// policy class for a LarsaSub model via Gate::policy()
// Admin role configuration
'admin_role' => ConfigHelpers::parseAdminRole(env('LARSASUB_ADMIN_ROLE', '')),
// Context detection for admin areas
'context' => [
'detection_method' => 'route_prefix',
'admin_route_prefix' => 'nova',
],
// Per-model permissions
'models' => [
'subscription' => ['admin_only' => true],
'billable_subscription' => [
'ownership_required' => true,
'actions' => [
'viewAny' => 'owner_or_admin',
'view' => 'owner_or_admin',
'create' => false,
'update' => 'admin',
'delete' => false,
'subscribe' => true,
'cancel' => 'owner_or_admin',
'terminate' => 'owner_or_admin',
'retryPayment' => 'owner_or_admin',
'changeSubscription' => 'owner_or_admin',
'changePaymentMethod' => 'owner_or_admin',
],
],
'billable_credit' => [
'ownership_required' => true,
'actions' => [
'viewAny' => 'owner_or_admin',
'view' => 'owner_or_admin',
'purchase' => env('LARSASUB_SELF_SERVICE_CREDITS', false),
'addCredit' => 'admin',
],
],
'credit_transaction' => [
'ownership_required' => true,
'actions' => [
'viewAny' => 'owner_or_admin',
'view' => 'owner_or_admin',
'create' => false,
'update' => false,
],
],
// ... other models
],
],
Mollie Connect Configuration
Connect requires the Platform tier. It settles payments to Mollie accounts that are not yours, which is what that tier covers. Declare it with
LARSASUB_LICENSE_TIER=platform(orlarsasub.license.tierin your published config) before enabling Connect; on any other tier the package refuses to boot rather than bill quietly on a license that does not cover it.The declaration is a local config value and nothing more. It is not verified, not signed and never sent anywhere — the package does not contact larsasub.eu, here or anywhere else. Run
php artisan larsasub:licenseto see what this installation declares. Pricing: https://larsasub.eu/pricing
'license' => [
// 'single' | 'unlimited' | 'platform' — Connect below needs 'platform'
'tier' => env('LARSASUB_LICENSE_TIER', 'single'),
],
'connect' => [
'enabled' => env('LARSASUB_CONNECT_ENABLED', false),
'tenant_relation' => env('LARSASUB_CONNECT_TENANT_RELATION', null),
'tenant_label' => env('LARSASUB_CONNECT_TENANT_LABEL', 'organization'),
'tenant_name_attribute' => env('LARSASUB_CONNECT_TENANT_NAME_ATTRIBUTE', 'name'),
'tenant_owner_attribute' => env('LARSASUB_CONNECT_TENANT_OWNER_ATTRIBUTE', 'owner_id'),
'client_id' => env('MOLLIE_CONNECT_CLIENT_ID'),
'client_secret' => env('MOLLIE_CONNECT_CLIENT_SECRET'),
'platform_organization_id' => env('MOLLIE_ORGANIZATION_ID'),
'scopes' => ['payments.read', 'payments.write', /* ... */],
'fee' => [
'enabled' => env('LARSASUB_CONNECT_FEE_ENABLED', false),
'type' => env('LARSASUB_CONNECT_FEE_TYPE', 'percentage'),
'value' => env('LARSASUB_CONNECT_FEE_VALUE', 10),
'description' => env('LARSASUB_CONNECT_FEE_DESCRIPTION', 'Platform fee'),
],
'notifications' => [
'also_notify_platform' => env('LARSASUB_CONNECT_ALSO_NOTIFY_PLATFORM', false),
],
],
Redirect Route Names
'redirect' => [
'first_payment' => 'larsasub.my-subscription.view',
'manual_charge_payment' => 'larsasub.my-subscription.view',
'single_payment' => 'larsasub.my-payments.view',
'mandate_setup' => 'larsasub.my-payment-methods.view',
'connect_callback' => 'larsasub.tenant-connect.view',
],
Charge Reference Codes
'reference' => [
'first_payment_prefix' => 'FP',
'recurring_charge_prefix' => 'RC',
'single_payment_prefix' => 'SP',
],
Models
BillableSubscription
The main subscription model that binds a base Subscription plan to a billable user.
Key Properties:
| Property | Type | Description |
|---|---|---|
subscription_id |
integer | Reference to Subscription plan |
billable_id |
integer | Reference to Billable intermediary model |
payment_method_id |
integer | Reference to PaymentMethod |
activated_at |
datetime | When subscription became active |
deactivated_at |
datetime | When subscription was deactivated |
activated_status |
string | Human-readable status text (display/audit only — never query it) |
deactivation_reason |
DeactivationReason|null | Machine-readable reason the subscription was deactivated (enum; null while active or for pre-0.12 rows) |
start_date |
date | Subscription start date |
end_date |
date | Subscription end date (null = continuous) |
duration |
string | '1 month', '3 month', '12 month', 'continuous' |
is_charged |
boolean | Whether subscription charges fees |
price_amount |
decimal | Regular price |
price_currency |
string | Currency code |
introduction_duration |
string | Intro period duration |
introduction_price_amount |
decimal | Intro period price |
cancellation_notice_period |
string | Notice period before cancellation |
renewal_cancellation_notice_period |
string | Notice period for renewal cancellation |
automatic_renewal |
boolean | Auto-renew at end date |
feature_overrides |
array | Override features for this subscription |
canceled_at |
datetime | When subscription was canceled |
credit_amount |
float | Credit amount applied to subscription |
renewal_announced_at |
datetime | When renewal was announced to user |
Status Methods:
$subscription->isActive(); // Currently active
$subscription->isUpcoming(); // Activated but future start date
$subscription->isInactive(); // Not active
$subscription->isCancelled(); // Canceled
$subscription->isChargeable(); // Has charges and price > 0
$subscription->willRenew(); // Will auto-renew
$subscription->willEnd(); // Will end without renewal
$subscription->isInIntroductionPeriod(); // In intro pricing period
$subscription->hasFailedFirstPayment(); // First payment failed (keys on deactivation_reason)
$subscription->state(); // SubscriptionState enum: Pending|Upcoming|Active|Ended|Deactivated, derived from the timestamps
$subscription->remainingIntroductionPayments(); // Number of intro payments left
$subscription->currentEffectivePrice(); // Current price (intro or regular)
Relations:
$subscription->subscription; // Base Subscription model
$subscription->billable; // Billable intermediary model
$subscription->paymentMethod; // Payment method used
$subscription->providerMandate; // Provider mandate (polymorphic)
$subscription->recurringCharges; // All recurring charges
$subscription->paidRecurringCharges; // Only paid charges
$subscription->unpaidRecurringCharges; // Only unpaid charges
$subscription->plannedRecurringCharges; // Unpaid, not yet executed
$subscription->pendingRecurringCharges; // Unpaid but executed, pending completion
Actions:
$subscription->activate('status'); // Activate subscription
$subscription->deactivate('status'); // Deactivate subscription
$subscription->cancelFutureCharges($afterDate); // Cancel future charges
$subscription->markRenewalAnnounced(); // Mark renewal as announced
$subscription->switchMandate($mandate); // Switch to different mandate
Feature Methods:
$subscription->features(); // Get features array
$subscription->hasFeature('feature_key'); // Check if has feature
$subscription->featureValue('key', $default); // Get feature value
$subscription->getNextBillingDate(); // Get next billing date
Query Scopes:
// Status scopes
BillableSubscription::isActive()->get(); // Active subscriptions
BillableSubscription::isUpcoming()->get(); // Upcoming subscriptions
BillableSubscription::isInactive()->get(); // Inactive subscriptions
BillableSubscription::isActivated()->get(); // Subscriptions with activated_at set
BillableSubscription::isNotActivated()->get(); // Subscriptions without activated_at
BillableSubscription::isDeactivated()->get(); // Subscriptions with deactivated_at set
BillableSubscription::isNotDeactivated()->get(); // Subscriptions without deactivated_at
// Date scopes (accepts optional Carbon date parameter)
BillableSubscription::isActive($checkDate)->get();
BillableSubscription::isUpcoming($checkDate)->get();
BillableSubscription::isInactive($checkDate)->get();
// Renewal scopes
BillableSubscription::renewalNotAnnounced()->get(); // Renewal not yet announced
BillableSubscription::announcementDateReached()->get(); // Announcement date has passed
// Charging scopes
BillableSubscription::isChargeable()->get();
// Duration scopes
BillableSubscription::hasEndDate()->get();
BillableSubscription::hasNoEndDate()->get();
// Renewal scopes
BillableSubscription::hasAutoRenewal()->get();
BillableSubscription::hasNoAutoRenewal()->get();
// Date range scopes
BillableSubscription::endDatePassed()->get();
BillableSubscription::endDateNotPassed()->get();
Billable
The intermediary model between your application's owner model (User, Team, Company) and all LarsaSub billing entities. Stored in the larsasub_billables table.
Your application model (the "owner") is linked to a Billable via a polymorphic owner_id/owner_type relationship. All billing entities (subscriptions, charges, credits, payments) reference the Billable's id — not the owner's id directly.
Billable records are created lazily via BillableResolver::getOrCreateBillable() the first time a billing operation occurs (e.g., subscribing, creating a payment).
Key Properties:
| Property | Type | Description |
|---|---|---|
profile_code |
string | Unique code for dashboards (e.g. A7K3MX2P or LCRA-A7K3MX2P with tenant) |
owner_id |
integer | Polymorphic owner ID |
owner_type |
string | Polymorphic owner type |
invoice_type |
string | personal or business. The B2B marker: it decides whether the three business fields below are part of the invoice snapshot and whether the EU reverse charge can apply. Switching away from business clears them. |
invoice_first_name |
string | Invoice first name |
invoice_last_name |
string | Invoice last name |
invoice_email |
string | Invoice email |
invoice_company_name |
string | Company name (business type) |
invoice_vat_number |
string | VAT number (business type) |
invoice_coc_number |
string | Chamber of Commerce number (business type) |
invoice_address_line_1 |
string | Address line 1 |
invoice_address_line_2 |
string | Address line 2 |
invoice_city |
string | City |
invoice_postal_code |
string | Postal code |
invoice_state |
string | State/province |
invoice_country |
string | ISO 3166-1 alpha-2 country code |
Relations:
$billable->owner(); // Polymorphic — the consuming app model (User, Team, Company)
$billable->subscriptions(); // HasMany BillableSubscription
$billable->recurringCharges(); // HasMany RecurringCharge
$billable->singlePayments(); // HasMany SinglePayment
$billable->credit(); // HasOne BillableCredit
$billable->creditTransactions(); // HasMany CreditTransaction
$billable->mollieCustomers(); // HasMany MollieCustomer
Key Methods:
// BillableModel interface delegation (proxied to owner)
$billable->getBillableNameAttribute();
$billable->getBillableEmailAttribute();
$billable->getBillableIdentifierAttribute();
$billable->getNotifiableUsers();
$billable->getTenantNotifiableUsers();
$billable->getTenants();
// Tenant resolution
$billable->resolveTenant(); // Resolve the tenant for this billable
$billable->isPlatformBillable(); // Check if this is a platform billable (dual billing)
// Invoice details
$billable->invoiceDetails; // Get formatted invoice details array
$billable->populateDefaultInvoiceDetails(); // Populate from owner model
// Mandates
$billable->providerMandates($tenant); // All mandates (optional tenant filter)
$billable->usableMandates($tenant); // Active, usable mandates
// Profile code
Billable::generateProfileCode($tenantCode); // Generate unique profile code
Subscription
The base subscription plan/template model. In Connect mode, plans can be global (available to all tenants) or tenant-specific.
Key Properties:
| Property | Type | Description |
|---|---|---|
name |
string | Plan name |
description |
string | Plan description |
content |
text | Detailed content (HTML) |
internal_note |
text | Internal notes |
active |
boolean | Whether plan is available |
start_date |
date | Plan availability start |
end_date |
date | Plan availability end |
duration |
string | Plan duration |
is_charged |
boolean | Whether plan has charges |
price_amount |
decimal | Regular price |
price_currency |
string | Currency |
introduction_duration |
string | Intro period length |
introduction_price_amount |
decimal | Intro period price |
cancellation_notice_period |
string | Notice before cancellation |
automatic_renewal |
boolean | Auto-renew default |
feature_list |
string | Feature codes (comma-separated) |
order_nr |
integer | Display order |
tenant_id |
integer | Tenant model ID (null = global plan) |
tenant_type |
string | Tenant model type (polymorphic) |
fee_type |
string | Per-plan fee override: 'percentage' or 'fixed' (null = use config) |
fee_value |
decimal | Per-plan fee value override (null = use config default) |
Duration Options:
1 month3 month6 month12 monthcontinuous
Query Scopes:
// Enabled-flag scopes (from HasActiveState trait)
Subscription::isEnabled()->get();
Subscription::isDisabled()->get();
// Tenant scoping (shared ScopedToTenant trait; explicit null policy)
Subscription::forTenant($tenant)->get(); // strictly the tenant's plans
Subscription::forTenant($tenant, includeGlobal: true)->get(); // tenant plans + global plans
Subscription::forTenant(null)->get(); // global (tenantless) plans only
// Charging scopes
Subscription::isChargeable()->get();
// Duration scopes
Subscription::hasEndDate()->get();
Subscription::hasNoEndDate()->get();
// Renewal scopes
Subscription::hasAutoRenewal()->get();
Subscription::hasNoAutoRenewal()->get();
// Date range scopes
Subscription::endDatePassed()->get();
Subscription::endDateNotPassed()->get();
RecurringCharge
Individual recurring payment record for subscriptions.
Key Properties:
| Property | Type | Description |
|---|---|---|
billable_id |
integer | Reference to Billable intermediary model |
billable_subscription_id |
integer | Associated subscription |
payment_method_id |
integer | Payment method |
amount |
decimal | Charge amount (after credit applied) |
original_amount |
decimal | Original charge amount before credit |
credit_applied |
decimal | Amount of credit applied |
currency |
string | Currency |
description |
string | Charge description |
amount_breakdown |
array | Snapshot of charge composition (base price + cost lines), set at planning time |
notify |
boolean | Whether to notify user |
notified_at |
datetime | When user was notified |
planned_on |
date | Planned charge date |
charged_at |
datetime | When charge was executed |
paid_on |
datetime | When charge was paid |
provider_payment_id |
string | Mollie payment ID |
provider_payment_status |
string | Current provider status |
provider_url |
string | Link to payment (for manual) |
needs_attention |
boolean | Requires admin attention |
payable |
boolean | Can be retried/paid manually |
superseded_by_charge_id |
integer | ID of charge that superseded this one |
Status Methods:
$charge->isPaid(); // Has been paid
$charge->isNotPaid(); // Not yet paid
$charge->isSuperseded(); // Was superseded by another charge
$charge->hasCreditApplied(); // Credit was applied to this charge
$charge->does_need_attention; // Needs admin attention
$charge->is_payable; // Can be paid manually
$charge->is_continuable; // Payment URL still open
Relations:
$charge->billableSubscription; // Associated subscription
$charge->paymentMethod; // Payment method used
$charge->supersededBy; // Charge that superseded this one
$charge->supersededCharges; // Charges superseded by this one
$charge->creditTransactions; // Credit transactions applied
Status Change Actions:
$charge->onNotified(); // User notified
$charge->onCharged(); // Charge executed
$charge->onPaid($log, $isFirst); // Charge paid
$charge->onCanceled($isFirst); // Charge canceled
$charge->onExpired($isFirst); // Charge expired
$charge->onFailed($isFirst); // Charge failed
$charge->onChargeback(); // Chargeback received
$charge->onRefunded(); // Charge refunded
Query Scopes:
// Charge execution scopes
RecurringCharge::isCharged()->get();
RecurringCharge::isNotCharged()->get();
// Payment status scopes
RecurringCharge::isPaid()->get();
RecurringCharge::isNotPaid()->get();
// Superseding scopes
RecurringCharge::notSuperseded()->get();
// Planned date scopes
RecurringCharge::plannedOnDatePassed()->get();
RecurringCharge::plannedOnDateNotPassed()->get();
RecurringCharge::plannedBetween('2025-01-01', '2025-12-31')->get();
// Subscription relation scopes
RecurringCharge::isSubscriptionCharge()->get();
RecurringCharge::isStandaloneCharge()->get();
RecurringCharge::belongsToBillableSubscription($subscription)->get();
// Notification scopes
RecurringCharge::requiresNotification()->get(); // notify = true
RecurringCharge::notNotified()->get(); // notified_at is null
RecurringCharge::notificationDue($days)->get(); // planned_on within X days
// Attention & payable scopes
RecurringCharge::noAttentionNeeded()->get();
RecurringCharge::isNotPayable()->get();
// Billable relation scopes
RecurringCharge::belongsToBillable($user)->get();
// Provider status scopes
RecurringCharge::hasFailedProviderStatus()->get();
RecurringCharge::hasPendingProviderStatus()->get();
SubscriptionCostLine
Recurring cost line (addon/module) on a BillableSubscription, billed on top of the base price in the monthly charge. The consuming app owns which lines exist (its module/addon catalog); LarsaSub owns the billing math. Mutate lines through SubscriptionCostService — never directly.
Key Properties:
| Property | Type | Description |
|---|---|---|
billable_subscription_id |
integer | Owning subscription instance |
key |
string | Consumer identifier, e.g. module:agenda — one active line per key |
description |
string | Line description (also used on charge breakdown/invoices) |
amount |
decimal | Monthly amount |
starts_on |
date | First active day (inclusive) |
ends_on |
date/null | Last active day (inclusive); null = open-ended |
billed_through |
date/null | Last day covered by a generated charge — drives proration catch-up |
Proration model (per calendar day, inclusive of the first day):
- A line active for a full charge period bills its flat monthly
amount— steady state is exact, no rounding drift. - Partial periods bill
amount * activeDays / periodDays. - Lines added mid-period are caught up on the next monthly charge: the unbilled window since
starts_onis valued against the period(s) it falls in. - Ending a line before its
billed_throughrefunds the overbilled days as credit (CreditTransaction::TYPE_COST_LINE_ADJUSTMENT), which the next charge consumes automatically.
SubscriptionCostService (consumer API):
use PetersDevelopment\LarsaSub\Service\SubscriptionCostService;
$service = app(SubscriptionCostService::class);
// Toggle a module on: starts today, billed (prorated) from today on the next charge
$line = $service->addLine($billableSubscription, 'module:agenda', 'Module: Agenda', 7.50);
// Toggle off: active through today; prepaid days after today are refunded as credit
$service->endLine($billableSubscription, 'module:agenda');
// Price change: old line ends today, new amount effective tomorrow
$service->changeLineAmount($billableSubscription, 'module:agenda', 10.00);
// Introspection
$service->activeLines($billableSubscription); // Active lines today
$service->monthlyTotal($billableSubscription); // Base price + active lines (steady-state "per month")
// Lifecycle (called by LarsaSub internally on subscription change)
$service->endAllLines($billableSubscription, $date); // End everything, refund prepaid days
Charge aggregation: RecurringChargeService::planChargeForCurrentMonth() aggregates base price + cost lines via SubscriptionCostService::calculateChargeAmount(), stores the composition snapshot on RecurringCharge->amount_breakdown, and advances each line's billed_through in the same transaction. When the charge is paid, InvoiceService::createForCharge() turns each breakdown entry into an InvoiceLine (see below). Breakdown entries look like:
[
['type' => 'subscription', 'key' => null, 'description' => 'Basic Plan', 'amount' => 30.00, 'billed_from' => '2026-08-01', 'billed_until' => '2026-08-31'],
['type' => 'cost_line', 'key' => 'module:agenda', 'description' => 'Module: Agenda', 'amount' => 4.84, 'monthly_amount' => 7.50, 'billed_from' => '2026-08-12', 'billed_until' => '2026-08-31'],
]
Subscription change: BillableSubscriptionService::changeSubscription() rolls back cost-line bookkeeping for planned-but-never-executed charges (rollbackChargeLines), then ends all lines per yesterday — prepaid days are refunded as credit and applied to the new subscription's first charge. Cost lines do not transfer to the new subscription; the consuming app re-adds them (it owns the module state).
Subscribe with an existing mandate — immediate vs deferred: two entry points reuse a usable mandate without a checkout, differing in when the first charge and activation happen.
// Immediate: charges + activates now (activation on payment; zero-amount activates at once).
// Use when the subscriber must pay now, e.g. re-subscribing after expiry.
// createSubscriptionWithMandate lives on the PaymentProviderService, not BillableSubscriptionService:
$provider = PaymentProviderFactory::make('mollie'); // PaymentProviderService
$provider->createSubscriptionWithMandate($owner, $subscription, $method, $mandate); // → RedirectResponse
// Deferred: "mandate now, first charge later" — activates immediately with a (future) start_date,
// makes NO provider call. The subscription is upcoming until start_date (host trial access keeps
// applying); the monthly planner bills on/after start_date via the mandate. Use to honor a
// remaining trial. Past start dates clamp to today. Requires a recurring method + a usable mandate
// that belongs to the owner's billable and matches the method (re-verified under a row lock).
$sub = app(BillableSubscriptionService::class)
->createDeferredMandateSubscription($owner, $subscription, $method, $mandate, $trialEndsAt); // → BillableSubscription
Deferring is deliberate for direct debit: SEPA settlement is asynchronous, so access follows activation, not payment confirmation. Do not use the immediate variant during a trial — it would charge at once and forfeit the remaining trial days. Add the subscriber's add-ons with SubscriptionCostService::addLine(..., startsOn: $sub->start_date) — the stored, normalized start date (a past $trialEndsAt clamps to today) — so the first charge bills from the start date, not the trial.
Relations & Scopes:
$billableSubscription->costLines; // HasMany
$line->billableSubscription; // BelongsTo
SubscriptionCostLine::activeOn($date)->get(); // Active on date (inclusive window)
SubscriptionCostLine::forKey('module:agenda')->get();
SubscriptionCostLine::isOpen()->get(); // ends_on is null
$line->isActiveOn($date); // bool
BillableCredit
Credit account model for billable models. One credit account per billable.
Key Properties:
| Property | Type | Description |
|---|---|---|
billable_id |
integer | Reference to Billable intermediary model |
balance |
decimal | Current credit balance |
currency |
string | Currency code |
Methods:
$credit->hasCredit(); // Check if has available credit
$credit->getAvailableCredit(); // Get available credit balance
Relations:
$credit->billable; // Billable intermediary model
$credit->transactions; // All credit transactions
Query Scopes:
BillableCredit::forBillable($billableId)->get(); // Find by billable
BillableCredit::hasBalance()->get(); // Accounts with balance > 0
CreditTransaction
Credit transaction history with full audit trail.
Key Properties:
| Property | Type | Description |
|---|---|---|
billable_credit_id |
integer | Credit account ID |
amount |
decimal | Transaction amount (positive/negative) |
balance_before |
decimal | Balance before transaction |
balance_after |
decimal | Balance after transaction |
type |
string | Transaction type |
description |
string | Transaction description |
source_type |
string | Polymorphic source type |
source_id |
integer | Polymorphic source ID |
recurring_charge_id |
integer | Related recurring charge |
expires_at |
datetime | Expiration date for credits |
expired |
boolean | Whether credit has expired |
expiration_notified_at |
datetime | When expiration notice was sent |
performed_by_user_id |
integer | User who performed transaction |
meta |
array | Additional metadata |
Transaction Types:
| Type | Description |
|---|---|
manual |
Manually added credit |
refund |
Credit from refund |
promotional |
Promotional credit |
charge_deduction |
Credit applied to charge |
expiration |
Credit expired |
Methods:
$transaction->isAddition(); // Positive amount
$transaction->isDeduction(); // Negative amount
$transaction->isExpired(); // Has expired
$transaction->onExpirationNotified(); // Mark as notified
Relations:
$transaction->billableCredit; // Credit account
$transaction->source; // Polymorphic source
$transaction->recurringCharge; // Related recurring charge
$transaction->performedBy; // User who performed
Query Scopes:
CreditTransaction::additions()->get(); // Positive amounts
CreditTransaction::isExpired()->get(); // Expired
CreditTransaction::expirationDue($days)->get(); // Expiring within X days
CreditTransaction::notExpirationNotified()->get(); // Not notified
InvoiceLine
Immutable line on a LarsaSub Invoice — the base subscription price, a prorated cost line (module/addon), a single payment, or (future) an order line. Lines are created together with the invoice by InvoiceService and never mutated afterwards.
Key Properties:
| Property | Type | Description |
|---|---|---|
invoice_id |
integer | Owning invoice |
key |
string/null | Origin cost-line key (e.g. module:agenda); null for base/single lines |
description |
string | Line description |
quantity |
decimal | Always 1 for subscription lines (supports future order lines) |
unit_amount / amount |
decimal | Line amount, net of VAT (invoice subtotal semantics) |
vat_rate / vat_amount |
decimal/null | Per-line VAT (opt-in); null = invoice-level VAT is authoritative |
period_start / period_end |
date/null | Billed period for prorated lines |
sort_order |
integer | Display order |
Amount semantics: line amounts follow the invoice's subtotal (net of VAT). With prices_include_vat = true the charge amounts are converted to net per line; any per-line rounding drift is absorbed by the last line so lines always sum exactly to the invoice subtotal. The invoice-level vat_amount/total remain authoritative.
Per-subscription VAT rate (opt-in): Subscription and BillableSubscription carry a nullable vat_rate (copied from the plan at subscription creation). InvoiceService resolves the rate as BillableSubscription → Subscription → config('larsasub.invoicing.vat_rate'); a rate of 0 is an explicit 0% (VAT-exempt), distinct from null. Consumers that configure nothing keep the config rate and existing behavior — VAT is never required.
Sources:
InvoiceService::createForCharge()— one line peramount_breakdownentry (base + cost lines, with billed periods); single fallback line for charges without a breakdown.InvoiceService::createForSinglePayment()— one line perSinglePaymentLinewhen the payment has order lines (per-line VAT split; mixed rates set the invoice-levelvat_rateto null and the lines carry the authoritative rates), otherwise one line with the payment description.- Invoices created before invoice lines existed have no lines; the PDF falls back to the single-description row.
Currency: the currency argument is validated against larsasub.currency.allowed (case-insensitively normalized); a currency outside the list throws a SinglePaymentException.
Order lines on SinglePayment (opt-in): pass lines to SinglePaymentService::createSinglePayment() — each ['description', 'unit_amount', 'quantity' => 1, 'vat_rate' => null]. Line totals must sum to the payment amount; per-line vat_rate is optional (null = config rate). Single-amount payments keep working unchanged.
Usage:
$invoice->lines; // HasMany, ordered by sort_order
$invoice->hasLines(); // bool
InvoiceResource::make($invoice->load('lines')); // includes 'lines' via InvoiceLineResource
PaymentMethod
Payment method configuration model.
Key Properties:
| Property | Type | Description |
|---|---|---|
name |
string | Method name |
description |
string | Description |
provider |
string | Provider code ('mollie') |
provider_id |
string | Provider-specific ID |
active |
boolean | Active/inactive |
recurring |
boolean | Supports recurring payments |
single |
boolean | Supports single payments |
first_payment_method_id |
integer | Method for first payment |
Query Scopes:
// Enabled-flag scopes (from HasActiveState trait)
PaymentMethod::isEnabled()->get();
PaymentMethod::isDisabled()->get();
// Payment type scopes
PaymentMethod::isRecurring()->get();
PaymentMethod::isSingle()->get();
ModelLog
Activity log for all models.
Key Properties:
| Property | Type | Description |
|---|---|---|
user_id |
integer | User who triggered the log |
message |
string | Log message |
loggable_type |
string | Polymorphic model type |
loggable_id |
string | Polymorphic model ID |
meta |
array | Additional metadata |
Common Model Scopes (BaseModel)
All LarsaSub models inherit from BaseModel and have access to these common scopes:
// Exclude specific model by ID
Model::isNotId($modelOrId)->get();
// Filter by creation time
Model::createdMoreThanHoursAgo(24)->get(); // Created more than 24 hours ago
Traits
IsBillableModel
Add to your User or billable model. This trait provides core billing relations through the Billable intermediary:
use PetersDevelopment\LarsaSub\Traits\IsBillableModel;
class User extends Authenticatable implements BillableModel
{
use IsBillableModel;
}
Provides:
// Billable registration
$user->larsasubBillable(); // MorphOne relation to Billable intermediary
// Relation helpers (route through Billable)
$user->hasOneThroughBillable(Model::class); // HasOneThrough via Billable (with owner_type guard)
$user->hasManyThroughBillable(Model::class); // HasManyThrough via Billable (with owner_type guard)
// Credit methods
$user->credit(); // Get credit account relation (via Billable)
$user->creditTransactions(); // Get credit transactions (via Billable)
$user->getCreditBalance(); // Get current credit balance
$user->hasCredit(); // Check if has available credit
// Single payments
$user->singlePayments(); // Get all single payments (via Billable)
// Mandate methods (tenant-aware in Connect mode)
$user->providerMandates($tenant); // Get all mandates (optional tenant filter)
$user->usableMandates($tenant); // Get usable mandates (optional tenant filter)
$user->hasUsableMandate(); // Check if has usable mandate
$user->countUsableMandates(); // Count usable mandates
// Notification recipients
$user->getNotifiableUsers(); // Get users to notify (for Team/Company scenarios)
$user->getTenantNotifiableUsers(); // Get tenant-specific notification recipients (Connect mode)
// Tenant methods (Connect mode)
$user->getTenants(); // Get all tenants this billable belongs to
// Logging (from HasModelLogs)
$user->log('message'); // Log activity
HasSubscriptions
Opt-in trait for subscription convenience methods. Add alongside IsBillableModel when your model needs subscription and feature access:
use PetersDevelopment\LarsaSub\Traits\HasSubscriptions;
use PetersDevelopment\LarsaSub\Traits\IsBillableModel;
class User extends Authenticatable implements BillableModel
{
use IsBillableModel, HasSubscriptions;
}
Provides:
// Subscription relations (via Billable intermediary)
$user->activeSubscription(); // Get active subscription
$user->upcomingSubscription(); // Get upcoming subscription
$user->pendingSubscription(); // Get pending subscription
$user->failedFirstPaymentSubscription(); // Get subscription with failed first payment
// Feature methods
$user->featureList(); // Get features array from active subscription
$user->hasFeature('feature_key'); // Check if active subscription has feature
$user->featureValue('key'); // Get feature value from active subscription
Note:
HasSubscriptionsdepends onhasOneThroughBillable()fromIsBillableModel— always use both traits together.
Type-hinting against it. There is a matching interface,
Contracts\HasSubscriptions, covering all seven methods above. The package
never type-hints on it, so declaring it is optional — but the trait satisfies
the interface in full, so it costs nothing but the implements:
use PetersDevelopment\LarsaSub\Contracts\HasSubscriptions as HasSubscriptionsContract;
use PetersDevelopment\LarsaSub\Traits\HasSubscriptions;
use PetersDevelopment\LarsaSub\Traits\IsBillableModel;
class User extends Authenticatable implements BillableModel, HasSubscriptionsContract
{
use IsBillableModel, HasSubscriptions;
}
Add it when your own code — or a package you use — wants to accept "anything with subscriptions" instead of one concrete model.
HasBillableOwnership
Trait for ownership verification in Team/Company scenarios:
use PetersDevelopment\LarsaSub\Traits\HasBillableOwnership;
class RecurringCharge extends Model
{
use HasBillableOwnership;
}
Provides:
$model->ownedByUser($user); // Check if owned by user
Supports ownership through:
- Direct
billable_idattribute billableSubscriptionrelationshipbillableCreditrelationshipbillable()relationship
ConfigurableNotificationChannels
Trait for notifications to support configurable channels:
use PetersDevelopment\LarsaSub\Notifications\ConfigurableNotificationChannels;
class MyNotification extends Notification
{
use ConfigurableNotificationChannels;
}
Automatically reads channel configuration from larsasub.notification_channels config.
HasModelLogs
Automatically included in core models:
$model->log('Action performed', ['key' => 'value']);
$model->logs; // Get all logs
HasActiveState
For models with active/inactive status:
$model->isEnabled();
$model->isDisabled();
Model::isEnabled()->get();
Model::isDisabled()->get();
HasPaidState
For payment models:
$charge->isPaid();
$charge->isNotPaid();
$charge->paid; // Attribute
$charge->not_paid; // Attribute
RecurringCharge::isPaid()->get();
RecurringCharge::isNotPaid()->get();
IsMollieBillableModel
Mollie-specific functionality for billable models. Add this trait alongside IsBillableModel to enable Mollie payment features:
use PetersDevelopment\LarsaSub\PaymentProviders\Mollie\Traits\IsMollieBillableModel;
class User extends Authenticatable implements BillableModel
{
use IsBillableModel, IsMollieBillableModel;
}
Provides:
// Customer management (tenant-aware in Connect mode)
$user->mollieCustomers(); // HasMany relation to all MollieCustomers (multi-tenant)
$user->mollieCustomer(); // First/global customer (backward compatible)
$user->getMollieCustomer(); // Get or create MollieCustomer via Mollie API (scoped by tenant)
// Mandate management
$user->mollieMandates(); // HasMany relation to all MollieMandates
$user->mollieMandate($method); // Get MollieMandate for specific PaymentMethod
$user->createMollieMandate($method, $consumerAccount); // Create mandate for PaymentMethod
In Connect mode, getMollieCustomer() automatically scopes by tenant using resolveMollieTenant(). Customers and mandates are created under the tenant's Mollie account via their OAuth access token.
Helpers
NotificationRecipientResolver
Resolves and sends notifications to the appropriate recipients for a billable model. Supports both simple User billables and complex Team/Company scenarios.
use PetersDevelopment\LarsaSub\Helpers\NotificationRecipientResolver;
// Send notification to billable's notifiable users
NotificationRecipientResolver::notify($billable, new SomeNotification(...));
Resolution Strategy:
- Calls
getNotifiableUsers()on billable if method exists - Falls back to billable itself if it uses Laravel's Notifiable trait
- Returns empty collection if neither condition is met
BillableResolver
Resolves the billable model from an authenticated user. Handles Team/Company scenarios where User belongs to a billable entity. Also manages the Billable intermediary registration.
use PetersDevelopment\LarsaSub\Helpers\BillableResolver;
// Resolve the owner model from user (returns User or Team/Company based on config)
$owner = BillableResolver::resolve($user);
// Resolve from request
$owner = BillableResolver::fromRequest($request);
// Check if resolution is direct (User IS billable)
$isDirect = BillableResolver::isDirect();
// Look up existing Billable registration (read-only, never creates)
$billable = BillableResolver::getBillable($owner);
// Get or create Billable registration (lazy creation on first use)
// Use in write contexts: subscription creation, payment, profile update
$billable = BillableResolver::getOrCreateBillable($owner);
// Resolve the 4-character tenant reference code (Connect mode)
// Returns null when Connect is disabled or tenant has no connected account
$tenantCode = BillableResolver::resolveTenantCode($owner); // e.g. 'LCRA'
NotifyRole
Sends notifications to users based on role or email configuration.
use PetersDevelopment\LarsaSub\Helpers\NotifyRole;
// Send to configured role/emails
NotifyRole::send(
config('larsasub.notifications.sales'),
new RecurringChargeFailedNotification($charge)
);
Supports:
- Role name (string): Requires
spatie/laravel-permission - Email list (array): Works without additional dependencies
Events
Events are fired during the subscription lifecycle:
BillableSubscription Events
| Event | Description |
|---|---|
BillableSubscriptionActivatedEvent |
Fired when subscription activated (first payment success) |
BillableSubscriptionCancelledEvent |
Fired when subscription canceled |
BillableSubscriptionEndedEvent |
Fired when subscription period ends |
BillableSubscriptionRenewedEvent |
Fired when auto-renewal converts the subscription to continuous (in place; no new subscription is created) |
BillableSubscriptionRenewalAnnouncedEvent |
Fired when renewal announcement is sent |
RecurringCharge Events
| Event | Description |
|---|---|
RecurringChargePaidEvent |
Payment successful |
RecurringChargeFailedEvent |
Payment failed |
RecurringChargeCanceledEvent |
Payment canceled |
RecurringChargeExpiredEvent |
Payment expired |
RecurringChargeChargebackEvent |
Chargeback received |
RecurringChargeRefundedEvent |
Refund processed |
RecurringChargeChargedEvent |
Charge executed at provider |
RecurringChargeNotifiedEvent |
User notified |
Mandate Events
| Event | Description |
|---|---|
MandateMarkedUsableEvent |
Mandate marked as usable |
MandateMarkedUnusableEvent |
Mandate marked as unusable |
Credit Events
| Event | Description |
|---|---|
CreditAddedEvent |
Credit added to account |
CreditDeductedEvent |
Credit applied to a charge |
CreditExpiringEvent |
Credit expiring soon |
CreditExpiredEvent |
Credit has expired |
Notifications
User Notifications
| Notification | Description |
|---|---|
SubscriptionActivatedNotification |
Sent when first payment succeeds |
SubscriptionCancelledNotification |
Sent when subscription cancelled |
SubscriptionEndedNotification |
Sent when subscription period completes |
SubscriptionRenewalAnnouncedNotification |
Sent when renewal is upcoming |
SubscriptionRenewedNotification |
Sent when subscription is renewed |
SubscriptionChargeAnnouncedNotification |
Sent when subscription charge is upcoming |
StandaloneChargeAnnouncedNotification |
Sent when standalone charge is upcoming |
PaymentFailedNotification |
Payment failed (non-first) |
PaymentCanceledNotification |
Payment canceled by user (non-first) |
PaymentExpiredNotification |
Payment link expired (non-first) |
FirstPaymentFailedNotification |
First payment failed |
ChargebackNotification |
Chargeback occurred |
RefundNotification |
Refund processed |
User Credit Notifications
| Notification | Description |
|---|---|
CreditAddedNotification |
Sent when credit is added to account |
CreditExpiringNotification |
Sent when credit is about to expire |
CreditExpiredNotification |
Sent when credit has expired |
Company Notifications
| Notification | Description |
|---|---|
SubscriptionActivatedNotification |
Company notified of activation |
BillableSubscriptionCancelledNotification |
Company notified of cancellation |
RecurringChargeFailedNotification |
Payment failed |
RecurringChargeCanceledNotification |
Payment cancelled |
RecurringChargeExpiredNotification |
Payment expired |
RecurringChargeChargebackNotification |
Chargeback |
RecurringChargeRefundNotification |
Refund |
Company Credit Notifications
| Notification | Description |
|---|---|
CreditAddedNotification |
Company notified of credit addition |
CreditExpiringNotification |
Company notified of expiring credit |
CreditExpiredNotification |
Company notified of expired credit |
Testing Notifications
# List all available notifications
php artisan larsasub:test-notifications --list
# Send specific notification
php artisan larsasub:test-notifications ChargeFailedCompany --to=test@example.com
# Send all notifications
php artisan larsasub:test-notifications --all --to=test@example.com
# Send with specific scenario
php artisan larsasub:test-notifications SubscriptionActivatedUser --scenario=intro --to=test@example.com
Available Scenarios:
continuous- Continuous subscriptionintro- With introduction periodfixed- Fixed durationno-renewal- Without renewalrenewal-direct- Direct renewalrenewal-longer- Renewal with longer periodrenewal-same- Renewal with same periodrenewal-from-none- Renewal from no renewal
Multilang Plan Content
Plan content (Subscription.name/description/content and the snapshot copied onto BillableSubscription) can be stored per locale. Off by default — configure to opt in:
// config/larsasub.php
'locales' => [
'supported' => ['nl', 'en'], // or LARSASUB_LOCALES=nl,en; null = single-language (default)
'default' => 'nl', // language plain strings are; null = derived (see below)
'fallback' => null, // read fallback; null = app.fallback_locale
],
When default is null it is derived as the first supported locale — deliberately not app.locale, which locale middleware mutates per request and would make the default session-dependent. Setting it explicitly is recommended. An explicitly configured default outside supported is a configuration error (throws on write paths).
Reading — plain property access always returns the current locale (with fallback), so views, API resources and mails need no changes:
app()->setLocale('en');
$plan->name; // "Basic"
$plan->getTranslation('name', 'nl'); // "Basis"
$plan->getTranslation('name', 'en', false); // null unless 'en' is genuinely set (edit UIs)
$plan->getTranslations('name'); // ['en' => 'Basic', 'nl' => 'Basis']
A plain (unpromoted) stored string counts as the value for every locale — including with useFallback: false — until it is promoted or translated.
Writing — arrays replace the full set; string writes merge into the write locale: the current app locale when it is a supported content locale, otherwise the default locale. Other translations always survive, so admin forms that post resolved strings are lossless, and re-saving an unchanged value is a no-op:
Subscription::create(['name' => ['nl' => 'Basis', 'en' => 'Basic'], ...]);
$plan->setTranslation('name', 'en', 'Basic'); // one locale
$plan->forgetTranslation('name', 'en'); // remove (fallback applies again)
$plan->name = 'Basis v2'; // merges into the write locale (see above)
Customer locale — frozen text (charge descriptions on bank statements, invoice text, credit-ledger entries) and user-facing notification mails follow the customer's locale, stored on the Billable registration. LarsaSub captures it from the owner model's locale attribute; expose a different source by overriding the accessor from IsBillableModel:
public function getBillableLocaleAttribute(): ?string
{
return $this->profile?->language; // any source; null = app default
}
Migrating existing content — existing plain strings have an unknown language; convert them explicitly:
php artisan larsasub:promote-translations nl # wraps plain strings as {"nl": ...}
php artisan larsasub:promote-translations nl --dry-run
Until content is promoted/translated, viewers of other locales see the original string (by design, no signal).
Admin UIs — with multilang enabled, both bundled admin surfaces edit per locale:
- Nova (larsasub-nova ≥ phase 2): one form field per supported locale via
TranslatableNovaFields::expand(); only genuinely-set locales prefill, the default locale is required for the name. - Tenant dashboard (Connect mode): the subscription API ships resolved strings for display plus raw maps for editing —
name_translations,description_translations,content_translations({locale: value}, built fromgetTranslations(); a plain string reports only the default locale) and atranslations_orphanedflag. Writes accept a plain string (merge-write: only the write locale is touched — the requester's app locale when it is a supported content locale, otherwise the default locale; note this makes string writes session-dependent, so API consumers managing multiple languages should send maps) or a{locale: value}map (full replace; keys must be supported locales, default locale required for name). Building your own admin UI? Use this same payload shape so behavior stays consistent across surfaces. - Content stored under locales that were later removed from
supportedrenders as empty fields and is refused on save (HTTP 422 / Nova validation error) — migrate the data or restore the locale config first.
Notes & limits
- Company/admin notifications keep the recipient's own language (
HasLocalePreference); only customer-facing mail is forced to the customer's locale. SubscriptionCostService::addLine()descriptions are owned by your app — supply them in the customer's locale, or invoices mix languages. They are capped at 191 characters (SubscriptionCostService::MAX_DESCRIPTION_LENGTH) and rejected with aLarsaSubExceptionabove it: the label is copied onto invoice lines and into the credit ledger, where a translated refund text is composed around it, so it is validated rather than silently shortened.- Package UI strings ship in
en/nl; additional content locales fall back to English for mail/dashboard chrome unless you publish overrides (--tag=larsasub-translations). - Per-locale max lengths: name 64, description 510, content 65535 (enforced by the package, not the DB).
- Never remove a locale from
supportedwhile translated content exists — migrate the data first. - Nova index sort/search on plan name operates on the raw stored text while multilang is enabled; the tenant dashboard sorts on the resolved name.
- Tenant-defined features (created in the tenant dashboard) keep untranslated labels; only platform-configured
features.labelsresolve through__().
Payment Provider (Mollie)
Supported Payment Methods
Ten methods are seeded by larsasub:install (MollieSeeder), each with a
recurring and a single flag. All are seeded inactive: activate the ones
your Mollie account supports. tenant_usable controls availability in Connect
mode.
| Method | Recurring | Single | Notes |
|---|---|---|---|
| SEPA Direct Debit | ✅ | — | Mandate established through a first payment; first_payment_method_id points at iDEAL by default |
| Credit Card | ✅ | ✅ | Establishes its own mandate |
| PayPal | ✅ | ✅ | Establishes its own mandate; works worldwide |
| iDEAL | — | ✅ | NL; the default mandate opener for direct debit |
| Bancontact | — | ✅ | BE; can be used as the mandate opener instead of iDEAL |
| Apple Pay | — | ✅ | |
| KBC/CBC | — | ✅ | BE |
| Belfius | — | ✅ | BE |
| EPS | — | ✅ | AT |
| TWINT | — | ✅ | CH |
Recurring methods carry the automatic monthly collection. Single methods cover
one-time payments and the manual recovery payment of a failed charge (see
RecurringCharge::getIsPayableAttribute() — only failed, expired, canceled or
needs_attention charges are manually payable).
To collect SEPA mandates from Belgian customers through a first Bancontact
payment, point the direct-debit method's first_payment_method_id at the
Bancontact method.
Mollie Models
MollieCustomer - Customer reference in Mollie system. Tenant-aware: in Connect mode, customers are scoped by tenant_id/tenant_type (null = global).
MollieMandate - Direct debit mandate for recurring payments. Tenant-aware in Connect mode.
MollieConnectedAccount - Stores a tenant's OAuth connection to Mollie (Connect mode only). Key fields:
| Property | Type | Description |
|---|---|---|
reference_code |
string | 4-character unique code for tenant |
tenant_id |
integer | Polymorphic tenant ID |
tenant_type |
string | Polymorphic tenant type |
access_token |
string | Encrypted OAuth access token |
refresh_token |
string | Encrypted OAuth refresh token |
expires_at |
datetime | Token expiration time |
mollie_organization_id |
string | Tenant's Mollie organization ID |
mollie_profile_id |
string | Tenant's Mollie profile ID |
organization_name |
string | Human-readable organization name |
connected_at |
datetime | When connection was established |
disconnected_at |
datetime | When connection was revoked |
// Check connection status
$account->isConnected();
// Token refresh (with DB locking to prevent race conditions)
$account->refreshIfNeeded();
// Query scopes
MollieConnectedAccount::forTenant($tenant)->first();
MollieConnectedAccount::connected()->get();
Webhook Configuration
Configure webhook URL in Mollie dashboard pointing to your application:
https://your-domain.com/api/larsasub/webhooks/mollie/{method}/{type}
For local development with ngrok:
LARSASUB_NGROK_DOMAIN=your-subdomain.ngrok.io
Multi-Tenant / Mollie Connect
LarsaSub supports multi-tenant platforms via Mollie Connect. Each tenant gets their own Mollie account, payments go directly to the tenant, and the platform can charge application fees.
Billing Modes
| Mode | Config | Description |
|---|---|---|
| Simple (default) | connect.enabled = false |
Single global Mollie API key. All payments go to one account. Backward compatible. |
| Connect | connect.enabled = true |
Each tenant has their own Mollie account via OAuth. Payments go to tenant's account. |
| Dual Billing | connect.enabled = true + billable.platform_model set |
Platform charges tenants (global key) AND tenants charge users (Connect). |
Connect Configuration
// Required: Connect runs on the Platform tier only. Without this the package
// refuses to boot. See "Mollie Connect Configuration" above.
'license' => [
'tier' => 'platform',
],
'connect' => [
'enabled' => true,
'tenant_relation' => 'danceSchool', // Relation on billable → tenant
'tenant_label' => 'dance school', // UI label for tenant entity
'tenant_name_attribute' => 'name', // Attribute for tenant display name
'tenant_owner_attribute' => 'owner_id', // Who can manage Mollie connection
'client_id' => env('MOLLIE_CONNECT_CLIENT_ID'),
'client_secret' => env('MOLLIE_CONNECT_CLIENT_SECRET'),
'platform_organization_id' => env('MOLLIE_ORGANIZATION_ID'),
'scopes' => [
'payments.read', 'payments.write',
'refunds.read', 'refunds.write',
'customers.read', 'customers.write',
'mandates.read', 'mandates.write',
'profiles.read', 'organizations.read',
],
'fee' => [
'enabled' => true,
'type' => 'percentage', // 'percentage' or 'fixed'
'value' => 10, // 10% or €10.00
'description' => 'Platform fee',
],
'notifications' => [
'also_notify_platform' => false, // Also send to global sales team
],
],
MollieClientResolver
The MollieClientResolver intelligently routes Mollie API calls to the correct client:
use PetersDevelopment\LarsaSub\PaymentProviders\Mollie\MollieClientResolver;
// Get the correct Mollie client for a billable
$client = MollieClientResolver::forBillable($billable);
// Get client for a specific tenant
$client = MollieClientResolver::forTenant($tenant);
// Get client + profile ID (needed for Connect mode payments)
$context = MollieClientResolver::contextForBillable($billable);
// $context->client, $context->profileId, $context->organizationId
// Get the global platform client
$client = MollieClientResolver::platform();
Routing Logic:
- Simple mode → global
Mollie::api()client - Platform model billable → global client (uses platform's own key)
- Tenant billable → new client with tenant's OAuth access token
- Fallback → global client on any error
Each call creates a NEW MollieApiClient instance (not singleton) to prevent thread safety issues with concurrent payments across tenants.
Testmode with OAuth (Connect mode): API keys carry their mode in the key
prefix, but OAuth access tokens require an explicit testmode flag on every
call. Test mode is derived from the platform key prefix (test_). How the flag
is passed depends on the endpoint — mollie-api-php's create-request factories
silently drop unknown payload keys, so never merge testmode into a create
payload:
// Payment creation: pass as the $query argument via the context helper
$context->client->payments->create($paymentData, $context->testmodeQuery());
// Customer creation: pass as the separate $testmode argument
$client->customers->create([...], MollieClientContext::testmodeParams());
// GET/list/delete calls: pass as the query/testmode parameter
$client->payments->get($id, MollieClientContext::testmodeParams());
MollieConnectService
Manages the OAuth flow for connecting tenants to Mollie:
use PetersDevelopment\LarsaSub\PaymentProviders\Mollie\MollieConnectService;
$service = app(MollieConnectService::class);
// Get OAuth authorization URL for a tenant
[$url, $state] = $service->getAuthorizationUrl($tenant);
// Handle the OAuth callback (exchanges code for tokens)
$service->handleCallback($code, $state);
// Check connection status
$status = $service->getStatus($tenant);
// ['connected' => true, 'organization_name' => '...', 'connected_at' => '...']
// Check Mollie onboarding/verification status of the connected account
$onboarding = $service->getOnboardingStatus($tenant);
// ['status' => 'needs-data|in-review|completed', 'can_receive_payments' => bool, 'can_receive_settlements' => bool]
// null when not connected or the status could not be fetched
// Disconnect a tenant
$service->disconnect($tenant);
Onboarding status matters: payment methods can appear active on a connected
account's profile while the organization has not completed Mollie's verification.
Such an account cannot receive live payments — payment creation fails with
"The payment method is not activated on your account". The tenant connect
dashboard shows a warning when can_receive_payments is false.
OAuth Flow
- Tenant owner visits the tenant admin dashboard
- Clicks "Connect Mollie" →
MollieConnectController::authorize() - Validates user is tenant owner (via
tenant_owner_attribute) - Redirects to Mollie OAuth authorization page
- Tenant approves access, Mollie redirects to callback URL
MollieConnectController::callback()exchanges auth code for tokens- Fetches organization info and profile ID from Mollie
- Stores encrypted tokens in
larsasub_mollie_connected_accounts - Redirects back to tenant admin dashboard
Admin Dashboard Authorization
The management dashboards (plans, subscribers, charges, invoices) are mode-aware:
- Connect mode: the authenticated user must be the owner of the tenant, resolved by comparing the tenant's
connect.tenant_owner_attributecolumn (defaultowner_id) against the user's primary key. Anything else gets a 403. All data is scoped to that tenant. The Mollie-connection and application-fee dashboards exist only in this mode. - Simple mode: there is no tenant concept — the same dashboards act as operator management on the global (tenant-less) plans and their subscribers, charges and invoices. Authorization runs through the policy layer: configure
larsasub.policies.admin_role(a role name, e-mail list, callback or invokable class). With no admin role configured, nobody passes — a denied request logs an explanatory line.
Two consequences to be aware of:
- Single owner, no roles. Exactly one user — the one referenced by the owner attribute — can manage a tenant's Mollie connection, plans and subscribers. There is currently no delegation or role support; if your app has multi-admin tenants, route your own authorization in front and keep the owner attribute pointing at a designated account.
- The owner attribute must be configured correctly. The check silently depends on
tenant_owner_attributenaming a real column on your tenant model. A wrong attribute name meansisset()fails and nobody can manage the tenant — verify this when wiring up Connect mode.
Application Fees
When connect.fee.enabled = true, platform fees are added to tenant payments:
- Percentage: A percentage of the payment amount (e.g., 10% of €50 = €5.00 fee)
- Fixed: A fixed amount per payment (e.g., €0.25 per transaction)
- Per-plan override: Each Subscription plan can override the global fee via
fee_typeandfee_value
Fees are automatically skipped when:
- The billable is the platform model itself (self-transactions)
- The tenant's Mollie organization ID matches the platform's organization ID
Dual Billing
For platforms that both charge tenants and where tenants charge their own users:
'billable' => [
'model' => \App\Models\User::class, // Tenant's users (Connect account)
'platform_model' => \App\Models\DanceSchool::class, // Platform bills this (global key)
],
The MollieClientResolver routes automatically:
- Payments for
platform_model→ platform's global Mollie API key - Payments for
model→ tenant's Connect OAuth access token
Both models must implement BillableModel and use IsBillableModel.
Notification Routing (Connect Mode)
In Connect mode, notifications are routed differently:
- User notifications: Sent via
NotificationRecipientResolver. In Connect mode, emails show the tenant's name instead ofconfig('app.name'). - Company notifications: Sent via
CompanyNotificationResolver. Routes to the tenant's notifiable users viagetTenantNotifiableUsers(). - Platform notifications: When
connect.notifications.also_notify_platform = true, the global sales team also receives company notifications alongside the tenant.
Tenant Admin Dashboard
A standalone tenant admin page is available at the configured URL (default: billing/admin). The BuildsConnectContext trait provides the frontend with:
- Connection status
- Tenant information
- Authorize/disconnect URLs
Nova Integration
Laravel Nova admin resources for LarsaSub are available as a separate package: peters-development/larsasub-nova.
composer require peters-development/larsasub-nova
The core LarsaSub package has no Nova dependency. See the larsasub-nova package documentation for available resources and setup instructions.
Artisan Commands
Installation Commands
# Full installation
php artisan larsasub:install
# With sample subscriptions
php artisan larsasub:install --seed
# With active test data
php artisan larsasub:install --seed --active
Safe to run again. Re-running is the supported way to publish the assets
that ship with a new package version and to apply its migrations. Each step is
idempotent: payment methods are seeded with firstOrCreate, and a published
config/larsasub.php is kept — the installer never overwrites your
customizations. Pass --force-config when you deliberately want the package
default back.
The dashboard assets (--tag=larsasub-assets) are force-published, because
they are build artifacts that must match the installed version; a stale bundle
against a newer payload shape is a broken UI, not a customization.
The three demo-data options are the exception to "safe to run again": they
only act on a fresh install. On an installation that already has plans,
--seed, --active and --active-payment-methods are skipped with an
explanation — re-running them used to duplicate purchasable plans, re-enable
payment methods an operator had switched off, and attach demo subscriptions
to existing billables. Reseed through your own seeder instead.
| Option | Effect |
|---|---|
--seed |
Seed two example subscription plans (fresh install only) |
--active |
Seed active subscriptions and charges for existing billables (test data, fresh install only) |
--active-payment-methods |
Activate every seeded payment method (fresh install only) |
--vue-components |
Publish the Vue SFC sources for your own SPA build (never overwritten once published) |
--force-config |
Overwrite an existing config/larsasub.php with the package default |
--skip-migrations |
Do not run migrations; run php artisan migrate yourself |
larsasub:install calls php artisan migrate, which applies all pending
migrations in your application — not only this package's. An interactive run
asks before doing so; a non-interactive run (--no-interaction, deploy
scripts) proceeds and says so. Use --skip-migrations to keep the two steps
apart.
Scheduled Commands
These commands run automatically when scheduling is enabled:
| Command | Description |
|---|---|
larsasub:plan-charges |
Plan recurring charges for active subscriptions |
larsasub:execute-charges |
Execute planned charges |
larsasub:end-subscriptions |
Deactivate expired base subscriptions |
larsasub:end-billable-subscriptions |
Deactivate expired billable subscriptions |
larsasub:cleanup-unused-billable-subscriptions |
Clean up failed first payments |
larsasub:renew-billable-subscriptions |
Handle automatic subscription renewals |
larsasub:announce-renewal-billable-subscriptions |
Send renewal announcements |
larsasub:notify-charges |
Send upcoming charge notifications |
larsasub:process-expired-credits |
Process expired credit transactions |
larsasub:notify-expiring-credits |
Send credit expiration warnings |
larsasub:validate-mandates |
Validate usable mandates against the provider and mark revoked ones unusable (weekly by default; supports --limit) |
One-Time Commands
Run manually, never scheduled:
| Command | Description |
|---|---|
larsasub:promote-translations {locale} |
Promote plain plan content to {locale: ...} translation maps when opting into multilang (supports --dry-run) |
larsasub:generate-missing-invoices |
Generate invoices for paid RecurringCharges that have none — useful when migrating existing data (supports --limit and --dry-run) |
Testing Commands
# List notifications
php artisan larsasub:test-notifications --list
# Test specific notification
php artisan larsasub:test-notifications {key} --to=email
# Test all notifications
php artisan larsasub:test-notifications --all --to=email
Routes & Webhooks
User Dashboard Routes
URLs are configurable via standalone.url config. Defaults:
| Method | URL | Description |
|---|---|---|
| GET | /billing/dashboard |
View subscription |
| PUT | /billing/dashboard/cancel/{subscription} |
Cancel subscription |
| DELETE | /billing/dashboard/terminate/{subscription} |
Terminate subscription |
| POST | /billing/dashboard/retry-first-payment/{sub} |
Retry first payment |
| PUT | /billing/dashboard/change/{current}/{new} |
Change subscription |
| GET | /billing/payments |
View payments dashboard |
| GET | /billing/payment-methods |
View payment methods |
| GET | /billing/admin |
Tenant admin dashboard (Connect mode) |
Mollie Connect Routes (Connect mode only)
| Method | URL | Description |
|---|---|---|
| GET | /larsasub/mollie/connect/authorize |
Start OAuth flow |
| GET | /larsasub/mollie/connect/callback |
OAuth callback from Mollie |
Mollie Webhooks
| Method | URL | Description |
|---|---|---|
| POST | /api/larsasub/webhooks/mollie/{method}/first-payment |
First payment webhook |
| POST | /api/larsasub/webhooks/mollie/{method}/recurring-charge |
Recurring charge webhook |
| POST | /api/larsasub/webhooks/mollie/{method}/manual-charge |
Manual charge webhook |
| POST | /api/larsasub/webhooks/mollie/{method}/single-payment |
Single payment webhook |
Database Schema
All tables use configurable prefix (default: larsasub_):
| Table | Description |
|---|---|
larsasub_billables |
Billable intermediary — links owner models (User, Team) to all billing entities |
larsasub_subscriptions |
Base subscription plans (with tenant_id/tenant_type for Connect mode) |
larsasub_billable_subscriptions |
User subscriptions (billing, references larsasub_billables) |
larsasub_payment_methods |
Payment method configs |
larsasub_recurring_charges |
Recurring payment records |
larsasub_single_payments |
One-time payments |
larsasub_model_logs |
Activity logs |
larsasub_mollie_customers |
Mollie customer references (tenant-aware) |
larsasub_mollie_mandates |
Direct debit mandates (tenant-aware) |
larsasub_mollie_connected_accounts |
Tenant OAuth connections to Mollie (Connect mode) |
larsasub_billable_credits |
Credit accounts |
larsasub_credit_transactions |
Credit transaction history |
larsasub_billable_credits
CREATE TABLE larsasub_billable_credits (
id BIGINT PRIMARY KEY,
billable_id BIGINT NOT NULL,
balance DECIMAL(10,2) DEFAULT 0.00,
currency VARCHAR(6),
created_at TIMESTAMP,
updated_at TIMESTAMP,
UNIQUE (billable_id),
FOREIGN KEY (billable_id) REFERENCES larsasub_billables(id) ON DELETE CASCADE
);
larsasub_credit_transactions
CREATE TABLE larsasub_credit_transactions (
id BIGINT PRIMARY KEY,
billable_credit_id BIGINT NOT NULL,
amount DECIMAL(10,2),
balance_before DECIMAL(10,2),
balance_after DECIMAL(10,2),
type VARCHAR(32),
description VARCHAR(255),
source_type VARCHAR(255),
source_id BIGINT,
recurring_charge_id BIGINT,
expires_at TIMESTAMP,
expired BOOLEAN DEFAULT FALSE,
expiration_notified_at TIMESTAMP,
performed_by_user_id BIGINT,
meta JSON,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (billable_credit_id) REFERENCES larsasub_billable_credits(id) ON DELETE CASCADE,
FOREIGN KEY (recurring_charge_id) REFERENCES larsasub_recurring_charges(id) ON DELETE SET NULL,
FOREIGN KEY (performed_by_user_id) REFERENCES users(id) ON DELETE SET NULL
);
Testing
Running Tests
# Run all tests
vendor/bin/phpunit
# Run specific test
vendor/bin/phpunit --filter TestName
Test Environment
- Uses Orchestra Testbench for Laravel package testing
- In-memory SQLite database for test isolation
- Test base class:
tests/TestCase.php
Environment Variables Reference
# Core
LARSASUB_PROVIDER=mollie
LARSASUB_CURRENCY_DEFAULT=EUR
LARSASUB_TABLE_PREFIX=larsasub_
LARSASUB_NGROK_DOMAIN=
# Multilang plan content (optional; empty = single-language)
LARSASUB_LOCALES= # e.g. nl,en
LARSASUB_LOCALE_DEFAULT= # language plain strings are; empty = the FIRST supported locale (multilang) / app.locale (single)
LARSASUB_LOCALE_FALLBACK= # read fallback; empty = app.fallback_locale
# Mollie
MOLLIE_KEY=test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Mollie Connect (Multi-Tenant)
LARSASUB_CONNECT_ENABLED=false
LARSASUB_CONNECT_TENANT_RELATION=
LARSASUB_CONNECT_TENANT_LABEL=organization
LARSASUB_CONNECT_TENANT_NAME_ATTRIBUTE=name
LARSASUB_CONNECT_TENANT_OWNER_ATTRIBUTE=owner_id
MOLLIE_CONNECT_CLIENT_ID=
MOLLIE_CONNECT_CLIENT_SECRET=
MOLLIE_ORGANIZATION_ID=
# Application Fees (Connect mode)
LARSASUB_CONNECT_FEE_ENABLED=false
LARSASUB_CONNECT_FEE_TYPE=percentage
LARSASUB_CONNECT_FEE_VALUE=10
LARSASUB_CONNECT_FEE_DESCRIPTION="Platform fee"
# Connect Notifications
LARSASUB_CONNECT_ALSO_NOTIFY_PLATFORM=false
# Notifications
LARSASUB_NOTIFICATIONS_SALES=sales
LARSASUB_NOTIFICATION_CHANNELS=mail,database
# Company Notification Toggles
LARSASUB_NOTIFY_COMPANY_SUBSCRIPTION_ACTIVATED=true
LARSASUB_NOTIFY_COMPANY_SUBSCRIPTION_CANCELLED=true
LARSASUB_NOTIFY_COMPANY_PAYMENT_FAILED=true
LARSASUB_NOTIFY_COMPANY_CHARGEBACK=true
LARSASUB_NOTIFY_COMPANY_REFUND=true
LARSASUB_NOTIFY_COMPANY_CHARGE_CANCELED=true
LARSASUB_NOTIFY_COMPANY_CHARGE_EXPIRED=true
LARSASUB_NOTIFY_COMPANY_CREDIT_ADDED=false
LARSASUB_NOTIFY_COMPANY_CREDIT_EXPIRING=false
LARSASUB_NOTIFY_COMPANY_CREDIT_EXPIRED=false
# Policy System
LARSASUB_ADMIN_ROLE=admin
LARSASUB_SELF_SERVICE_CREDITS=false
# Scheduling - Base
LARSASUB_SCHEDULING_ENABLED=true
LARSASUB_SCHEDULING_AUTO_REGISTER=true
LARSASUB_SCHEDULING_TIMEZONE=
# Scheduling - Command Frequencies
LARSASUB_PLAN_CHARGES_FREQUENCY=daily
LARSASUB_EXECUTE_CHARGES_FREQUENCY=daily
LARSASUB_EXECUTE_CHARGES_LIMIT=
LARSASUB_END_SUBSCRIPTIONS_FREQUENCY=daily
LARSASUB_END_BILLABLE_SUBSCRIPTIONS_FREQUENCY=daily
LARSASUB_CLEANUP_UNUSED_BILLABLE_SUBSCRIPTIONS_FREQUENCY=twiceDaily
LARSASUB_CLEANUP_UNUSED_BILLABLE_SUBSCRIPTIONS_HOURS=12
# Scheduling - Renewal & Announcements
LARSASUB_RENEW_BILLABLE_SUBSCRIPTIONS_FREQUENCY=daily
LARSASUB_ANNOUNCE_RENEWAL_BILLABLE_SUBSCRIPTIONS_FREQUENCY=daily
# Scheduling - Charge Notifications
LARSASUB_NOTIFY_CHARGES_FREQUENCY=daily
LARSASUB_NOTIFY_CHARGES_DAYS_BEFORE=10
# Scheduling - Credit Expiration
LARSASUB_PROCESS_EXPIRED_CREDITS_FREQUENCY=daily
LARSASUB_NOTIFY_EXPIRING_CREDITS_FREQUENCY=daily
LARSASUB_NOTIFY_CREDIT_EXPIRATION_DAYS_BEFORE=14
# Scheduling - Mandate Validation
LARSASUB_VALIDATE_MANDATES_FREQUENCY=weekly
LARSASUB_VALIDATE_MANDATES_DELAY_MS=100
# Standalone Dashboard
LARSASUB_DASHBOARD_SUBSCRIPTION=true
LARSASUB_DASHBOARD_PAYMENTS=true
LARSASUB_DASHBOARD_PAYMENT_METHODS=true
LARSASUB_STANDALONE_SUBSCRIPTION_URL=billing/dashboard
LARSASUB_STANDALONE_PAYMENTS_URL=billing/payments
LARSASUB_STANDALONE_METHODS_URL=billing/payment-methods
LARSASUB_STANDALONE_TENANT_CONNECT_URL=billing/admin/connect
LARSASUB_STANDALONE_SHOW_BACK=true
LARSASUB_STANDALONE_BACK_URL=/
License
This software is proprietary. See LICENSE.md for full terms.