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

Documentation for larsasub v0.38.2. On an older version? Your release notes ship with the package (CHANGELOG.md).

Scheduled commands

LarsaSub's recurring billing runs on Laravel's scheduler: eighteen commands plan and collect charges, end and renew subscriptions, send the notices that fall on a date, and prune what the package stores. This page says what to put in your scheduler, what runs when by default, what each command does, and how to size and watch the execution run.

Options, exit codes and the one-off commands are on Artisan commands.

Running the scheduler

Add Laravel's one cron entry to the server that runs your scheduler:

* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

That is all the package needs by default. With scheduling.enabled and scheduling.auto_register both true (the shipped values), ScheduledCommandsServiceProvider registers all eighteen commands with the scheduler — each with overlap protection, the frequency from its *_frequency key, and the timezone from scheduling.timezone (empty: the app timezone).

Without a running scheduler there is no recurring billing. No subscription charge is planned or collected, cancelled and fixed-term subscriptions never end, and the scheduler-only events never fire. A headless host needs it as much as any other.

The default schedule

The commands in the order they are registered, with the frequency key that moves each one:

Command Frequency key Default
larsasub:renew-billable-subscriptions renew_billable_subscriptions_frequency daily (00:00)
larsasub:plan-charges plan_charges_frequency daily (00:00)
larsasub:execute-charges execute_charges_frequency daily (00:00)
larsasub:end-subscriptions end_subscriptions_frequency daily (00:00)
larsasub:detect-started-subscriptions detect_started_frequency daily (00:00)
larsasub:end-billable-subscriptions end_billable_subscriptions_frequency daily (00:00)
larsasub:cleanup-unused-billable-subscriptions cleanup_unused_billable_subscriptions_frequency twiceDaily (01:00, 13:00)
larsasub:announce-renewal-billable-subscriptions announce_renewal_billable_subscriptions_frequency daily (00:00)
larsasub:notify-charges notify_charges_frequency daily (00:00)
larsasub:process-expired-credits process_expired_credits_frequency daily (00:00)
larsasub:notify-expiring-credits notify_expiring_credits_frequency daily (00:00)
larsasub:process-dunning process_dunning_frequency daily (00:00)
larsasub:prune-model-logs prune_model_logs_frequency daily (00:00)
larsasub:prune-notification-deliveries prune_notification_deliveries_frequency daily (00:00)
larsasub:prune-dunning-cases prune_dunning_cases_frequency daily (00:00)
larsasub:sync-settlements sync_settlements_frequency daily (00:00)
larsasub:check-vat-numbers check_vat_numbers_frequency twiceDaily (01:00, 13:00)
larsasub:validate-mandates validate_mandates_frequency weekly (Sunday 00:00)

