LarsaSub is launching soon. Purchases and account registration open shortly.

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

  1. Requirements
  2. Installation
  3. Configuration
  4. Models
  5. Traits
  6. Helpers
  7. Events
  8. Notifications
  9. Multilang Plan Content
  10. Payment Provider (Mollie)
  11. Multi-Tenant / Mollie Connect
  12. Nova Integration
  13. Artisan Commands
  14. Routes & Webhooks
  15. Database Schema
  16. Testing

Requirements


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:

  1. Publishes configuration file to config/larsasub.php
  2. Publishes Vue components and pages (if using standalone dashboard)
  3. Publishes built standalone assets
  4. Runs database migrations
  5. Seeds payment methods via LarsaSubSeeder
  6. 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 Billable intermediary model (in larsasub_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 IsBillableModel trait provides core billing relations (credit, mandates, single payments). For subscription methods like activeSubscription(), hasFeature(), and featureValue(), also add the HasSubscriptions trait — 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:

Theming by the host app is optional and works in two layers:

  1. Theme viewconfig('larsasub.standalone.theme_view') (default null) names a Blade view that the dashboard views include after larsasub.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/.dark re-themes the dashboards automatically. Example: create resources/views/larsasub-theme.blade.php containing your @vite call and set 'theme_view' => 'larsasub-theme'.
  2. 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 bundled larsasub.css this 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:

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:

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:

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, and larsasub:end-billable-subscriptions is not limited: if execution lags past a fixed-term subscription's end_date, that subscription is deactivated first and its last charge is then parked with needs_attention instead 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 (or larsasub.license.tier in 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:license to 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:

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):

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:

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: HasSubscriptions depends on hasOneThroughBillable() from IsBillableModel — 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:

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:

  1. Calls getNotifiableUsers() on billable if method exists
  2. Falls back to billable itself if it uses Laravel's Notifiable trait
  3. 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:


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:


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:

Notes & limits


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:

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

  1. Tenant owner visits the tenant admin dashboard
  2. Clicks "Connect Mollie" → MollieConnectController::authorize()
  3. Validates user is tenant owner (via tenant_owner_attribute)
  4. Redirects to Mollie OAuth authorization page
  5. Tenant approves access, Mollie redirects to callback URL
  6. MollieConnectController::callback() exchanges auth code for tokens
  7. Fetches organization info and profile ID from Mollie
  8. Stores encrypted tokens in larsasub_mollie_connected_accounts
  9. Redirects back to tenant admin dashboard

Admin Dashboard Authorization

The management dashboards (plans, subscribers, charges, invoices) are mode-aware:

Two consequences to be aware of:

Application Fees

When connect.fee.enabled = true, platform fees are added to tenant payments:

Fees are automatically skipped when:

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:

Both models must implement BillableModel and use IsBillableModel.

Notification Routing (Connect Mode)

In Connect mode, notifications are routed differently:

Tenant Admin Dashboard

A standalone tenant admin page is available at the configured URL (default: billing/admin). The BuildsConnectContext trait provides the frontend with:


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


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.