Skip to main content

Subscriptions

A visitor becomes a subscriber from the /plan screen. The client submits the tier key only — the price is resolved server-side (anti-price-injection) — and the package opens a hosted Checkout Session in subscription mode. The trial, provider tax and VAT-id collection, promotion codes and the billing address all ride on that session, and the card is captured (with SCA / 3-D Secure) on the provider's own page. On return, /checkout/return reconciles the subscription onto the local row immediately, so a paying customer is never shown "Free" while the webhook is still in flight. An owner who already subscribes swaps in-app instead of opening a second subscription.

The return URLs default to the hub's own routes; set billing.checkout.success_url / cancel_url only to override them. Set billing.customer.model to your billable model — without it, no subscription webhook can find its owner.

When the first real charge lands​

On a driver the package bills itself, each subscription records when its periods are collected, and billing.collection_timing decides it for every new one. The default is in_advance: a period is paid when it begins. The payment taken at checkout is not a plan payment under either timing. It exists to establish the mandate, and billing.mandate_verification_minor sets it, defaulting to one minor unit (0.01 in the account currency).

PlanFirst real charge, in_advanceFirst real charge, in_arrears
Monthly, no trialat the next billing:run, within the hourafter 30 days
Monthly, 14-day trialwhen the trial endsafter 44 days
Yearly, no trialat the next billing:run, within the hourafter 365 days

In advance, the first period is charged right after the mandate exists, or when the trial ends, and each later period at the moment the one before it ends. The invoice is raised when the payment arrives and names the period it pays for.

In arrears, an order closes the period it is for, so the charge happens at the end of a cycle rather than at its start. A year of service is delivered before anything beyond that one minor unit has been collected, and the trial adds its own length on top rather than overlapping. If you choose it, the lever that keeps the wait short is the period length: a yearly price billed monthly carries the same annual sum with thirty days of exposure instead of three hundred and sixty-five.

Each subscription keeps the timing it started with. One started before the setting existed is collected in arrears, and changing the setting reaches new subscriptions only: switching a running one would collect the period in progress twice, once as the bill that closes it and once as the payment for the next.