Commands due in the same minute run one after another, in this order. That is what puts renew-billable-subscriptions ahead of plan-charges (a term renewed at midnight is planned the same night), plan-charges ahead of execute-charges (a charge planned at midnight is collected in the same run) and the chargeback-reversal look inside execute-charges ahead of process-dunning (that day's reminders already know about a reversed debt).

Three adjustments are worth making on purpose:

Registering the commands yourself

Set scheduling.auto_register to false when you want to choose the times yourself, and register the commands in routes/console.php:

use Illuminate\Support\Facades\Schedule;

// Renewals first, so a subscription renewing today is planned the same night
Schedule::command('larsasub:renew-billable-subscriptions')->dailyAt('00:00')->withoutOverlapping(60);

// Billing: plan, then collect
Schedule::command('larsasub:plan-charges')->dailyAt('00:00')->withoutOverlapping(60);
Schedule::command('larsasub:execute-charges')->everyFifteenMinutes()->withoutOverlapping(60);

// Subscription lifecycle
Schedule::command('larsasub:end-subscriptions')->dailyAt('00:30')->withoutOverlapping(60);
Schedule::command('larsasub:detect-started-subscriptions')->dailyAt('00:45')->withoutOverlapping(60);
Schedule::command('larsasub:end-billable-subscriptions')->dailyAt('01:00')->withoutOverlapping(60);
Schedule::command('larsasub:cleanup-unused-billable-subscriptions')->twiceDaily(1, 13)->withoutOverlapping(60);
Schedule::command('larsasub:announce-renewal-billable-subscriptions')->dailyAt('09:00')->withoutOverlapping(60);

// Notices and credit
Schedule::command('larsasub:notify-charges')->dailyAt('10:00')->withoutOverlapping(60);
Schedule::command('larsasub:process-expired-credits')->dailyAt('00:15')->withoutOverlapping(60);
Schedule::command('larsasub:notify-expiring-credits')->dailyAt('11:00')->withoutOverlapping(60);

// Reminder series (a no-op while dunning.reminders.enabled is off)
Schedule::command('larsasub:process-dunning')->dailyAt('10:30')->withoutOverlapping(60);

// Provider checks
Schedule::command('larsasub:validate-mandates')->weeklyOn(1, '03:00')->withoutOverlapping(60);
Schedule::command('larsasub:check-vat-numbers')->twiceDaily(1, 13)->withoutOverlapping(60);
Schedule::command('larsasub:sync-settlements')->dailyAt('05:00')->withoutOverlapping(60);

// Maintenance
Schedule::command('larsasub:prune-model-logs')->dailyAt('04:00')->withoutOverlapping(60);
Schedule::command('larsasub:prune-notification-deliveries')->dailyAt('04:15')->withoutOverlapping(60);
Schedule::command('larsasub:prune-dunning-cases')->dailyAt('04:30')->withoutOverlapping(60);

What changes when you take over:

renew-billable-subscriptions is registered before plan-charges, here and in the package's own registration. On the renewal day the subscription still has yesterday as its end date until the renewal converts it, so a planner run before it skips the subscription, and the first charge of the renewed period would be planned and collected a night late. Keep that order when you move either command.

Turning scheduling off

// config/larsasub.php
'scheduling' => [
    'enabled' => false,
],

This disables the commands themselves, not just their registration: every scheduled command then exits 0 without doing anything, also when you run it by hand (see Manual command execution). larsasub:doctor warns while it is off, because recurring billing is off.

Configuration

Everything lives in the scheduling block of config/larsasub.php:

'scheduling' => [
    'enabled' => env('LARSASUB_SCHEDULING_ENABLED', true),
    'auto_register' => env('LARSASUB_SCHEDULING_AUTO_REGISTER', true),

    // One frequency per command (see "The default schedule")
    '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'),
    'detect_started_frequency' => env('LARSASUB_DETECT_STARTED_FREQUENCY', 'daily'),
    'end_billable_subscriptions_frequency' => env('LARSASUB_END_BILLABLE_SUBSCRIPTIONS_FREQUENCY', 'daily'),
    'renew_billable_subscriptions_frequency' => env('LARSASUB_RENEW_BILLABLE_SUBSCRIPTIONS_FREQUENCY', 'daily'),
    'cleanup_unused_billable_subscriptions_frequency' => env('LARSASUB_CLEANUP_UNUSED_BILLABLE_SUBSCRIPTIONS_FREQUENCY', 'twiceDaily'),
    'announce_renewal_billable_subscriptions_frequency' => env('LARSASUB_ANNOUNCE_RENEWAL_BILLABLE_SUBSCRIPTIONS_FREQUENCY', 'daily'),
    'notify_charges_frequency' => env('LARSASUB_NOTIFY_CHARGES_FREQUENCY', 'daily'),
    'process_expired_credits_frequency' => env('LARSASUB_PROCESS_EXPIRED_CREDITS_FREQUENCY', 'daily'),
    'notify_expiring_credits_frequency' => env('LARSASUB_NOTIFY_EXPIRING_CREDITS_FREQUENCY', 'daily'),
    'process_dunning_frequency' => env('LARSASUB_PROCESS_DUNNING_FREQUENCY', 'daily'),
    'prune_model_logs_frequency' => env('LARSASUB_PRUNE_MODEL_LOGS_FREQUENCY', 'daily'),
    'prune_notification_deliveries_frequency' => env('LARSASUB_PRUNE_NOTIFICATION_DELIVERIES_FREQUENCY', 'daily'),
    'prune_dunning_cases_frequency' => env('LARSASUB_PRUNE_DUNNING_CASES_FREQUENCY', 'daily'),
    'sync_settlements_frequency' => env('LARSASUB_SYNC_SETTLEMENTS_FREQUENCY', 'daily'),
    'check_vat_numbers_frequency' => env('LARSASUB_CHECK_VAT_NUMBERS_FREQUENCY', 'twiceDaily'),
    'validate_mandates_frequency' => env('LARSASUB_VALIDATE_MANDATES_FREQUENCY', 'weekly'),

    // Per-command settings
    'cleanup_unused_billable_subscriptions_hours' => env('LARSASUB_CLEANUP_UNUSED_BILLABLE_SUBSCRIPTIONS_HOURS', 12),
    'notify_charges_days_before' => env('LARSASUB_NOTIFY_CHARGES_DAYS_BEFORE', 10),
    'notify_credit_expiration_days_before' => env('LARSASUB_NOTIFY_CREDIT_EXPIRATION_DAYS_BEFORE', 14),
    'validate_mandates_delay_ms' => env('LARSASUB_VALIDATE_MANDATES_DELAY_MS', 100),
    'validate_mandates_limit' => env('LARSASUB_VALIDATE_MANDATES_LIMIT'),   // empty = the built-in 5000
    'check_vat_numbers_delay_ms' => 500,                                     // pause between two VIES calls
    'check_vat_numbers_limit' => null,                                       // registrations per run; empty = all due
    'sync_settlements_lookback_days' => env('LARSASUB_SYNC_SETTLEMENTS_LOOKBACK_DAYS', 60),

    // Timezone for the registered commands (null = app timezone)
    'timezone' => env('LARSASUB_SCHEDULING_TIMEZONE', null),

    // larsasub:execute-charges
    'execute_charges_limit' => env('LARSASUB_EXECUTE_CHARGES_LIMIT'),       // null = no limit
    'reconcile_unresolved_after_minutes' => env('LARSASUB_RECONCILE_UNRESOLVED_AFTER_MINUTES', 30),
    'reconcile_lookup_ceiling' => env('LARSASUB_RECONCILE_LOOKUP_CEILING', 500),
    'reconcile_park_after_hours' => env('LARSASUB_RECONCILE_PARK_AFTER_HOURS', 24),
    'recover_mandates_within_days' => env('LARSASUB_RECOVER_MANDATES_WITHIN_DAYS', 30),

    // How long a command's overlap mutex is held before it counts as stale
    'mutex_expires_after_minutes' => env('LARSASUB_SCHEDULING_MUTEX_EXPIRES_AFTER_MINUTES', 60),
],

The keys that are more than a frequency:

