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

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

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 RecurringChargeFollowUpCreatedEvent fires, no mail goes out and no dunning.* 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:

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:

For tests that do not care about the provider at all, the cheapest path is often to avoid it entirely:

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.