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
- Configuration
- Manual command execution
- One-off commands
- Command Details
- Overlap protection
- Limiting one execution run
- Charges whose outcome was never learned
- A paid first payment without a mandate
- Troubleshooting
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:
- Run
execute-chargesmore often if you want the reconciliation safety net: ondailya claim whose provider answer was lost is only looked at long after Mollie dropped the idempotency key.everyFifteenMinuteskeeps it — see Charges whose outcome was never learned. - Keep
plan-chargesdaily, at or before the firstexecute-chargesrun of each day. A charge is created on its collection day, soweekly,monthly, ortwiceDailyagainst adailyexecute run collect late; boot logs a warning for those andlarsasub:doctorlists it. - Keep
renew-billable-subscriptionsdaily, or every few hours — nottwiceDaily.weeklyormonthlyrenews a term up to six days or a month late, andtwiceDaily(01:00 and 13:00) beside adailyplan-charges or execute-charges collects every renewed term's first charge a day late; boot logs a warning for those andlarsasub:doctorlists it.
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:
- Every command you leave out never runs, and the scheduler-only events it dispatches never fire.
- Overlap protection is yours to add.
mutex_expires_after_minutesonly applies to the package's own registration, and Laravel'swithoutOverlapping()defaults to an expiry of 24 hours. scheduling.timezoneno longer applies: use->timezone().- The package cannot read your schedule. The late-collection warnings
(plan-charges and renewal) and the reconciliation-window check go quiet, so
the ordering rules above are yours to keep.
scheduling.enabledstill gates every run.
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.enabledgates manual runs too. Every scheduled command starts with the same check, so with'enabled' => falserunning one by hand printsLarsaSub scheduling is disabled.and exits0without doing any work. The one exception islarsasub: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-chargesbeside 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, raiseexecute_charges_frequencyrather than running it by hand; if you do run it by hand, pause the cron entry for the duration. Do not reach for'enabled' => falsefor 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):
renew_billable_subscriptions_frequencyweeklyormonthly— a term ending between two runs is renewed, planned and collected on the next run, up to six days or a month late;renew_billable_subscriptions_frequencytwiceDaily(01:00 and 13:00) beside adaily(00:00)plan_charges_frequencyorexecute_charges_frequency— every renewed term's first charge is collected a day late.
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:
- Plans nothing ahead: a period starting on the 25th is planned on the 25th
- Composes the amount from the base price and the cost lines active at that moment, so a cost line added or ended before the collection day is in that charge (see Cost lines)
- Catches up a period whose day was missed (a failed or skipped run) on the next run: the same period, composed from the cost lines of that run, planned on that day and collected the same night
- Plans nothing before
first_charge_onand nothing pastend_date - Applies available credit; a charge that credit covers in full is settled locally, without a provider call
- With a
connect.tenant_contexthook, plans each subscription in its own tenant context. One whose context cannot be established is not planned and reported asNOT planned— repair it within the billing period, because the catch-up only reaches the period containing today. One whose hook threw is skipped until the next run - Handles each subscription on its own: one that throws is logged with its id, the rest is planned, and the run exits non-zero at the end
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):
plan_charges_frequencyweeklyormonthly— a charge waits for the next plan run, up to six days or a month;plan_charges_frequencytwiceDaily(01:00 and 13:00) withexecute_charges_frequencydaily(00:00) — every charge is collected a day late.
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:
- Registers the missing mandate behind paid first payments whose registration failed (A paid first payment without a mandate)
- Asks the provider about charges and single payments that were claimed but whose answer was never stored (Charges whose outcome was never learned)
- Looks for reversed chargebacks on recent chargeback follow-ups (Reversed chargebacks)
- Recovers chargeback follow-ups whose manual payment start died halfway
- Collects the subscription charges, then the standalone charges, whose
planned_onhas arrived — together at mostexecute_charges_limit(Limiting one execution run) - 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 charge's tenant context is established first; when that fails, the
charge is flagged
needs_attentionand skipped - A charge whose subscription no longer exists, or is inactive, is flagged
needs_attentioninstead of collected - A charge whose subscription is no longer chargeable (
is_chargedoff, or nothing left to bill) is settled at €0: applied credit goes back and the cost lines it marked are rolled back - A charge with nothing left to collect (€0,00 — credit covered it, and
settling it when it was planned did not complete) is settled locally, as
planning would have done, and never sent to the provider; one with a
negative amount is flagged
needs_attentionand neither collected nor settled - A collection that fails is flagged
needs_attention, which keeps it out of later runs until someone clears it; whether the provider provably created nothing decides whether the customer may pay it by hand meanwhile - Charges of a tenant that is draining a Connect disconnect are left alone — unclaimed and unflagged — and chargeback follow-ups are never collected from the mandate
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:
- it was created more than
cleanup_unused_billable_subscriptions_hours(default 12) ago and is not deactivated yet — one a terminal webhook already deactivated keeps its more precise reason; - none of its charges is still a live payment attempt (claimed, with no final status yet — an unresolved claim, or a SEPA collection still settling);
- none of its charges is paid.
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:
- Takes activated subscriptions with automatic renewal on, an end date not yet passed, and no announcement yet
- Announces from two weeks before the notice-free deadline:
end_date − cancellation notice period − 14 days - Marks the subscription as announced, so it is never announced twice
- Dispatches
BillableSubscriptionRenewalAnnouncedEvent, which sendsSubscriptionRenewalAnnouncedNotificationto the subscriber (notifications.user.subscription_renewal_announced, on by default)
Notify Charges Command
larsasub:notify-charges — daily by default (scheduling.notify_charges_frequency).
Announces upcoming charges to the subscriber. This command:
- Takes charges flagged
notifythat have not been announced, are not collected yet, and are planned after today and withinnotify_charges_days_before(default 10) days. The package never flags a charge it generates itself: a subscription is a fixed series covered by the one-time notice at activation. This command serves the charges you create whose amount differs per collection (usage, tokens, ad-hoc costs); see Notifications - Sends
SubscriptionChargeAnnouncedNotificationfor a subscription charge andStandaloneChargeAnnouncedNotificationfor a standalone one (notifications.user.charge_announced, on by default) - Marks each charge as announced
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:
- Takes every credit addition past its expiry that is not marked expired
- Marks it expired and removes only its unspent remainder from the balance, with an expiration entry in the ledger — credit already used up is not taken twice
- Dispatches
CreditExpiredEvent, which sends the credit-expired mail (notifications.user.credit_expired, on by default; the company copy is off)
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:
- Takes credit additions expiring within
notify_credit_expiration_days_before(default 14) days that have not been warned about and still have something unspent - Marks each as warned, so it is warned about once
- Dispatches
CreditExpiringEvent, which sendsCreditExpiringNotification(notifications.user.credit_expiring, on by default; the company copy is off)
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:
- Opens a series for every unpaid charge that qualifies since
dunning.reminders.since(required once the series is on) - Derives each running series' status from its charge: paid or closed ends it, a payment under way or a flagged charge pauses it, a payable one resumes it
- Takes the step that fell due — never within a day of opening, a resume, the charge's last payment attempt or the previous step, and only the latest of several due steps
- Sends the series' events after the commit, and again on a later run when they did not go out
- Handles each series on its own: one that throws does not stop the others, and the run fails at its end
--dry-run(with--since=Y-m-d) shows what a run would do and writes and sends nothing — also while the series is off, but like any run it needsscheduling.enabledon
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:
- Reads the retention period from
retention.model_logs_days(default 730 days — comfortably beyond the 13-month SEPA chargeback window a disputed direct debit needs the trail for) - Deletes every entry older than that, in chunks of 1,000 rows, so one run never holds a long transaction over a hot table
- Does nothing when the retention is
nullor0(pruning disabled; logs grow forever)
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:
- Reads the retention period from
retention.notification_deliveries_days(default 365) - Deletes every record older than that, in chunks of 1,000 rows
- Does nothing when the retention is
nullor0
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:
- Reads the retention period from
retention.dunning_days - Does nothing while it is
null(the default) or0— series are financial administration and kept unless you choose otherwise - Only looks at resolved series (paid or closed) older than the retention; a running or exhausted series is never pruned
- Keeps, with a reason per series, what it cannot prove is done: an event not yet sent, or a charge that is still an unpaid debt (deleting its series would let the reminders start over) — see the reasons in Reminder series
- Skips a series whose check throws as
error, for the next run
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:
- Syncs the platform's own account first when
settlements.sync_platformis on (which needs an organization access token — Mollie refuses an API key here), then every connected account when Connect is enabled - Looks back
scheduling.sync_settlements_lookback_days(default 60), overridable with--days;--account=limits a run to one account'sreference_code, orplatform, and an unknown code fails the run --resyncwalks the payments of paid-out payouts in the window again — the repair for a payout completed while payments were missing; idempotent, and it announces no payout twice- Handles each account on its own: a missing settlements.read scope, a lost
connection or a provider error is logged and stored on the account
(
settlements_sync_error) and never stops the others — the command still exits 0. A missing scope never disconnects the account - Skips every account in test mode: Mollie has no test payouts
- Is idempotent: safe to run by hand beside the scheduled tick
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:
- Does nothing while
invoicing.vat_validation.enabledis off - Looks only at business profiles with a VAT number in an EU country, and
takes, in this order: profiles never checked,
pendingfor over 15 minutes (the job never ran),validolder thaninvoicing.vat_validation.recheck_after_days(default 30; null = never), andunavailable - Checks one after another with
scheduling.check_vat_numbers_delay_ms(default 500) between the calls — VIES caps concurrent requests and blocks abusive IPs - Checks at most
scheduling.check_vat_numbers_limitregistrations (empty or non-positive = everything due).--limit=Noverrides it for one run; a non-positive--limitfalls back to the configured limit, and one that is not a number is refused before anything is checked (exit 1). A number no check applies to (one that does not normalize, such as-) is left out, and a registration skipped for its tenant context does not count against the limit - Keeps a
validresult when the re-check gets no answer; only a definitive no or a change of the number replaces it. No answer is a status, not a failure: the command exits 1 only when a registration threw, after the others ran - Establishes each registration's tenant context first, and skips one that cannot be established
--unchecked: only profiles never checked — the backfill. Runs while the feature is off and withscheduling.enabledoff: check the numbers you have, then switch the check on
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:
- Caps one run at
--limit, elsescheduling.validate_mandates_limit - Falls through to a built-in 5000 when neither is a positive integer — there is deliberately no unbounded mode, because an uncapped run outlives the 60-minute scheduler mutex at fleet scale
- Resumes: mandates are ordered never-validated first, then oldest
last_validated_at, so successive runs cover a fleet larger than one run's budget instead of re-checking the same head - Throttles provider calls with
scheduling.validate_mandates_delay_ms(default 100) - Counts per-mandate errors and still exits 0
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
arraydriver provides no overlap protection at all, even on one server: it is process-local, so everyschedule:runinvocation starts with an empty store and acquires the mutex while the previous run still holds it. - The
filedriver works on a single server, but is per-server. - Multiple app servers running the scheduler need a shared, persistent store (redis, memcached, database) — with a per-server driver each server sees only its own locks, both start, and the mutex protects nothing between them.
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:
- It is one budget for the whole run, shared by the subscription loop and the standalone loop. A limit of 50 means 50 charges in total, not 50 per loop.
- It counts every charge the run takes up, including the ones it skips (a charge whose subscription is inactive, or whose tenant context cannot be established). Those still cost queries and writes, so they still cost budget. The number of payments created in a run is therefore at most the limit, usually fewer.
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:
- A subscription passes its
end_datewithout automatic renewal and is deactivated. larsasub:execute-chargesfinally reaches that subscription's last charge.BillableSubscription::isInactive()now holds, so the charge is parked withneeds_attentioninstead 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_minutesis 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 shippeddailythe 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 schedulingexecute-chargesateveryFifteenMinutesor finer (30 + 15 = 45);everyThirtyMinuteslands on exactly 60 andhourlygives 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:
- Executed charges —
larsasub:execute-chargesclaimscharged_atunder a row lock before collecting. - The first payment of a new subscription (
createSubscription, including the retry flow) — the controllers claimcharged_atbefore the checkout payment is created, and release the claim only when Mollie provably rejected the request (a 4xx other than 409, or a failure before anything was sent). A 409 means an earlier request with the same idempotency key is still being processed, so it keeps the claim, like a timeout. - Mandate setup (
createMandateSetupPayment) — the bookkeeping charge is born claimed and flaggedmandate_setup. When Mollie turns out to have nothing for it, the leftover is removed rather than released: there is no executor to hand it back to, and releasing it would put a 0,01-charge in front of the standalone loop. - Single payments — the start flows claim by setting
payable = falsebefore the call, soreconcileUnresolvedSinglePayments()finds a claimed start without a (new) payment id, adopts the payment Mollie made, or hands the pay button back when Mollie has nothing. A claimed retry still carries the id of its previous failed payment; finding exactly that payment back counts as "nothing new". Unlike charges, whose window is measured fromcharged_at, the single-payment window is measured fromupdated_at(the claim is the last write in the normal flow) — an unrelated write to the row restarts it.reconcile_lookup_ceilingapplies here too. - Chargeback follow-ups — a follow-up is only ever paid through a manual
start, which claims
payable = falsebefore the provider call and writes the payment id, status, checkout URL andcharged_atafter it.reconcileStrandedFollowUps()finds a follow-up claimed and unpaid, not flagged, whose provider payment is absent, failed, or adopted without a status (its own residue), and asks the provider: nothing there → the claim is handed back (or the follow-up withdrawn, when its chargeback was reversed meanwhile); a payment the row does not know, or the same one no longer failed → adopted, then run through the same status handling a webhook uses (paid, failed, expired, open). The window is measured fromupdated_at, as for single payments. Without it a stranded follow-up is a debt the customer can neither pay nor get past: a plan change is refused while it is open.
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 charge whose collection failed at the provider stays unflagged. With
the reminder series on, an operator sees it there (the series pauses as
payment_in_progress, andlarsasub:doctorreports series paused for more than a week). With the shipped defaults (reminders off) nothing points at it —outstandingCharges()saysnot_marked_payable, which is also what a manual payment in progress right now says. - A charge whose collection created nothing (no mandate) keeps the
executor's
attempt:created-nothingflag, which looks the same as before the start, and never opens a series.
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:
- with a failed provider status,
$charge->updatePayable(true)does it; - whose collection created nothing (no provider status), the same works only
while its
attempt:created-nothingflag still stands. Once an unclear start has overwritten that flag, or someone has cleared it,payable = truealone does not bring the button back — the charge readspayment_in_progress— and there is no supported way yet.
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:
- Selection is on evidence, not on symptoms. A charge a customer repaired by hand through iDEAL is paid, still flagged from the failed direct debit, carries a provider id and has no mandate — every symptom of this failure. Repairing it would create a SEPA direct-debit mandate out of a one-off iDEAL payment, which the customer never authorized. So recovery keys on a marker the failure itself writes, and on nothing else.
- A removed payment method is never brought back. Removing a payment method revokes it best-effort and deletes the local record either way, so it can still be valid at Mollie. Adopting it would restore it, mark it usable and mail "payment method added" for something the customer deleted. During a checkout the package does adopt on a proven identity — the customer is setting that instrument up at that moment — but this pass runs from the scheduler, so a soft-deleted mandate for the same method stops it. The operator alert stays standing instead.
- The flag is only lifted when it still says what it was repaired for.
needs_attentionis one slot for every reason there is, and the last writer wins. If something else flagged the charge in the meantime (a failed invoice), the recovery registers the mandate and leaves the flag alone.
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
- Check that the
schedule:runcron entry exists on the server that should run the scheduler, and thatphp artisan schedule:listshows thelarsasub:*commands. - Check that
scheduling.enabledistrue— with it off, every command printsLarsaSub scheduling is disabled.and does nothing. - Check that
larsasub.provideris set:plan-charges,execute-charges,sync-settlementsandvalidate-mandatesrefuse to run without one. - With
auto_registeroff, check that you registered every command you need. - 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, andphp artisan schedule:clear-cacheclears it (see Overlap protection). - 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
- Bound
execute-chargeswithexecute_charges_limit, and size it against your peak day (Sizing the limit). - Lower
validate_mandates_limitorcheck_vat_numbers_limitfor large fleets; both resume where the previous run stopped. - Move the maintenance commands to quiet hours.
- Monitor database performance during the midnight run, when most commands are due at once.
Payment processing errors
- Verify the payment provider credentials (
larsasub:doctor --livemakes one read-only call). - 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. - Review the provider's own logs for the payment ids on those charges.
Credit notifications not sending
- Check that
notify_credit_expiration_days_beforeis set and that the credit transactions have anexpires_at. - Check the toggles (
notifications.user.credit_expiring,notifications.user.credit_expired) and your mail configuration. - 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.