Key Default What it does
enabled true false turns every scheduled command into a no-op, manual runs included
auto_register true false keeps the commands but leaves registering them to you
cleanup_unused_billable_subscriptions_hours 12 How old a never-activated subscription must be before the cleanup deactivates it
notify_charges_days_before 10 How far ahead notify-charges announces a charge flagged notify
notify_credit_expiration_days_before 14 How far ahead notify-expiring-credits warns
validate_mandates_delay_ms 100 Pause between two provider calls in validate-mandates
validate_mandates_limit empty Mandates per run; empty, zero or invalid means 5000 — there is no unbounded mode
check_vat_numbers_delay_ms 500 Pause between two VIES calls
check_vat_numbers_limit null Registrations per VIES run; empty or non-positive means everything due
sync_settlements_lookback_days 60 Days of payouts each settlement run looks back over; an invalid value reads as 60
timezone null Timezone of the registered commands; null is the app timezone
execute_charges_limit null Charges one execution run takes up — see Limiting one execution run
reconcile_unresolved_after_minutes 30 How old an unresolved claim must be before the run asks the provider about it; clamped to 59
reconcile_lookup_ceiling 500 How many of a customer's payments one reconciliation walks before it counts the lookup as failed
reconcile_park_after_hours 24 How long a claim that cannot be reconciled is retried before it is parked with needs_attention; below 1 reads as 1
recover_mandates_within_days 30 How far back a paid first payment may be and still get its missing mandate registered — not an off switch
mutex_expires_after_minutes 60 Overlap-mutex expiry for the registered commands; non-positive reads as 60

The retention periods the three prune commands read live in the retention block (retention.model_logs_days, retention.notification_deliveries_days, retention.dunning_days) — see Configuration.

Available frequencies

A *_frequency key accepts these names, each of them a Laravel scheduler method:

Name Runs
everyMinute, everyTwoMinutes, everyThreeMinutes, everyFourMinutes, everyFiveMinutes, everyTenMinutes, everyFifteenMinutes, everyThirtyMinutes Every N minutes
hourly At the start of every hour
everyTwoHours, everyThreeHours, everyFourHours, everySixHours Every N hours
daily 00:00
twiceDaily 01:00 and 13:00
weekly Sunday 00:00
monthly The 1st, 00:00

Anything else — a typo, another scheduler method, an empty value — is registered as that command's own default (the third column of the default schedule), without a warning. A mistyped cleanup_unused_billable_subscriptions_frequency therefore runs twiceDaily, and a mistyped validate_mandates_frequency runs weekly.

Environment variables

Every key above that reads an environment variable, in .env form. The full list for the package is on Environment variables.

# Scheduling on/off and registration
LARSASUB_SCHEDULING_ENABLED=true
LARSASUB_SCHEDULING_AUTO_REGISTER=true
LARSASUB_SCHEDULING_TIMEZONE=Europe/Amsterdam
LARSASUB_SCHEDULING_MUTEX_EXPIRES_AFTER_MINUTES=60

# Billing
LARSASUB_PLAN_CHARGES_FREQUENCY=daily
LARSASUB_EXECUTE_CHARGES_FREQUENCY=daily
LARSASUB_EXECUTE_CHARGES_LIMIT=
LARSASUB_RECONCILE_UNRESOLVED_AFTER_MINUTES=30
LARSASUB_RECONCILE_LOOKUP_CEILING=500
LARSASUB_RECONCILE_PARK_AFTER_HOURS=24
LARSASUB_RECOVER_MANDATES_WITHIN_DAYS=30

# Subscription lifecycle
LARSASUB_END_SUBSCRIPTIONS_FREQUENCY=daily
LARSASUB_DETECT_STARTED_FREQUENCY=daily
LARSASUB_END_BILLABLE_SUBSCRIPTIONS_FREQUENCY=daily
LARSASUB_RENEW_BILLABLE_SUBSCRIPTIONS_FREQUENCY=daily
LARSASUB_CLEANUP_UNUSED_BILLABLE_SUBSCRIPTIONS_FREQUENCY=twiceDaily
LARSASUB_CLEANUP_UNUSED_BILLABLE_SUBSCRIPTIONS_HOURS=12
LARSASUB_ANNOUNCE_RENEWAL_BILLABLE_SUBSCRIPTIONS_FREQUENCY=daily

# Notices and credit
LARSASUB_NOTIFY_CHARGES_FREQUENCY=daily
LARSASUB_NOTIFY_CHARGES_DAYS_BEFORE=10
LARSASUB_PROCESS_EXPIRED_CREDITS_FREQUENCY=daily
LARSASUB_NOTIFY_EXPIRING_CREDITS_FREQUENCY=daily
LARSASUB_NOTIFY_CREDIT_EXPIRATION_DAYS_BEFORE=14

# Reminder series
LARSASUB_PROCESS_DUNNING_FREQUENCY=daily

# Provider checks
LARSASUB_VALIDATE_MANDATES_FREQUENCY=weekly
LARSASUB_VALIDATE_MANDATES_DELAY_MS=100
LARSASUB_VALIDATE_MANDATES_LIMIT=
LARSASUB_CHECK_VAT_NUMBERS_FREQUENCY=twiceDaily
LARSASUB_SYNC_SETTLEMENTS_FREQUENCY=daily
LARSASUB_SYNC_SETTLEMENTS_LOOKBACK_DAYS=60

# Pruning (the retention values live in the retention block)
LARSASUB_PRUNE_MODEL_LOGS_FREQUENCY=daily
LARSASUB_MODEL_LOGS_RETENTION_DAYS=730
LARSASUB_PRUNE_NOTIFICATION_DELIVERIES_FREQUENCY=daily
LARSASUB_NOTIFICATION_DELIVERIES_RETENTION_DAYS=365
LARSASUB_PRUNE_DUNNING_CASES_FREQUENCY=daily
LARSASUB_DUNNING_RETENTION_DAYS=