Swaps, cancel, resume, trials​

  • In-app upgrade/downgrade swap with a proration preview; an upgrade takes effect immediately, a downgrade is scheduled to the current period end so the customer is never charged twice or refunded for time they already paid. On a driver the package bills itself, what a swap moves depends on when the subscription is collected. Collected in advance, the period was paid at the old tier, so a swap that takes effect at once squares the time left: a dearer tier is charged for it right away, as an order of its own with its own invoice, and a cheaper one leaves the difference as credit that the next payments spend first. Nothing is paid out. Each tier is valued over its own interval, so a monthly tier changed for a yearly one costs a twelfth of the yearly price for a month, and from the period end the new tier is billed over its own interval. A charge the provider refuses does not undo the change: it stays open and is collected after the next paid cycle. Collected in arrears, nothing is paid for the period in progress, so a swap moves no balance: the cycle that closes the period charges each tier for the days it held, and the preview shows what the swap adds to that bill or takes off it. Under both timings, a downgrade scheduled to the period end is charged at the new tier from the first day of the next period, even when the run that applies it comes later. A seat change with proration follows the same rule as a swap. On Stripe, a swap that changes the billing interval is charged at once, and where Stripe charges a card or Link for the subscription the change waits for that payment: a declined card, or a payment the bank still has to confirm, leaves the subscription on its tier, and Stripe drops the change within a day if the invoice stays unpaid. A SEPA Direct Debit, or a subscription whose invoices are sent to be paid, takes the change at once, as Stripe does by default.

    billing:run applies a downgrade scheduled to the period end without asking CanTransactMoney again. The account screen asked it when the customer made the change, and at the due date nobody is acting, so a gate that needs an acting person would refuse every scheduled change. A subscription that ends or is terminated before the date drops its change, and so does a deleted owner. If your application blocks an account in a way of its own, drop the pending change with $subscription->cancelScheduledSwap().

  • Cancel-into-grace (paid through the period end), resume, and immediate cancel from the danger zone. On a driver the package bills itself and a subscription collected in advance, the period is paid already, so a cancellation lets it run out and the subscription ends at the period end, and the immediate cancel ends it at the next billing:run. Collected in arrears, a period is collected at its end, so a cancellation keeps that last cycle: it bills the period and then ends the subscription. The immediate cancel keeps it too, brought forward to the moment the customer asked: the next billing:run bills the days of the period they already had, and until then the subscription cannot be replaced by a new one. The danger zone asks for that through EndsNowOnTheOwnersRequest::endNow(). SubscriptionActions::cancelNow() bills nothing more, and it is what account deletion, a withdrawal and a reversed sale use. A screen of your own that lets customers end their subscription at once calls endNow() where the bound actions implement EndsNowOnTheOwnersRequest, as the danger zone does.

  • Pause and resume. Where the bound actions implement PausesSubscriptions, as both shipped drivers do, pause() lets an active subscription that is not ending rest: nothing is collected, and it grants no access until unpause() takes it up again. On a driver the package bills itself, the time the pause took is added to the period the subscription is in, so the days that were paid, or would have been billed, are still there afterwards, and a cancellation while it rests ends it at once. On Stripe the pause is pause_collection with void, and Stripe keeps its own periods. SubscriptionPaused and SubscriptionUnpaused are dispatched for a lock or a notice of your own.

  • Cancel to a date, with the rest of the period paid back. ProratedCancellation ends a subscription at a moment inside the period it is in and refunds what was paid for the time after it, through the same refund path as every other refund, recorded as RefundKind::UnusedPrepaidPeriod. It answers a renewed consumer contract that may be canceled at a month's notice: on a yearly plan that end falls inside a paid year, and canceling at the period end would hold the customer up to eleven months too long. GermanNoticePeriod::earliestEnd() says when such a contract ends under § 309 Nr. 9 BGB, from when the cancellation arrived and when the initial term ends:

    use Pushery\Billing\Consumer\GermanNoticePeriod;
    use Pushery\Billing\Consumer\ProratedCancellation;

    $endsAt = GermanNoticePeriod::earliestEnd(now('Europe/Berlin'), $initialTermEndsAt);

    $quote = app(ProratedCancellation::class)->quote($user, $endsAt); // the end and the refund, to confirm
    $settlement = app(ProratedCancellation::class)->cancel($user, $endsAt); // ends it there and refunds the rest

    The initial term comes from you, because the package does not store what a subscription was sold as. GermanNoticePeriod counts the month the way the law does: the day the cancellation arrives does not count, and one arriving on 31 January ends with the last day of February. It answers for terms that ask for the full month the law allows; a shorter notice ends earlier.

    A date at or after the period end ends the subscription at the period end, with nothing refunded, and $settlement->endsAt names the end that applies, in UTC whatever zone the date came in, so format it in the zone you show it in. A cancellation to a date inside the period is final: resume() refuses it and the account screen offers no resume button, because the rest of the period may have been refunded. On a driver the package bills itself, a subscription collected in advance is refunded from the order that paid the period, as on Stripe. One collected in arrears has nothing paid for the period in progress, so nothing goes back: the last cycle runs at the date instead and bills the days up to it, the same amount a prepaid customer keeps paying after the refund. A provider that refuses the refund leaves the end in place; the settlement then says refundRefused and names the payment, which is what a second attempt through BillingAdmin::refund() needs.

  • Trials — none / subscription / generic, global or per-tier, card required or if_required; a trial-ending reminder goes out before the trial lapses, and it reaches every trial whatever drives it. Which path delivers it depends on whether the provider announces trial ends at all: a driver that does sends its own event, and billing:trials:warn covers the rest — a generic trial, which has no provider behind it, and a subscription trial under a driver whose cycle this package runs itself. Nothing at such a provider knows a trial is running, so nothing there can say it is ending. Both paths record the same notice, so a customer is told once.

    The mode without a card is the one where that matters most: there is no failed charge and no receipt to notice, so the reminder is the only signal before the first debit.

  • A canceled subscription is announced again before it ends. The cancellation notice goes out when the owner cancels; billing:subscriptions:warn reminds them once more within billing.subscriptions.warn_ending_within_days (3 by default) of the end, while resuming still keeps the plan without a gap. Once per subscription and end: resuming and canceling again ends on another day and is reminded again. A subscription held with a merchant is reminded under the merchant's name and without a button, since the hub's subscription screen shows the platform's own.

    The reminder, the trial-ending reminder and the cancellation notice name the day in UTC, the zone the package stores every moment in. Bind OwnerTimezone to an answer with the owner's zone, and they name the owner's day (Contracts).

  • A subscription trial is granted once per customer. Not once per subscription — the eligibility is read from their own billing history, so somebody who cancels and comes back subscribes at the full price. The plan screen offers the trial only to a customer who would actually be granted one, so the page and the checkout never disagree. A generic trial follows the same rule through the customer's own trial date.

  • The days a trial covers carry no plan charge, and the document says so at zero. Under a driver whose cycle this package runs itself and a subscription collected in advance, the trial is never charged at all: the run at its end charges the first paid period. Collected in arrears, an order names the period it closes — the cycle that ends a trial therefore names the trial's own days. That period is billed with the recurring plan line priced at zero, so the invoice states which days were free and what they were free of, rather than leaving no record of them. The first plan charge is the cycle after it.

    Metered usage and add-ons are not part of the waiver. A free trial is an offer about the recurring charge; consumption an application priced for those days is still billed on the same document. The distinction is the line type, so a CycleItemPricer that expresses a plan as several fixed lines is covered, and a metered line beside them is not.

    The waiver runs after the order-item preprocessor chain: a step may reprice the plan for an ordinary cycle, and may not reprice a period the plan screen promised for free. It runs before the coupon, so a customer does not spend one of their discounted cycles on a cycle nobody charged them for.

    One consequence to know if you listen for payments. In arrears the cycle still closes, so it still dispatches PaymentSucceeded — with an amount of zero and no provider reference, because nothing was collected. A listener that mails a receipt on that event will mail a zero receipt at every trial end unless it checks the amount. The event is not suppressed on purpose: a cycle that a customer's credit balance covers in full behaves the same way, and a listener that has to handle one already handles the other.

