Documentation for larsasub v0.38.2.
On an older version? Your release notes ship with the package (CHANGELOG.md).
Testing
How to test your LarsaSub integration in your own feature tests: the fixture kit the package ships, and the traps that make a billing test pass for the wrong reason. Read this before you write the first test that touches a subscription, a charge or a mandate.
- Fixtures for host apps
- Shared-PDO transactions
- SQLite proves no locking
- Faking the provider
- Testing scheduled work
- The package's own suite
Fixtures for host apps
Testing\LarsaSubFixtures builds saved database rows for your feature tests.
It writes through the models' own $fillable lists — no Model::unguard()
assumption — and knows the schema facts you would otherwise learn one SQLSTATE
at a time: consumer_account on a Mollie mandate is NOT NULL and
encrypted, a mandate requires a MollieCustomer (a restricting foreign key),
and a mandate-setup charge is born claimed.
The package's own test suite builds its fixtures through this same class, so
the kit cannot fall behind the schema. The other class in that namespace,
Testing\NotificationFixtures, builds the unsaved models behind
larsasub:test-notifications and is internal: to preview a mail, run
that command.
use PetersDevelopment\LarsaSub\Enums\DunningStatus;
use PetersDevelopment\LarsaSub\Testing\LarsaSubFixtures;
$billable = LarsaSubFixtures::billable($user); // the real BillableResolver path
$method = LarsaSubFixtures::recurringPaymentMethod(); // direct debit
$single = LarsaSubFixtures::singlePaymentMethod(); // iDEAL
$mandate = LarsaSubFixtures::usableMandate($billable); // including the required MollieCustomer
$plan = LarsaSubFixtures::plan(['price_amount' => '9.95']);
$sub = LarsaSubFixtures::activatedSubscription($billable, $plan);
$charge = LarsaSubFixtures::recurringCharge($billable, $sub, [
'provider_payment_status' => 'failed',
'charged_at' => now(),
]);
$setup = LarsaSubFixtures::mandateSetupCharge($billable); // standalone, €0.01, born claimed
$paid = LarsaSubFixtures::recurringCharge($billable, $sub, ['charged_at' => now(), 'paid_on' => now(), 'payable' => false]);
$followUp = LarsaSubFixtures::chargebackFollowUp($paid); // the debt a full SEPA chargeback leaves
$case = LarsaSubFixtures::dunningCase($followUp, ['status' => DunningStatus::Paused]); // a reminder series, any state
$payment = LarsaSubFixtures::singlePayment($billable);
$payout = LarsaSubFixtures::settlement(null, ['reference' => '1234567.2609.03'], [$charge, $payment]); // null = platform account
$b2b = LarsaSubFixtures::validatedVatNumber($billable); // a German business VIES confirmed, without asking VIES
Every builder defaults to a plain, healthy record and takes an $overrides
array that wins over its defaults. billable() is the exception: it takes the
owner and an optional tenant, nothing else.
The builders
All methods are static and return the saved model.
| Method | Returns | Default record |
|---|---|---|
billable(Model $owner, ?Model $tenant = null) |
Billable |
The owner's registration, created or fetched through BillableResolver::getOrCreateBillable() — tenant derivation included |
recurringPaymentMethod(array $overrides = []) |
PaymentMethod |
Mollie directdebit, active, recurring |
singlePaymentMethod(array $overrides = []) |
PaymentMethod |
Mollie ideal, active, single |
mollieCustomer(Billable $billable, array $overrides = []) |
MollieCustomer |
The billable's one customer for its tenant, with a fixture cst_… id |
usableMandate(Billable $billable, ?PaymentMethod $paymentMethod = null, array $overrides = []) |
MollieMandate |
A valid, usable direct-debit mandate on a fixture IBAN, with its customer and (when none is given) a recurring payment method |
plan(array $overrides = []) |
Subscription |
A €30.00 continuous plan, charged, active, no introduction, no notice period, no automatic renewal |
activatedSubscription(Billable $billable, ?Subscription $plan = null, array $overrides = []) |
BillableSubscription |
Activated and started six months ago, no end date; a new plan() when none is given |
recurringCharge(Billable $billable, ?BillableSubscription $subscription = null, array $overrides = []) |
RecurringCharge |
€30.00, planned yesterday, payable, not yet claimed by the executor |
mandateSetupCharge(Billable $billable, array $overrides = []) |
RecurringCharge |
The €0.01 verification: standalone, mandate_setup, claimed, open at the provider |
chargebackFollowUp(RecurringCharge $original, array $overrides = [], ?PaymentChargeback $chargeback = null) |
RecurringCharge |
The follow-up a full chargeback made now leaves on a paid charge |
singlePayment(Billable $billable, ?PaymentMethod $paymentMethod = null, array $overrides = []) |
SinglePayment |
€25.00, payable, submitted to the provider and open |
dunningCase(RecurringCharge $charge, array $overrides = []) |
DunningCase |
An active reminder series with the configured schedule |
settlement(?Model $tenant = null, array $overrides = [], array $payments = []) |
Settlement |
A paid-out €100.00 payout with a unique reference, the given payments attached |
validatedVatNumber(Billable $billable, string $vatNumber = 'DE123456789', string $country = 'DE', array $overrides = []) |
Billable |
A business customer whose VAT number VIES confirmed today |
What each builder does for you
Payment methods. Both builders use firstOrCreate on (provider, provider_id), so overrides apply on first creation only: a later call
returns the earlier row unchanged. Distinct methods need distinct
provider_ids.
Mollie rows. They follow the billable's tenant. mollieCustomer() finds or
creates the billable's one customer for the billable's own tenant — the shape
production has. Pass tenant_type/tenant_id in the overrides for a
different tenant, or null for the platform-level row. Note the name clash:
customer_id on the customer is the Mollie id (cst_…), while customer_id
in usableMandate()'s overrides is the local foreign key to that row.
Subscriptions. activatedSubscription() freezes its plan half through
BillableSubscription::snapshotFromPlan() — the same definition
BillableSubscriptionService::createSubscription() writes — so a quarterly or
yearly plan produces a subscription on that interval, and a translated plan
keeps its full locale map. Only the lifecycle position (activated_at,
start_date, end_date, …) comes from the fixture's own defaults, and
$overrides wins over both. An overridden subscription_id names the plan the
snapshot is taken from. The plan's vat_rate is not part of the snapshot: as
in production, the subscription's charges follow the plan's rate as it is when
they are composed, unless you pass vat_rate in $overrides as that one
subscription's own rate. A subscription whose start date has passed is marked as
already detected as started, so larsasub:detect-started-subscriptions does not fire its
event for it; pass started_processed_at => null to build the shape before
detection.
Charges and payments. recurringCharge() and singlePayment() write the
same VAT snapshot the services write, taking the amount as given (never grossed
up). Pass a VAT column explicitly, even as null, and the builder leaves it
alone. mandateSetupCharge() with provider_payment_id => null builds a
checkout that never reached the provider, and drops the provider status with
it.
Chargeback follow-ups. chargebackFollowUp() copies the period,
description, payment method and VAT decision from the original, and writes the
chargeback entry onto the original, so hasStandingChargeback(),
receivedAmount() and the dashboards read it as charged back. Overriding
origin_reversed_at marks the original's entry reversed too. It refuses an
unsaved or unpaid original, a follow-up as the original (no chains), and
setting follows_charge_id or origin_chargeback_id yourself — pass a
PaymentChargeback as the third argument instead. A chargeback that would get
no follow-up in production throws a LogicException too: one in another
currency than the charge, or one that earlier chargebacks on the same charge
leave nothing to ask for. The original is read from the database first, so
unsaved changes on the instance you pass are discarded.
The follow-up fixture skips the provider's refresher. No wallet credit is applied, no
RecurringChargeFollowUpCreatedEventfires, no mail goes out and nodunning.*setting is checked. Deliver the chargeback through the provider when your test is about those.
Reminder series. dunningCase() builds a series in whatever state you
pass, without asking whether the charge would qualify and without dispatching
anything. Keep in mind:
- The charge must be saved and have a billable registration, or it throws a
LogicException. - The case is marked as if its events went out, so
larsasub:process-dunningdoes not send its Started or Resolved later. A resolved status getsresolved_at = now()unless you pass one. - To test that outbox yourself, pass
started_dispatched_at/resolution_dispatched_atasnullandtravel()more than 15 minutes before the run. The grace counts from creation and resolution, and you cannot passcreated_at. active_sincedefaults to now, and no step goes out within a day of it. Back-date it when your test needs a step taken.
Settlements. settlement() attaches the payments you pass the way the
settlement sync does. settlement_id is not fillable on the payment models, so
this is the way to build a payout with its payments. A null tenant is the
platform's own account.
VAT numbers. validatedVatNumber() writes a business profile and the VIES
check columns as a valid answer would: status valid, the normalized number, a
consultation number and today's date. It saves quietly, so the profile hook
neither resets the check nor queues a real one — with
invoicing.vat_validation on and the sync queue, that would be an HTTP call
from your test. Pass vat_check_status and friends in $overrides for any
other check state. A country outside the EU throws a LogicException.
Shared-PDO transactions
If your test suite points larsasub.database.connection at a differently
named connection that shares the default's SQLite PDO, every money-path
test dies with "cannot start a transaction within a transaction".
Laravel tracks transaction depth per connection object, not per PDO. LarsaSub
opens its transactions on its configured connection, so RefreshDatabase's
wrapping transaction — on the default connection — is invisible to it, and
LarsaSub issues a literal BEGIN where a savepoint was needed.
The fix is one line in phpunit.xml: make the package connection be the
default connection's name inside tests.
<env name="LARSASUB_DB_CONNECTION" value="sqlite"/> <!-- your default connection's name -->
Production keeps its own value (a landlord connection, for instance); this only aligns the names inside the test process, so savepoints nest correctly.
SQLite proves no locking
On SQLite lockForUpdate() compiles to nothing, and a test process has no
second writer. A green test on an in-memory SQLite database therefore says
nothing about whether two requests, a webhook and a scheduled run, or two
queue workers are kept apart.
Never verify a concurrency fix with a green SQLite test. If your code takes locks around LarsaSub calls, test the ordering on MySQL or PostgreSQL, and follow the locking rule in Wrapping a LarsaSub call in your own transaction.
Faking the provider
mollie/laravel-mollie ships Mollie::fake(). Point it at the calls your flow
makes, and remember that LarsaSub resolves its clients through
MollieClientResolver:
- in simple mode that is the
Mollie::api()client, whichMollie::fake()replaces; - in Connect mode it is a new client per call, carrying the tenant's OAuth token — not the facade's singleton — so faking the facade does not reach it.
For tests that do not care about the provider at all, the cheapest path is often to avoid it entirely:
- a free plan activates at creation with no provider call (with
subscription.free_plan_mandateon its default,activate); - a zero-amount charge is settled locally, without a provider payment;
createDeferredMandateSubscription()makes no provider call by design.
Faking a refund needs more than the create call: see Refunds → Testing.
Testing scheduled work
Test a scheduled command by running it, as the scheduler would:
$this->artisan('larsasub:plan-charges')->assertSuccessful();
$this->artisan('larsasub:execute-charges')->assertSuccessful();
Every scheduled command opens with the same gates, and two of them look like a passing test:
| Situation | Result |
|---|---|
larsasub.scheduling.enabled is false (in your test environment or your published config) |
The command prints "LarsaSub scheduling is disabled.", does nothing and exits 0 — assertSuccessful() passes. Only larsasub:check-vat-numbers --unchecked runs anyway |
The feature a command serves is off: dunning.reminders.enabled for larsasub:process-dunning, invoicing.vat_validation.enabled for larsasub:check-vat-numbers, a null or 0 retention for a prune command |
The command says so, does nothing and exits 0 — assertSuccessful() passes. process-dunning --dry-run and check-vat-numbers --unchecked run anyway |
larsasub.provider is empty |
larsasub:plan-charges, larsasub:execute-charges, larsasub:sync-settlements and larsasub:validate-mandates exit with a failure; the others run |
Mollie Connect is on below the platform licence tier |
Every scheduled command except larsasub:validate-mandates exits with a failure |
So when your environment switches scheduling or a feature off, switch it on in the test that exercises a command:
config(['larsasub.scheduling.enabled' => true]);
Several events are dispatched only from those commands — among them
BillableSubscriptionStartedEvent, the renewal and renewal-announcement
events, the end-date BillableSubscriptionEndedEvent, the credit expiry
events and the reminder-series events. A feature test for a listener on one of
them has to run the command; there is no other path. The full list is in
Events.
The package's own suite
The package is tested with Orchestra Testbench on an in-memory SQLite database. That suite is not part of the archive you install, but the fixtures above are the same class it builds its own records with, so what it proves about the schema holds for the rows your tests build too.