Manual command execution

Every scheduled command can be run by hand — to test a configuration, or to catch up after an outage:

php artisan larsasub:plan-charges
php artisan larsasub:execute-charges
php artisan larsasub:process-dunning --dry-run
php artisan larsasub:validate-mandates --limit=200
php artisan larsasub:check-vat-numbers --unchecked --limit=500   # the VIES backfill, before switching the check on
php artisan larsasub:sync-settlements --days=365

The options of each command are on Artisan commands.

scheduling.enabled gates manual runs too. Every scheduled command starts with the same check, so with 'enabled' => false running one by hand prints LarsaSub scheduling is disabled. and exits 0 without doing any work. The one exception is larsasub:check-vat-numbers --unchecked, the one-off shape of a scheduled command. The one-off commands are not gated at all.

A manual larsasub:execute-charges beside the scheduler is safe, but not free. Each charge is claimed under a row lock before anything reaches Mollie, so a second run is told the charge was already taken and nobody is charged twice. A manual run is outside the overlap mutex, though, so the two runs contend over the same rows and repeat each other's lookups. To drain a backlog after the run limit was hit, raise execute_charges_frequency rather than running it by hand; if you do run it by hand, pause the cron entry for the duration. Do not reach for 'enabled' => false for that: it gates your manual run as well.

One-off commands

These ship with the package but are never scheduled, and scheduling.enabled does not gate them. Each is described on Artisan commands.

php artisan larsasub:install                              # publish, migrate, seed — after every upgrade
php artisan larsasub:doctor --strict                      # is this install able to bill? warnings fail too
php artisan larsasub:license                              # the declared tier, and whether Connect fits it
php artisan larsasub:generate-missing-invoices --dry-run  # invoices for paid charges that have none
php artisan larsasub:promote-translations nl              # plain plan content to a locale map
php artisan larsasub:connect-reset {tenant_type} {tenant_id}
php artisan larsasub:prune-billables --orphaned --dry-run # from your own cleanup scheduler
php artisan larsasub:backfill-valued-from --dry-run
php artisan larsasub:test-notifications --list            # not registered in production

Run larsasub:doctor after every upgrade. It checks the columns, which matters for a database created before 0.37.1: up to then the migrations were edited in place, so migrate can report "nothing to migrate" on an outdated schema.

Command Details

What each scheduled command does, in registration order.

Renew Billable Subscriptions Command

larsasub:renew-billable-subscriptions — daily by default (scheduling.renew_billable_subscriptions_frequency).

Renews every activated subscription whose end date has passed and that has automatic_renewal on, in place: it becomes continuous (no end date), takes its renewal notice period (renewal_cancellation_notice_period) as its cancellation notice period, and turns automatic_renewal off. No new subscription is created. Dispatches BillableSubscriptionRenewedEvent with the previous end date and duration, which sends the renewed mail.

It is registered before plan-charges, so the first charge of the renewed period is planned and collected on the day that period starts. A schedule of your own must keep that order (see Registering the commands yourself). Two schedules collect that charge late, and boot logs a warning for both (larsasub:doctor lists them too):

Plan Charges Command

larsasub:plan-charges — daily by default (scheduling.plan_charges_frequency). Needs a configured provider.

Plans the charge of every active, chargeable subscription whose running billing period has no charge yet — on the period's first day, the day it is collected. This command:

It has to run before execute-charges. The charge it plans is due the same day and is collected by the next execute-charges run. Both default to daily (00:00) and plan-charges is registered before execute-charges, so a charge planned at midnight is collected in the same scheduler run. Two schedules collect late, and boot logs a warning for both (larsasub:doctor lists them too):

With auto_register off you register both yourself: schedule plan-charges at or before the first execute-charges run of each day. The package cannot read that schedule, so nothing warns.

Before 0.37.0 the planner planned ahead, usually on the 1st of the month, and fixed the amount then. See UPGRADE.md.

Execute Charges Command

larsasub:execute-charges — daily by default (scheduling.execute_charges_frequency). Needs a configured provider.

Collects the charges that are due, and repairs what earlier runs left behind. One run, in this order:

  1. Registers the missing mandate behind paid first payments whose registration failed (A paid first payment without a mandate)
  2. Asks the provider about charges and single payments that were claimed but whose answer was never stored (Charges whose outcome was never learned)
  3. Looks for reversed chargebacks on recent chargeback follow-ups (Reversed chargebacks)
  4. Recovers chargeback follow-ups whose manual payment start died halfway
  5. Collects the subscription charges, then the standalone charges, whose planned_on has arrived — together at most execute_charges_limit (Limiting one execution run)
  6. With Connect enabled, ends the drains of requested disconnects (MollieConnectService::finalizeDisconnects()): an account with nothing under way any more is removed, and one past its deadline is removed with what is left flagged. Without the scheduler a drain never ends on its own; see Disconnecting a tenant

In step 5, per charge:

The reversal look (step 3) and the drain pass (step 6) are contained: when one of them fails, the collections still run and the command exits non-zero afterwards. So is a local settlement in step 5 (the €0 settlement and the one with nothing left to collect): one that fails costs only its own charge, and the rest of the run goes on. The log names the charge — check it: usually the settlement was rolled back and the next run retries it, but a failure after the settlement was saved, or after a charge was flagged, leaves it settled or flagged.