The cancellation button​

Where consumers in Germany conclude a subscription on your website, § 312k BGB asks for a cancellation button: a declaration anybody can make from your pages, signed in or not, kept with the moment it arrived and confirmed in text form. Your application draws the two buttons, the confirmation page and the receipt. CancellationButton takes the declaration in, and the order it works in is the point: the declaration is written first, because the row is the receipt; the contract is ended second, and nothing that happens there takes the receipt back; the confirmation goes last, so it can say when the contract ends.

use Pushery\Billing\Consumer\CancellationButton;
use Pushery\Billing\Contracts\ListsContracts;
use Pushery\Billing\Enums\CancellationKind;
use Pushery\Billing\ValueObjects\DeclaredCancellation;

// The contracts the page offers a signed-in person: a key for the option value and a name for the label.
$contracts = app(ListsContracts::class)->contractsOf($user);

$declaration = app(CancellationButton::class)->declare(new DeclaredCancellation(
kind: CancellationKind::Ordinary,
name: $name,
email: $email,
contract: $contractAsChosen,
confirmationEmail: $confirmationEmail,
contractKey: $chosenKey,
), owner: $user);

ListsContracts is the one thing your application supplies. The package binds RunningSubscriptions, which offers every running subscription of the person that is not already ending, named after its tier, and reads the initial term as one period of the tier from the day the subscription started. Bind your own to name your contracts, to leave some out, or to state an initial term you sold differently. A declaration made without signing in passes no owner and names its contract in free text.

What is carried out at once. A signed-in person naming one of their own running contracts and declaring an ordinary cancellation at the earliest possible date: the package works out the earliest end with GermanNoticePeriod, ends the contract there through ProratedCancellation where that falls inside the paid period and pays back the rest, and otherwise cancels at the period end. Every other declaration waits for a person, with the reason on the row in failure where something failed: a chosen date, an extraordinary cancellation, a declaration without an owner, a provider that refused. CancellationDeclaration::query()->awaitingReview() lists them, the oldest first, and CancellationButton::resolve() records that somebody dealt with one, with the end it came to. The moment it arrived never moves.

The wording. The confirmation mail and your receipt page say the same thing when both come from DeclarationStatement: cancellationLines() gives label and value per line, and cancellationDeclaredThrough() the sentence that quotes the button that was pressed. Write your confirmation button from the translation key billing::declarations.cancellation.button, so that sentence quotes your button. Every moment is stated in billing.consumer_rights.declaration_timezone, with the zone named. Values the person typed reach the mail as they were typed and cannot turn into a link. A DeclarationRefused names the field a declaration came without.