End Subscriptions Command

larsasub:end-subscriptions — daily by default (scheduling.end_subscriptions_frequency).

Takes plan templates (Subscription) whose end date has passed out of the offer by setting active to false, and logs it on the plan.

Detect Started Subscriptions Command

larsasub:detect-started-subscriptions — daily by default (scheduling.detect_started_frequency).

Dispatches BillableSubscriptionStartedEvent for activated subscriptions whose future start date has arrived — the upcoming → active moment, which is also when the subscription_started notices go out (off by default). Each subscription gets the event once. One that was deactivated before its start day is marked without an event; one deactivated on or after it still gets it, possibly after its Ended event. Registered before end-billable-subscriptions, so a short fixed term normally gets Started before Ended; correctness never depends on that order.

End Billable Subscriptions Command

larsasub:end-billable-subscriptions — daily by default (scheduling.end_billable_subscriptions_frequency).

Deactivates every activated subscription whose end date has passed and that does not renew automatically — fixed terms with automatic_renewal off, and cancelled subscriptions (a cancellation sets the end date). The end date is inclusive, so a subscription ending on the 24th is deactivated on the 25th, with DeactivationReason::ContractPeriodCompleted. Dispatches BillableSubscriptionEndedEvent for each. It is not limited and does not look at charges still waiting — see A lagging run can cost you the charge.

Cleanup Unused Billable Subscriptions Command

larsasub:cleanup-unused-billable-subscriptions — twice daily by default (scheduling.cleanup_unused_billable_subscriptions_frequency).

Deactivates subscriptions that were never activated because their first payment was abandoned, with DeactivationReason::FirstPaymentAbandoned. It does not delete them: the subscriber can still retry or terminate it from the dashboard (see Subscriptions). It takes a subscription only when:

Charges that reached the provider are kept as the record of that attempt. Charges that never did are deleted, after their cost lines are rolled back and their applied credit is returned.

Announce Renewal Billable Subscriptions Command

larsasub:announce-renewal-billable-subscriptions — daily by default (scheduling.announce_renewal_billable_subscriptions_frequency).

Announces an upcoming automatic renewal once per subscription. This command:

Notify Charges Command

larsasub:notify-charges — daily by default (scheduling.notify_charges_frequency).

Announces upcoming charges to the subscriber. This command:

A charge is only announced while its day is still ahead, and on the shipped schedule execute-charges runs before this command in the same minute. So a charge planned for tomorrow is collected before any run announces it. To give N days' notice, plan the charge at least N + 1 days ahead (N up to notify_charges_days_before).

Process Expired Credits Command

larsasub:process-expired-credits — daily by default (scheduling.process_expired_credits_frequency).

Expires credit whose expires_at has passed. This command:

Notify Expiring Credits Command

larsasub:notify-expiring-credits — daily by default (scheduling.notify_expiring_credits_frequency).

Warns about credit that is about to expire. This command:

The warning is per credit addition and names its unspent remainder (remaining_amount); an addition spent in full is not warned about. The expiry removes what is still unspent then.

Process Dunning Command

larsasub:process-dunning — daily by default (scheduling.process_dunning_frequency).

Runs the reminder series — off by default (dunning.reminders.enabled), and a no-op that exits 0 while it is off. This command:

Registered after execute-charges, so on the shipped schedule its reminders see the chargeback reversals that run found. See Reminder series.

Prune Model Logs Command

larsasub:prune-model-logs — daily by default (scheduling.prune_model_logs_frequency).

Deletes old model-log entries (the per-model activity trail). This command:

Prune Notification Deliveries Command

larsasub:prune-notification-deliveries — daily by default (scheduling.prune_notification_deliveries_frequency).

Deletes old notification-delivery records — the per-notification, per-channel log that stops a retried job from re-mailing a list it already served. This command:

Prune Dunning Cases Command

larsasub:prune-dunning-cases — daily by default (scheduling.prune_dunning_cases_frequency).

Deletes resolved reminder series, with their steps and model log. This command:

Sync Settlements Command

larsasub:sync-settlements — daily by default (scheduling.sync_settlements_frequency). Needs a configured provider.

Mirrors Mollie's payouts into larsasub_settlements and attaches every package payment to the payout it was paid out in, so a reference on a bank statement leads to its payments. This command:

The full contract lives in Mollie Connect → Settlements.

Check VAT Numbers Command

larsasub:check-vat-numbers — twice a day by default (scheduling.check_vat_numbers_frequency).

Checks business VAT numbers in VIES and stores the outcome on the registration (vat_check_status and friends). A profile change is checked by a queued job already; this command is the net under it and the re-check. This command:

The default is twiceDaily (01:00 and 13:00 in scheduling.timezone) rather than daily: VIES publishes a nightly maintenance window for Germany (23:00–01:30, timezone not stated), so a run at midnight would never reach a German number. A failed re-check keeps the last valid result, and the 13:00 run reaches it the same day.

The full contract lives in Invoicing → Validating VAT numbers.

Validate Mandates Command

larsasub:validate-mandates — weekly by default (scheduling.validate_mandates_frequency). Needs a configured provider.

Re-checks usable mandates against the provider and marks revoked or invalid ones unusable (dispatching MandateMarkedUnusableEvent). A customer who cancels a direct debit at their bank is otherwise only discovered when a collection fails. This command:

The full contract lives in Payment methods → Validating mandates.

Overlap protection

Every command the package registers carries withoutOverlapping(), so a tick does not start while the previous run of the same command is still going. Two properties of that mutex are easy to assume and wrong to rely on.

The expiry is a stale-lock cleanup, not a promise. The number (mutex_expires_after_minutes, default 60) is how long Laravel holds the mutex before considering it abandoned. A run that takes longer than that loses its slot and the next tick starts alongside it. That overlap is not a double-billing risk — execute-charges claims each charge under a row lock before anything goes to Mollie, so the second run skips what the first one took — but it is two processes fighting over the same rows and doing work the other already did. If your runs legitimately outlive the default (large volume, slow provider), raise the expiry instead of accepting the contention. Non-positive values fall back to 60: a mutex that expires instantly is no mutex at all.

The mutex lives in the default cache store. Laravel's withoutOverlapping() uses cache.default, with no way to point it at another store per command. That makes the driver load-bearing:

The same claim that makes an expired mutex safe makes this safe too — no double billing — but a host with the wrong driver is running without the protection it thinks it has. Run the scheduler on one designated server with a persistent store, or give all servers a shared one.

Several servers with a shared store have one more effect: a server that finds plan-charges held by another skips it and runs execute-charges while the other is still planning. A charge planned after that execute run started waits for the next one — on daily, the next day. One designated server avoids it.

Limiting one execution run

execute_charges_limit caps how many charges larsasub:execute-charges takes up in a single run. It bounds the work per run — provider API calls and wall-clock — and nothing else: whatever is left over stays planned and is picked up by the next run, so lowering it spreads billing over more runs instead of dropping charges.

Two things to know:

null, an empty value, or any non-positive number means no limit. A configured 0 reads as "don't limit this", never as "process nothing".

The repair passes before the collections each get the same number as a budget of their own — see Charges whose outcome was never learned.

Sizing the limit

A leftover charge is not touched: it keeps its past planned_on, so it matches plannedOnDatePassed() again on the next run and the backlog drains by itself. No queue, no extra state, no manual step. But it drains at the rate of your schedule, and that is the whole risk.

Your capacity is limit × runs per day. With the default execute_charges_frequency of daily and a limit of 50, that is 50 charges a day — 600 due charges then take twelve days.

You do not add schedulers for this; there is one schedule:run cron entry and it drives everything. You raise execute_charges_frequency:

'execute_charges_frequency' => 'hourly',   // 24 × 50 = 1200/day
'execute_charges_limit' => 50,

Size capacity against your peak day, not your average — charges cluster on the days most of your customers signed up. And note that a run that outlives its interval makes the next tick skip (the overlap mutex), so at aggressive frequencies your real ceiling is how long a run takes, not the interval.

The mutex's 60 minutes is an expiry, not a guarantee. It exists so a crashed run does not block the schedule forever, which means a run that is still going after an hour loses its lock and the next tick starts alongside it — two runs at once, over the same due charges. Keeping runs short is therefore not only about throughput: a limit sized so that a run finishes well inside an hour is itself the mitigation.

When the cap bites, the run says so — Run limit of N charges reached on stdout and in the log. Seeing that on every run means your capacity is too low, not that the limit is doing its job.

Raise the frequency rather than draining a backlog by hand: a manual run alongside the scheduled one is not covered by the overlap mutex. It will not charge anyone twice — each charge is claimed under a row lock before anything reaches Mollie, and the second run is told it was already taken — but it is two processes contending over the same rows, each doing work the other already did. See Manual command execution.

A lagging run can cost you the charge

larsasub:end-billable-subscriptions is not limited, runs daily, and knows nothing about charges still waiting in the queue. That produces a race:

  1. A subscription passes its end_date without automatic renewal and is deactivated.
  2. larsasub:execute-charges finally reaches that subscription's last charge. BillableSubscription::isInactive() now holds, so the charge is parked with needs_attention instead of collected.

The customer cannot pay it either: RecurringCharge::getIsPayableAttribute() returns false for a charge whose subscription is inactive — that state needs an admin, not a self-service payment. So a limit set too low turns revenue into a support ticket.

It does not have to be an invisible support ticket. The charge stays listed by $owner->outstandingCharges() (also on the Billable), and $charge->notPayableReason() returns NotPayableReason::SubscriptionInactive for it — enough to show the amount and say why it cannot be paid here, instead of showing nothing. That is a display API only: is_payable still governs the pay button, and starting a payment for such a charge anyway bypasses the double-payment guard. See Outstanding charges.

This affects every subscription with an end date that does not renew: fixed terms with automatic_renewal = false, and cancelled subscriptions, which get their end date from the cancellation. A continuous subscription nobody cancelled has no end_date and is never ended by that command, and one with automatic_renewal = true is converted to continuous by larsasub:renew-billable-subscriptions rather than deactivated.

Practical rule: if you set a limit, your execution capacity must stay comfortably ahead of your peak day. Treat a recurring Run limit reached line as an incident, not as noise.

Charges whose outcome was never learned

Sending a payment is two steps that cannot be made one: a write in your database, then an HTTP call to Mollie. larsasub:execute-charges claims a charge first — that is what stops two runs from collecting it twice — and only afterward does the provider answer come back and get stored.

A run that is killed in between (a deploy, the OOM killer, a request timeout) leaves a charge behind that is marked as taken up but has no payment against it. Nothing would pick it up again: the executor filters on "not yet charged", and no webhook can be matched to a payment id that was never stored. Whether the customer was actually charged is not readable from your database at all.

It is readable at Mollie. Payments created for a RecurringCharge carry recurring_charge_id in their metadata (a one-off payment carries single_payment_id instead), so every run asks about the claims that have been unresolved for longer than reconcile_unresolved_after_minutes, before it collects anything:

What Mollie says What happens
A payment exists for this charge Its id and status are recorded, so the charge is reachable by its webhook again
No payment exists The claim is released and the next run collects the charge normally
The lookup itself failed (Mollie unreachable, no Mollie customer to search, a tenant context that cannot be established) The claim stays. The run retries on every following run until the claim is older than reconcile_park_after_hours (default 24), then parks it with needs_attention — releasing on a failed lookup is how you charge someone twice

This works in Connect mode as well, where a webhook for an unknown payment cannot be resolved at all: the tenant is derived from the local charge instead of from the remote payment.

execute_charges_limit bounds these lookups too, at the same number — each one costs a provider call, so a run that caps its charges should cap these as well. They do not come out of the charge budget: an unresolved charge is precisely one this run cannot execute. Whatever is left waits for the next run. The same applies to the mandate-recovery pass and to the look for reversed chargebacks, so a run of larsasub:execute-charges with execute_charges_limit = 50 is up to five budgets of 50 plus 50 charges: at most 50 mandate recoveries, 50 unresolved charges, 50 unresolved single payments, 50 charged-back charges checked for a reversal, 50 stranded chargeback follow-ups and 50 collections. Size the limit against the work, not against the number of charges alone.

The mandate pass is the one to count carefully, because its budget is not one call per charge. A charge it takes up costs at least two provider calls: the stored payment details are refreshed first, on every charge that reaches the repair, and a mandate that gets registered is read back afterwards to pick up its status. On top of that comes the registration itself, which is where the number varies: when the refreshed details already name the mandate it is a local record and nothing more, and otherwise the customer's mandate list is walked (page by page, up to 250 mandates) to find one to adopt, followed by a mandate creation at the provider when there is nothing to adopt. So budget 2 calls per recovered charge as the floor and several as the realistic case. Two shapes cost nothing: a charge the preconditions refuse, and one that turns out to have a usable mandate already — both are answered from local rows, before the first call. The reversal look costs two requests per charge (the payment and its chargebacks).

reconcile_unresolved_after_minutes is clamped to 59. Releasing a charge hands it back to the executor, and that retry is only safe while Mollie still holds the idempotency key from the first attempt — the package sends a deterministic key with every payment, and Mollie keeps keys for one hour. Past the hour a release that turns out to be wrong becomes a second payment, so a configured 60 or more is lowered to 59 rather than honoured. The default of 30 minutes leaves room for a slow run to finish while staying inside the window.

The clamp bounds the threshold, not the run. It decides how old a claim must be before this pass will look at it — not when the pass happens, which is execute_charges_frequency. The age a claim actually reaches is this threshold plus that interval, so on the shipped daily the key is long gone by the time anything looks, and the safety net under a wrong lookup is not there. Keeping the sum under 60 means scheduling execute-charges at everyFifteenMinutes or finer (30 + 15 = 45); everyThirtyMinutes lands on exactly 60 and hourly gives 90.

You do not have to do that arithmetic yourself. larsasub:doctor prints the sum as a Reconciliation window line and ConfigValidator logs a warning while it is 60 or more — which on the shipped defaults it is. The threshold is reported as the reconciler will apply it, so a configured 600 shows up as the 59 it is clamped to. A frequency Laravel's scheduler has no method for is judged as the daily it is registered as. With scheduling.auto_register off, no warning is logged and the doctor line reads not assessed: your own scheduler sets the interval, and that case is yours to check.

The lookup walks the customer's complete payment history, page by page, and takes the newest payment that matches — not merely the first one it runs into. A charge can carry more than one payment (a claim that died in the window, then another attempt), and binding it to the older one leaves the newer unreachable.

That walk is not indefinite: reconcile_lookup_ceiling (500) bounds one lookup. The bound is on payments actually examined, so a history of exactly 500 is a complete walk and is answered normally; only a 501st payment — the proof that the history reaches past the ceiling — stops the walk. That stop counts as a failed lookup and ends with the charge parked rather than released: "we stopped looking" is not the same as "there is nothing there", and treating it as such is how you charge someone twice. It parks even when a matching payment was already seen: a walk that stopped early cannot tell whether a later payment supersedes that match, and a wrong answer there costs money in both directions.

What this does and does not cover

Reconciliation covers every flow that claims before the provider call:

While a claim is unresolved the charge is not manually payable (PaymentInProgress): the provider may be collecting it, and a manual payment on top would debit twice. The same holds for a collection whose call failed ambiguously (a timeout, a 5xx); only a failure that provably created nothing — no mandate, a rejected request — leaves the pay button there.

What still falls outside it: a manual payment for an ordinary recurring charge (startManualChargePayment) whose process dies mid-call. Its claim is payable = false, but the charge keeps the charged_at (and, after a failed collection, the provider id) of the original attempt, so a lost answer is not distinguishable from the recorded state by the row alone. The customer loses the pay button, and the start adds no flag of its own:

A start that fails with an unclear error (a timeout, a 5xx) is flagged payment-start:unclear instead; clearing that flag also leaves payable = false.

In both cases there is no package action to hand the charge back yet. What works by hand, after confirming at the provider that no payment is open, depends on the row. For an ordinary charge — not a mandate-setup charge, not a chargeback follow-up — whose subscription is still active:

This is rare. Chargeback follow-ups have the pass above.

Reversed chargebacks