The receipt. A declaration is bound by its random token, never by its id, so a route such as /cancellation/{declaration} resolves it, and billing.consumer_rights.declaration_receipt_route makes the confirmation mail link to it. The rows are kept as business letters: an erasure unlinks them and keeps them, and billing:prune removes them with the records of an erased owner, counted from the year a declaration arrived.

The withdrawal function​

Where consumers conclude a contract on your website, § 356a BGB asks for a withdrawal function: the person gives their name, the contract and an address, presses a confirmation button, and receives a confirmation of receipt with that content and the moment it arrived. WithdrawalFunction takes the withdrawal in, and its order is the point: the declaration is kept and confirmed before anything is carried out, because neither may depend on a payment provider being reachable. A withdrawal counts from the moment it was sent, so declared_at never moves.

use Pushery\Billing\Consumer\WithdrawalFunction;
use Pushery\Billing\ValueObjects\DeclaredWithdrawal;

$declaration = app(WithdrawalFunction::class)->declare(new DeclaredWithdrawal(
name: $name,
contract: $contractAsChosen,
confirmationEmail: $confirmationEmail,
contractKey: $chosenKey,
), owner: $user);

The contracts come from the same ListsContracts as the cancellation button. A signed-in person withdrawing from one of their own contracts has it carried out at once through ConsumerWithdrawal::withdrawSubscription(), which ends the contract and refunds the period less the value of what was already provided, for the contract the person chose. A closed withdrawal window, a contract that is gone, an unreachable provider and a refund the provider refused leave the declaration for a person, with the reason in failure and the payment in charge_reference where a refund was refused. WithdrawalDeclaration::query()->awaitingReview() lists them, and WithdrawalFunction::resolve() records that somebody dealt with one.

The confirmation is written from DeclarationStatement::withdrawalLines() and withdrawalDeclaredThrough(), which a receipt page uses as well. Write the confirmation button from billing::declarations.withdrawal.button, so the sentence that quotes it quotes your button. The receipt, the time zone and the retention work as for the cancellation button above.

Coupons, and what "applied" means​

The account hub has a coupon field, and the status under it answers one question: would the driver you configured actually apply this code? Not "does the code exist" — that is a different question, and answering it there would tell a customer their code took and then charge them in full.

Which catalog answers depends on who applies the discount:

Your driverWhere the code has to beWhat applies it
A hosted checkout (Stripe)billing.coupons with a stripe_coupon mapping, or a billing_coupons row with a provider_coupon_idThe provider, on its own checkout session
A driver the package bills itselfThe billing_coupons tableThe package, as a discount line on each cycle it covers

A billing.coupons entry with no stripe_coupon reaches the checkout session as nothing at all, so the hub calls it invalid. That is not a new limitation — it is the existing one, said out loud.

A live billing_coupons row resolves its code on its own, with no config entry. Where a live row and a billing.coupons entry share a code, the row answers, and its provider_coupon_id, when set, is the coupon the checkout session gets.

Under a driver the package bills itself the coupon is carried, not spent, across the redirect. The customer goes to the provider to establish a mandate; the code travels with the rest of the request and is redeemed when the mandate settles and the subscription becomes real. A customer who closes the tab has spent nothing: a coupon allows one redemption per owner, and burning it on a checkout that never finished would take it away for good.

A coupon never blocks a subscription. If it has run out, expired, or was already used by this customer in the time between the screen and the settlement, they are subscribed at full price rather than not subscribed.

Trial codes​

A coupon can give a subscription days of trial, instead of a discount or as well as one. Its trial_days name them by plan, with * for every plan they do not name:

use Pushery\Billing\Models\Coupon;

Coupon::query()->create([
'code' => 'PARTNER30', 'type' => 'percent', 'value' => 0, 'duration' => 'once',
'max_redemptions' => 500, 'expires_at' => '2026-12-31 23:59:59',
'trial_days' => ['pro' => 30, '*' => 14],
]);

The code travels like any coupon code: the customer enters it, and it goes to the subscription start. The days count only where the code is redeemed, under the coupon's row lock, so of two customers racing for its last redemption one gets the trial and the other none:

  • A subscription whose trial needs no payment method starts at once, and the code is redeemed in the same transaction that writes it.
  • A subscription that needs a mandate carries the code across the redirect, and the code is redeemed when the mandate settles. A customer who never comes back has spent nothing.