Mollie sends no webhook when the bank reverses a chargeback, and nothing else fetches a paid payment again. So before the stranded follow-ups, the run fetches the original payment of every chargeback follow-up younger than dunning.chargeback_reversal_check_days (default 60; 0 is off) whose chargeback is not yet seen reversed — two requests at Mollie each (the payment and its chargebacks), paid follow-ups included. A reversal found there is handled as if a webhook had brought it: an unpaid follow-up is withdrawn, one whose payment is under way is marked (and withdrawn once that payment ends without money), a paid one is a double payment (dunning.double_paid). A tenant that cannot be established, or a refresh that throws, skips that one charge for this run.

Unlike the passes above this one does not drain — a chargeback that still stands stays in the window — so under execute_charges_limit it takes the least recently asked charges first (reversal_checked_at, stamped on every attempt) and works through the rest on the following runs. Only charges with a follow-up are looked at: a chargeback on a method outside dunning.chargeback_follow_up_methods (only directdebit by default, so card and PayPal are outside) has nothing to settle, and its reversal still waits for another refresh of the payment. See Recurring charges.

A paid first payment without a mandate

The worst outcome a first payment has: the money is in, the subscription is active, the invoice is raised — and there is no payment mandate. The payment succeeded and the mandate registration behind it threw, so every following collection fails, and the subscriber only notices when month 2 parks.

On the EU and mandate-setup flows the mandate itself exists at Mollie and only the local registration failed. So each larsasub:execute-charges run retries it, before the execution loops, which means a mandate recovered this way is used by the same run.

On the NL flow it is different, and worth knowing before you enable anything: there LarsaSub itself creates the mandate from the IBAN of the first iDEAL payment, so a registration that never completed left nothing to adopt — and the retry therefore creates a mandate at Mollie, not only a local record. It is the only repair that flow has. The subscriber receives the ordinary "payment method added" mail for it. Everything in the table below exists to make sure that only ever happens for a subscriber who was in fact setting up a direct debit.

What it will and will not touch:

Charge What happens
Paid, flagged with this failure's own marker, and the subscription still has no mandate The registration is retried; on success the flag is lifted, the mandate is linked to the subscription, and the collections that stranded on it are freed
Paid and flagged, but for something else (a failed invoice, a charge the customer repaired by hand with iDEAL) Never touched
The €0,01 mandate verification with the same failure Recovered — the purest form of it, with no subscription to link
Paid and flagged with the marker, but the subscription has since been deactivated (cancelled, terminated, ended) Never touched — nothing would ever collect with the mandate, and it would be a running authorisation for a relationship that is over
This billable removed a payment method of the same kind Never touched, and that is deliberate (below)
Paid longer ago than recover_mandates_within_days (default 30) Left for an operator

Three properties are worth knowing, because each of them is a decision:

There is no switch that turns this pass off, and that is deliberate: the state it repairs is money collected with nothing to collect the next period with, so "leave it alone" is not a neutral setting. recover_mandates_within_days bounds how far back it reaches and nothing more — 0, false and any non-numeric value are raised to a one-day window rather than honoured as an off switch, and ConfigValidator warns when it sees one. Nor is there a way round it by re-wiring the scheduler: the pass runs inside RecurringChargeService::scheduleExecute(), which is the only public entry to the execution loops, so "no mandate recovery" and "no collection" are the same choice. If the create-at-the-provider half is what you object to, raise it with us rather than working around it — that is a package decision to revisit, not a config value.

The one thing that still needs a human: when the repair keeps failing — the mandate genuinely does not exist at Mollie — the subscriber has to be sent through a new mandate setup. That is a customer contact, so the package leaves it to you; MandateRegistrationFailedEvent and the company mail are where it starts.

Troubleshooting

Start with php artisan larsasub:doctor: it reports most of the causes below as a line of its own.

Commands not running

  1. Check that the schedule:run cron entry exists on the server that should run the scheduler, and that php artisan schedule:list shows the larsasub:* commands.
  2. Check that scheduling.enabled is true — with it off, every command prints LarsaSub scheduling is disabled. and does nothing.
  3. Check that larsasub.provider is set: plan-charges, execute-charges, sync-settlements and validate-mandates refuse to run without one.
  4. With auto_register off, check that you registered every command you need.
  5. A command that is skipped on every tick may be held by the overlap mutex of a run that crashed. It expires after mutex_expires_after_minutes, and php artisan schedule:clear-cache clears it (see Overlap protection).
  6. Run the command by hand to see its output: a failing run prints its error and exits non-zero, and most failures are also written to the package's log channel.

Performance issues

  1. Bound execute-charges with execute_charges_limit, and size it against your peak day (Sizing the limit).
  2. Lower validate_mandates_limit or check_vat_numbers_limit for large fleets; both resume where the previous run stopped.
  3. Move the maintenance commands to quiet hours.
  4. Monitor database performance during the midnight run, when most commands are due at once.

Payment processing errors

  1. Verify the payment provider credentials (larsasub:doctor --live makes one read-only call).
  2. Look for charges flagged needs_attention: a failed collection is parked there with its reason, and the company mail (notifications.company.payment_needs_attention) tells you.
  3. Review the provider's own logs for the payment ids on those charges.

Credit notifications not sending

  1. Check that notify_credit_expiration_days_before is set and that the credit transactions have an expires_at.
  2. Check the toggles (notifications.user.credit_expiring, notifications.user.credit_expired) and your mail configuration.
  3. Each credit addition is warned about once: a warning that went out is not repeated.

Set the timezone. scheduling.timezone decides when "midnight" is; set it to your business timezone when the app runs in UTC. And set notify_credit_expiration_days_before long enough for a customer to still use the credit after the warning.