A code is spent only when it lengthens the trial. One that gives fewer days than your trial policy is left alone, so the customer keeps it. A code is honored for a customer who had a trial before, because winning someone back is what such a code is for. The end of the trial is announced like that of any subscription trial, by billing:warn-ending-trials.

To check a code on your own form before the start, TrialCodes::assertGives() answers the days the code gives the plan. It throws TrialCodeRefused with one message for every code that gives nothing, whether it is unknown, withdrawn, past its last day, used up, issued by a seller or without days for the plan, so the answer never tells a stranger which codes exist:

use Pushery\Billing\Discounts\TrialCodes;
use Pushery\Billing\Exceptions\TrialCodeRefused;

try {
$days = app(TrialCodes::class)->assertGives($request->string('code')->value(), 'pro');
} catch (TrialCodeRefused $refused) {
return back()->withErrors(['code' => $refused->getMessage()]);
}

Localized hub and emails​

The hub and its emails ship translated in English, German, Spanish, French, Italian, Dutch and Portuguese, with an informal register throughout. Publish the views or the translations to customize them:

php artisan vendor:publish --tag=billing-views
php artisan vendor:publish --tag=billing-lang

Every publishable asset also sits under a shared billing umbrella tag, so php artisan vendor:publish --tag=billing publishes the config, migrations, views and translations in one go.


← Back to the documentation index

SEPA direct debit on your own form​

With the Mollie driver, an account holder can give a SEPA direct debit mandate on a form of your application, with their name and IBAN and no payment. Pushery\Billing\DirectDebit\DirectDebitMandates has one call per screen:

use Pushery\Billing\DirectDebit\DirectDebitMandates;
use Pushery\Billing\ValueObjects\DirectDebitConsent;

$mandates = app(DirectDebitMandates::class);

// The form: the mandate text for the payer's country, under a new reference. Null means no
// mandate is taken there, and the form offers another way to pay.
$text = $mandates->text('DE');

// The confirmation: what the account holder entered, and the text the form showed.
$mandate = $mandates->give($owner, new DirectDebitConsent(
accountHolder: $request->string('account_holder')->value(),
iban: $request->string('iban')->value(),
country: 'DE',
reference: $text->reference,
statement: $text->statement(),
notifyEmail: $request->string('email')->value(),
ip: $request->ip(),
userAgent: $request->userAgent(),
));

text() answers the parts a form shows: heading, the particulars the rulebook asks the payee to state, the paragraphs with the authorization and the refund notice, and the agreement of the notice period beside the mandate. statement() joins them into one text.

give() checks the IBAN (its country, its length and its check digits), checks that the text is still the one the form showed, and registers the mandate at Mollie. The payee is Stichting Mollie Payments with its creditor identifier, on behalf of billing.company.name. The mandate becomes the one the local engine charges, because the form is where the account holder chose it. A refusal throws DirectDebitMandateRefused with a reason and stores nothing.

The consent is kept in billing_mandate_consents, written once: the text, the reference, the account holder, the country and last four characters of the account, the agreed notice period, the address the notices go to, and when and from where the account holder confirmed. An erasure unlinks it from its owner and keeps it, because the payee has to be able to show the mandate while a debit drawn under it can still be disputed.

The rulebook asks for the mandate in a language of the payer's country. The package ships the German wording, for AT, DE, LI and LU. Another country gets a mandate once billing.direct_debit.mandate_wordings names a class of your own implementing Pushery\Billing\Contracts\SepaMandateWording for it.

The notice before each debit​

The local engine announces every SEPA direct debit before it draws it, by DirectDebitAnnouncedNotification, billing.direct_debit.notice_days ahead, or by the period a mandate's consent records. That covers mandates given on your form and mandates a first payment established, since Mollie collects iDEAL, Bancontact and the other bank methods as direct debits afterwards. A notice goes to the address the account holder gave with the mandate, and to the owner otherwise.

  • A subscription is announced once for its amount, which then covers each later due date. The engine announces a due cycle the agreed number of days ahead, priced the way the cycle prices it, so the debit is drawn on its day. When the amount changes, the new amount is announced before it is drawn.
  • The rest of a period after a change, a late fee, and the retry of a cycle the bank returned are each announced on their own and collected on the first day their notice allows.
  • A debit that comes due without a notice that names it is announced then and waits for its notice period. The subscription's next run moves to that day, and nothing is claimed in between.

Each notice is recorded in billing_direct_debit_notices, which goes with the owner on an erasure.