Database reference
Every table the package creates, what it holds, and what an erasure request does to it.
The migrations live under database/migrations/server and are loaded by the service provider, so
php artisan migrate picks them up without publishing anything. The one migration that is generated is
the owner-columns migration billing:install writes against your own billable table — see
the last section.
Naming
No package column carries a vendor's name. A row that points at something in the payment provider stores the
provider key in provider and the remote id in provider_id, so the same row shape holds for any
driver. Money is always an integer in the currency's minor unit, in a column ending in _minor, beside a
three-letter currency — never a float, and never a bare number whose currency you have to infer.
The one vendor-shaped name in the whole design is on your table: billing.customer.column defaults to
stripe_id, because that is the column Cashier already created for apps coming from it. Rename it in config
and the package follows.
The tables at a glance
billing:erase {owner} answers a right-to-erasure request, and every table below has one of five answers.
The eraser and the billing:export exporter read the same list, so a table cannot be covered by one and
forgotten by the other.
| Table | What it holds | On erasure |
|---|---|---|
billing_subscriptions | One row per owner and subscription type: the local mirror of the provider's subscription | Purged |
billing_subscription_items | The priced lines of a subscription, for a driver that bills the cycle locally | Cascaded |
billing_orders | The operational billing unit a due cycle is assembled into | Purged |
billing_order_items | The lines of an order | Cascaded |
billing_invoices | The frozen financial document, with its buyer snapshot and lines | Retained |
billing_addon_purchases | A one-time purchase and any reversal of it | Retained |
billing_credit_balances | An owner's money credit, per currency | Purged |
billing_credit_ledger_entries | Every movement behind that credit, with the reason it happened. A movement is a booking record, so it outlives the balance | Retained |
billing_payment_mandates | Stored mandates a local-engine driver may charge off-session | Purged |
billing_payment_mandate_anchors | One row per owner and provider whose mandate was stored, which every storing locks so that two of them take turns. It holds nothing else | Purged |
billing_direct_debit_notices | Every notice the local engine sent before it drew a SEPA direct debit: the mandate, the amount and the first day the debit may be drawn | Purged |
billing_prepaid_units | Usage units an owner bought that never expire | Purged |
billing_usage_grants | Units of a meter given until a moment, such as a plan's allowance for its period: what was given, what is left, and what an expiry wrote off | Purged |
billing_ai_spend | What model calls cost per provider and UTC day, and per owner and day, in millionths of the currency: the accounts of the AI spend brake | Purged |
billing_usage_counters | The per-period usage total per meter | Purged |
billing_usage_events | The metering outbox: each recorded usage and its reporting state | Purged |
billing_usage_reservations | Short-lived holds on a metered allowance | Purged |
billing_spend_caps | An owner's cap on what usage past the allowance may cost per billing period, and the period in which it last stopped them | Purged |
billing_cancellation_surveys | Why an owner canceled, when they chose to say | Purged |
billing_merchant_accounts | A merchant's account at the provider and the capabilities it has confirmed | Purged (merchant) |
billing_merchant_customers | Which buyer a customer reference means inside one merchant account | Purged |
billing_creator_tax_statuses | A merchant's tax standing over time, as dated intervals | Retained (merchant) |
billing_creator_tax_status_anchors | One row per merchant whose tax standing was recorded, which every recording locks so that two of them take turns. It holds nothing else | Purged (merchant) |
billing_merchant_charges | A payment routed to a merchant, and what has since been reversed off it | Retained (merchant) |
billing_refund_attempts | One intent to reverse money, recorded before the provider is called | Not owner-scoped |
billing_access_grants | What somebody bought and may still open — ownership of a work, not a plan's permissions | Purged |
billing_coupon_redemptions | Who redeemed which coupon | Purged |
billing_coupons | The coupon definitions themselves, each belonging to an issuer — the platform, or one merchant | Not owner-scoped |
billing_webhook_events | One row per delivery: the dedup key, the raw payload and the delivery state | Scrubbed |
billing_webhook_effect_runs | One row per effect per delivery, so a replay cannot double-apply | Not owner-scoped |
billing_events | The append-only audit ledger | Not owner-scoped |
billing_number_sequences | The gapless invoice-number counter, per scope | Not owner-scoped |
billing_self_billing_agreements | A creator's standing agreement that the platform may self-bill them, versioned and revocable | Retained (merchant) |
billing_submitted_invoices | A creator's own invoice, submitted through the fallback lane and reconciled before payout | Retained |
billing_subscription_intents | What somebody asked to subscribe to, held across the provider redirect that establishes their mandate — keyed on the payment, so a customer merely adding a card cannot be handed a subscription they never asked for | Purged |
billing_provider_fees | What the payment provider charged the platform — a dispute fee, kept once per dispute so it can be posted and reconciled | Retained (merchant) |
billing_disputes | Every dispute a provider opened, kept once per dispute whatever its outcome, so the rate the card networks measure can be read through DisputeRates | Retained (merchant) |
billing_in_person_sales | Every sale put on a card reader, with the place and the tax decided for it at the counter, so the receipt can state them when the provider confirms the card payment. Unique on the provider and the payment. It names no buyer: a buyer at the counter is anonymous | Not owner-scoped |
billing_withdrawal_consents | The two declarations a buyer made before a digital work was provided — with the version of the notice they were shown, so a later edit cannot reinterpret the sale | Retained |
billing_mandate_consents | The proof of each SEPA direct debit mandate given on the application's form: the text the account holder confirmed, their name, the country and last four characters of the account, the agreed notice period, and when and from where they confirmed | Retained |
billing_cancellation_declarations | Every cancellation declared through the cancellation button, as it was declared: the kind and reason, who declared it, the contract, when it should end, where the confirmation went, the moment it arrived, and what was done about it | Retained |
billing_withdrawal_declarations | Every withdrawal sent through the withdrawal function, as it was declared: who sent it, the contract, where the confirmation went, the moment it arrived, and what was done about it | Retained |
billing_tax_hold_warnings | That a merchant was told the tax-standing deadline is coming, and which deadline — so the message is sent once, and again only if the date moves | Purged (merchant) |
billing_seller_declarations | What a seller outside the Union declared before the platform supplies its goods: where it is established, its tax numbers, the goods and where they are dispatched from, with the statement the seller confirmed, its SHA-256 hash and the moment it arrived. A seller who declares again writes a new row, and the newest one is in force | Retained (merchant) |
billing_seller_declaration_contradictions | A signal that contradicts what a seller declared, such as a delivery address of the seller inside the Union, with the moment it was observed. After it, the seller's next sale of goods the platform would supply itself waits for a new declaration | Retained (merchant) |
billing_filing_reminders | That one filing obligation, for one due date, has been announced — keyed on both, because two obligations share the end-of-January deadline and a marker on the date alone would silence the second | Not owner-scoped |
billing_reporting_exports | What was reported about sellers for one period, as the exact bytes — with the format, its version and a fingerprint. A second run is a second row, never an overwrite, so "did the figures move?" stays answerable | Not owner-scoped |
billing_reporting_acknowledgements | One operator's answer to one plausibility finding, for one reporting period — who, when and why. The period is in the key, so an answer clears that period and never becomes a switched-off rule | Not owner-scoped |
billing_reporting_filings | Which produced record went out for a period, when and by whom — and which earlier filing a correction supersedes. A period can be filed once; everything after that names what it corrects | Not owner-scoped |
billing_merchant_balances | What a merchant owes the platform per currency when a clawback could not take it back — signed, offset against later settlements | Retained (merchant) |
billing_vouchers | A voucher somebody paid for, with what is left on it — redeemable on the platform only, never topped up, cashed out or handed on | Retained |
billing_voucher_movements | Each issue, redemption and expiry as it happened, which is what the accounting export books — a voucher is a liability when sold and only becomes turnover when spent, and the voucher row itself can only say what is left today. It names the voucher code and no person, so an owner erasure passes it by: the movement is a fact about money, and the buyer's link lives on billing_vouchers, which is where erasure unlinks it | Not owner-scoped |
billing_voucher_volume_notices | That one voucher-volume level, for one currency, in one calendar year, has been announced. The command runs daily, so without the marker the same warning would go out every morning and the channel would stop being read. Keyed on the year rather than forever, because the window is rolling: a figure can fall back and cross again years later under a genuinely new obligation, and a permanent marker would swallow it. The volume as it stood is kept beside the fact, since recomputing it later gives a different answer | Not owner-scoped |
billing_market_access_log | When each market was opened or closed and by whom — append-only, because the sales made while it was open need the record that explains them | Not owner-scoped |
billing_place_evidence | Which country a sale was taxed in and what said so — country codes only, no address, card or connection data | Retained |
billing_tax_id_verifications | What a tax authority's register said about a buyer's tax ID and when, one row per answer that changes the one before: the number, its type and the verdict | Retained |
billing_document_artifacts | The electronic document exactly as it was issued — bytes plus a fingerprint, never a later re-render | Retained |
billing_tax_return_exports | Each produced tax-return file: when, what it contained, and a fingerprint of the exact bytes — a second run is a second row, never an overwrite. return_type says which declaration a row is: one_stop_shop, import_scheme or recapitulative_statement; a month is kept under the quarter it falls in and told apart by its label | Not owner-scoped |
billing_us_tax_forms | What a seller declared about where they are taxed, and when it expires — the declaration and a reference to the signed document, never the identifying number | Retained (merchant) |
billing_buyer_protection_holds | A sale whose payout waits for the buyer to confirm, with the two deadlines it runs on — the money stays with the payment provider throughout | Retained (merchant) |
billing_merchant_creditor_accounts | The ledger account a merchant's payables book against, where the installation keeps an account per merchant rather than one collective account | Retained (merchant) |
billing_document_deliveries | When a settlement document was made available, when its recipient was told, and when they fetched it — append-only | Retained (merchant) |
billing_invoice_exchange_rates | The rates a document was actually converted at, frozen onto it — one per conversion layer, append-only | Not owner-scoped |
billing_exchange_rates | Published exchange rates you imported, one per pair per day per rule — reference data, with nobody's personal data in it | Not owner-scoped |
billing_settlement_restatements | One row per settlement a corrected creator standing made wrong: whether it was issued again, left alone or blocked, and the cancellation and replacement that stand in its place. It names no person | Not owner-scoped |
billing_seller_data_escalations | What the platform did about a seller whose record stayed incomplete, one row per episode: the reminders, the channels they reached and the measures that applied. It names fields, never their values | Retained (merchant) |
Purged — operational data with no reason to outlive the person it belongs to. Deleted outright.
Retained — the financial record. A valid invoice has to carry the buyer's name and address, and invoices
have to be kept for years, so the right to erasure yields to the retention obligation: the row is unlinked
from the owner (owner_type and owner_id go null, owner_erased_at is stamped) and kept until
billing:prune ages it out. Where a table keeps an erasure key (erased_owner_key on invoices and credit
movements, erased_merchant_key on creditor accounts), the erasure writes one random key for the person into all
of them, on both axes, so their kept rows stay together and name nobody: a settlement document of an erased merchant
still books to the creditor account the merchant had.
Scrubbed — the row survives, its personal data does not. The delivery row is the dedup that keeps a
redelivery from being processed twice; the payload inside it carries the customer's email, name, billing
address and card last four, so the payload is nulled, and so is last_error, which can quote it. The scrub
stamps payload_removed_at, and a later delivery of the same event leaves the payload out.
Cascaded — a child keyed to its parent rather than to the owner. The eraser reaches it by joining through
the parent, deliberately not by trusting the foreign key: SQLite enforces foreign keys only when
PRAGMA foreign_keys is on, and it is off by default, so an erasure obligation resting on the cascade would
fail silently on the one engine where nothing would look wrong. The cascade stays as defense in depth.
Not owner-scoped — no personal data keyed to an owner. The audit ledger is the exception worth naming:
it records who did what, and it is aged out on its own clock (billing.retention.audit_days) rather than by
an erasure request.
Columns
Timestamps (created_at, updated_at) are on every table and are not repeated below. A column marked ? is
nullable.
billing_subscriptions
owner_type + owner_id · type (default default, so one owner can hold several subscriptions) ·
provider · provider_id? · status · tier_key? · scheduled_tier_key? · scheduled_swap_at? ·
trial_ends_at? · ends_at? · delinquent_since? · dunning_level (default 0) · synced_event_at? ·
current_period_start? · current_period_end? · declaration_reference? · started_at? ·
merchant_uid · merchant_type? · merchant_id? · scheduled_processing_at? · payment_reminded_on? · terminated_at? ·
seat_quantity? · seat_quantity_since? · seat_days_accrued (default 0) · tier_adjustment_accrued (default 0) ·
collection_timing? · paused_at?
Unique on (owner_type, owner_id, type, merchant_uid), indexed on (owner_type, owner_id,
status). The merchant is part of that key rather than outside it, so one owner may hold the same
subscription type with several merchants — the marketplace case — while still holding only one of it per
merchant.
delinquent_since is a timestamp rather than a gateway status: the dunning ladder counts days from it, so a
provider outage cannot reset an owner's position on the ladder. A collected cycle clears it, together with
dunning_level and payment_reminded_on, so the next failure starts a ladder of its own. synced_event_at is the ordering guard —
an out-of-order webhook cannot move the row backwards. declaration_reference is the key the buyer's
withdrawal declarations were recorded under, and it belongs to one subscription: a new subscription that
reuses the row starts without it. started_at is when the subscription standing in the row began, the day its
withdrawal window opens, and a new subscription that reuses the row starts again.
The three seat columns are written only for a subscription the local engine bills. seat_quantity is the seat
count in force (null bills one unit), seat_quantity_since when it began, and seat_days_accrued the seat-days of
the running period before that; the cycle that closes the period bills them and starts the next at zero. A new
subscription that reuses the row starts at zero too, counted from its own start, and keeps only the seat count.
tier_adjustment_accrued is written only for a subscription the local engine bills, too. The cycle prices a period
at the tier in force when it closes, and a tier changed during the period leaves the difference its own price makes
for the seat-days it held: the price difference in minor units times those seat-days, negative after an upgrade. The
cycle divides it by the period's days, adds it to the plan charge and starts the next period at zero, as does a
new subscription that reuses the row.
collection_timing is when the local engine collects each period of the subscription, in_advance or in_arrears,
written from billing.collection_timing when the subscription starts and never changed afterwards. A row started
before the column existed holds null and is collected in arrears. Collected in advance, the seat-days and the tier
difference above are not what a change is billed by: a change inside a paid period is squared at once instead.
paused_at is when a subscription the local engine bills was paused, and null while it is not. When it goes on, the
time since then is added to the period it is in and to its next run.
billing_subscription_items
billing_subscription_id (foreign key, cascades on delete) · plan_key · price_ref? · quantity? ·
metered (default false) · amount_minor? · currency · preprocessor?
Unique on (billing_subscription_id, plan_key) — the dedup that stops a repeated cycle build from adding
every line twice and doubling the amount.
billing_orders
owner_type + owner_id · provider · subscription_id? (foreign key, nulls on delete) · total_minor ·
currency · status (default open) · period_start? · period_end? · processed_at? ·
payment_reference? · reference?
Unique on (subscription_id, period_start), indexed on (owner_type, owner_id, status). A null
subscription_id does not collide, so an owner may have many one-off orders. Unique on reference where it is
set: a late fee carries its dunning reference there, so the advance running twice for one rung opens one fee.
An order that charges the rest of a period for a change inside it names its subscription and starts a second after
the period's own order at the earliest, so the two never share the key.
billing_order_items
order_id (foreign key, cascades on delete) · description · unit_price_minor · quantity (default 1) ·
total_minor · currency · tax_bps? · type · metadata? (JSON)
Tax is basis points, not a percentage: the money layer is integer-only, and a percentage float here would be the one place a rounding error could enter.
billing_merchant_accounts
merchant_type + merchant_id · provider · account_reference · charges_enabled (default false) ·
payouts_enabled (default false) · details_submitted (default false) · capabilities_refreshed_at? ·
capabilities_event_at? · status · status_reason? · status_changed_at? · deauthorized_at?
capabilities_event_at is the provider's own stamp, in Unix seconds, of the report the three flags were last taken
from. Reports arrive out of order, and one older than that stamp is set aside.
Unique on (provider, merchant_type, merchant_id) and on (provider, account_reference). A second
account for the same merchant is not a harmless duplicate: it splits their money across two identities the
provider pays separately.
The three capability flags are the provider's to grant and to withdraw, so nothing but a provider report
writes them, and all three default to false — an account nobody has heard about yet cannot receive money.
They are cached rather than read live because they are reported asynchronously; reading them on every routed
charge would put a network call on the money path and still not be current.
This table is on the merchant erasure axis, not the owner one. It is purged with its merchant: a provider account reference is an operational key, not a financial record.
billing_merchant_customers
owner_type + owner_id · provider · account_reference · customer_reference
Unique on (provider, account_reference, customer_reference).
A provider's customer ids are unique within the account that issued them, not across a platform and all its merchants. The ordinary global lookup is right for a single seller and quietly wrong once a second account exists: two different people holding the same id under two merchants resolve to whichever one the global lookup finds first. That is not a failed lookup anybody notices — it is a webhook attributed to a stranger, and with it their invoice, their receipt and their data.
billing_merchant_charges
merchant_type + merchant_id · merchant_erased_at? · provider · charge_reference · payment_reference? ·
transfer_reference? · gross_minor · fee_minor · fee_bps? · fee_flat_minor? ·
commission_tax_bps? · net_minor ·
currency · settlement_state ·
settled_at? · term_canceled_at? · refunded_minor · transfer_reversed_minor · fee_refunded_minor ·
charge_type? · purpose? · tax_archetype? · seller_posture? · deemed_supply? · goods_consignment? (JSON) · destination_country? · fee_residual? · settlement_invoice_id? · transfer_moved_minor? · transfer_requested_at? · transfer_failed_at? · transfer_failure? · transfer_withheld_at? · transfer_withheld_reason? · buyer_fee_gross_minor? · buyer_fee_net_minor? · buyer_fee_tax_minor? · buyer_fee_place_of_supply? · buyer_fee_refunded_minor (default 0)
Unique on (provider, charge_reference), indexed on (merchant_type, merchant_id, settlement_state) and
on (provider, payment_reference).
charge_reference is the payment for a one-off sale and the invoice for a subscription cycle, one row per
cycle. payment_reference holds the payment behind such a cycle, because a dispute names only the payment:
without it, a lost dispute over a routed cycle could not find the sale it belongs to. It is null on a one-off
sale, whose reference already is the payment, and on cycles recorded before the column existed.
A hosted one-off sale is written down before the buyer pays, and its payment does not exist yet, so until the
payment confirms charge_reference holds the package's own reference for the sale, billing_sale_…. The
confirmation replaces it with the payment.
deemed_supply names the paragraph that makes the platform the supplier of the goods sold, imported_consignment
or within_the_union, decided once before the money moved, and null on every other sale. goods_consignment keeps
the consignment the lane passed for a sale of goods. The buyer's document of a hosted purchase decides the tax from
them once the provider reports where the buyer is, and a refund reads the paragraph from the row rather than
classifying the sale again. destination_country is where the goods were sent or the buyer was: the direct lane
writes it with the sale, a hosted purchase once the provider reports the address the buyer entered. The platform's
records of the sales it only arranges read both from here.
term_canceled_at is stamped when the prepaid term the charge paid for is canceled through
PrepaidTermCancellation, before the refund is asked for, so that the term is canceled once. A refund the
provider refuses clears it again.
transfer_requested_at is stamped each time the merchant's share is asked of the provider, before the answer
comes. A pending sale whose request is more than a quarter of an hour old belongs to a run that stopped during
the transfer: billing:doctor counts it beside the failed ones, and billing:marketplace:retry-transfers asks
again under the sale's own key, so a transfer that did arrive is not paid twice.
Three cumulative totals rather than one, and that is the design rather than an accident. The moment a platform keeps its commission on a refund — a normal policy, the work was done — the buyer is returned the whole payment while only the merchant's share is clawed back, and the two totals part company permanently. A single column cannot carry three monotonic sums that move at different speeds, and code that reads the refunded total where it meant the reversed one skips a partial reversal without a word.
fee_bps and fee_flat_minor are the commission terms the sale was made under, frozen beside the
amounts. The amounts alone settle a full clawback — everything still held goes back — but not a partial one:
recomputing a proportional share is the wrong figure whenever the fee has a flat part. A 100.00 sale at 10%
plus 1.00 pays out 89.00, and half of it refunded leaves a 50.00 sale that would have paid out 44.00, so
45.00 comes back and not 44.50.
commission_tax_bps is the third frozen term and says which amount the commission was taken on. The rate is
a net rate — applied to what the sale is worth before the buyer's tax, not to what the buyer paid — so on
119.00 at 19% with a 10% rate the platform keeps 10.00 and not 11.90. The rate is stored rather than the
computed base, because a partial clawback recomputes the commission on what remains of the sale, and a
stored base amount cannot answer for a remainder.
Two places cannot state the fee on the net when the payment is made, and both are brought there. Stripe's
hosted subscription on the destination lane expresses the fee as a percentage of the invoice total,
which includes the buyer's tax. Each renewal's invoice is a draft for about an hour before Stripe charges it,
and the package sets the draft's fee on the net in that hour, so a renewal's row carries the invoice's rate. The
first cycle is paid as the checkout completes, with no draft to set, and withholds the commission on the gross,
11.90 on that same sale; so does a renewal whose draft was charged before the fee was set. The hosted one-off
purchase needs an absolute fee at the moment the session opens, which is before the buyer's rate is a fact at
all, so at a tax-inclusive price it takes the commission on the price. In each of these the difference, 1.90
here, is given back to the merchant as part of the fee after the payment, and the row's fee, net and rate are
then written together. Until that has happened the row says 0, and it stays so where nothing could be given
back, such as a sale refunded first. A hosted subscription on the separate-transfer lane withholds nothing at
the payment, so each of its cycles takes the commission on the net, separated by the tax rate the invoice names,
and its row carries that rate. An invoice that charged no tax, or names more than one rate, keeps the payment as
its base, and its row says so with a 0 as well.
null on that column means the commission was taken on the payment itself, which is what every row written
before the basis was corrected actually did. That is a description of those rows and not a gap in them: they
describe money that moved that way, and a reversal reads the basis off the row so an old sale comes back on
the basis it was made on. A new row always states its rate, including 0 for a tax-free sale.
The three terms are nullable and never backfilled. Rows written before they existed genuinely do not know them,
and a backfill from the current configuration would be exactly the guess-as-fact the columns exist to
prevent: a platform that raised its rate would claw old sales back at the new one, and both figures would look
plausible. An old row answers null, and the caller has to see it.
transfer_reference is null until settlement, which is a state and not a gap. A payment waiting on a
3-D Secure step — routine under PSD2 — is pending, not failed: nothing is credited to a merchant before it
settles, and nothing is discarded because the buyer was asked to authenticate.
Retained on the merchant axis: it says what money moved and to whom, so it outlives the person named on it, and it is the only place a clawback ceiling lives.
billing_access_grants
owner_type + owner_id · purchaser_type? + purchaser_id? · content_type · content_ref · source ·
status · acquired_at · expires_at? · revoked_at? · revoked_reason? · reinstated_at? ·
source_reference? ·
update_policy · version_pin_ref? · update_window_ends_at? · conformity_update_until? ·
conformity_waiver · conformity_waiver_ref? · withdrawal_type? · withdrawal_declaration_ref? · bundle_ref? · max_seats? ·
merchant_uid · merchant_type? + merchant_id? ·
withdrawal_window_ends_at?
Unique on (owner_type, owner_id, content_type, content_ref, merchant_uid).
Ownership is a fact, not a state. What a plan lets somebody do changes the moment their tier does; what they bought outlives the plan, the creator's account, and the work's own publication. That is why this is a row of its own rather than something read from a subscription, and why nothing here is soft-deleted — a grant that stopped granting says so, with a reason and a date, because "why can this person no longer read what they bought" is a question somebody will ask and a deleted row cannot answer.
content_ref, version_pin_ref, bundle_ref, withdrawal_declaration_ref and conformity_waiver_ref are opaque strings and
never foreign keys. A foreign key would hand the referenced table a veto over ownership: deleting a work
would cascade into "this person never owned this" — and deleting a work is exactly when the record of who
owned it matters most.
The merchant axis carries a merchant_uid sentinel (platform, or m:<type>#<id>) rather than a nullable
column, for the same reason as billing_subscriptions: on MySQL two NULLs do not collide, so a nullable
merchant inside the uniqueness would let a second single-seller grant through, and the double-ownership
guard would vanish the moment the first marketplace row appeared.
The indexed string columns carry explicit lengths. MySQL caps an index key at 3072 bytes and utf8mb4 counts four bytes per character, so four default-length strings overrun it before the fifth column is considered.
expires_at null means permanent, not unknown: a grant always knows whether it ends.
billing_refund_attempts
provider · charge_reference · amount_minor · currency · cause? · transfer_reversal_minor ·
fee_refund_minor · dispute_fee_minor? · idempotency_key · status · failure_reason? ·
completed_at? ·
transfer_reversal_short_minor?
Unique on idempotency_key, indexed on (provider, charge_reference).
The row is written before the provider is called, and the provider's idempotency key is derived from the id the database assigned it. That ordering is the whole feature. A cumulative key works for the webhook path, because the provider sends the running total and a redelivery carries the same one; an operator-initiated refund has no external total, so computing one locally is a read-modify-write. The call times out, the operator retries, the local total has not moved, a new key is derived — and the buyer is refunded twice while the merchant's transfer is reversed twice. Deriving the key from a row that already exists makes the retry send the same key, and the provider collapses it.
A pending row is therefore not "nothing happened" but "we do not know", including a call that may already
have succeeded. Asking again is safe precisely because the key travels with the row. The reversal after a lost
dispute relies on that: a call that throws leaves its row pending for the queue's next try, and the row turns
failed only once the queue has given up on the job.
A caller that names its refund with a key of its own, as the idempotencyKey of BillingAdmin::refund()
does, finds that key on the row instead, and a second call with the same key finds the same row. While the
row is pending the provider is asked again under that key. Once it has succeeded, the call returns the
refund the row recorded and the provider is not asked again. A refusal moved nothing, so the next call opens
a row of its own under the key and a count (key#2). A key that already names a refund of another charge
or another amount is refused.
cause and dispute_fee_minor are nullable because both are facts about the attempt that were settled when
the reversal was decided, and older rows recorded neither. A backfill would be a guess presented as a fact,
and the rows it guessed wrong are exactly the disputes somebody would later go looking for. On
dispute_fee_minor the null carries meaning of its own: it says no dispute happened, where a zero would say
one happened and the provider charged nothing for it — a different claim, and one worth checking against a
statement.
billing_invoice_exchange_rates
invoice_id · layer · from_currency · to_currency · rate_scaled · rate_date · basis · source
Unique on (invoice_id, layer). Deleted with the document; nothing else deletes one, and nothing
updates one at all — the model refuses an update as a whole row rather than listing protected columns,
because a rate, its date, its rule and its publisher are one statement about one moment.
A rate is part of the booking, not a lookup. Looked up when somebody opens a document, you get the rate today rather than the rate the sale was booked at — and the difference is exactly what an audit finds, with the added twist that the second figure looks perfectly reasonable. Corrections read what is recorded here: a refund that re-derived the rate would reverse an amount nobody ever declared, and the difference would be a currency movement rather than anything either party did.
One row per layer, because one sale carries more than one lawful euro figure. The document takes one rule, the one-stop-shop return takes another — the central bank's rate at period end, which expressly excludes the monthly average the document may have used — and the payout is whatever the money moved at. That divergence is sanctioned rather than a defect, and a single frozen rate could not express it: one of the three would have to be re-derived later, at whatever the rate is then.
Rows rather than columns because the layer count is not fixed: a jurisdiction with its own reporting rule adds a fourth, and as columns that would be a migration on the busiest table in the schema.
rate_date is the day the rate was published for, which for a weekend resolves forward to the next
publication day — that is the day a reviewer looks the figure up under. Saying the day that was asked about
would cite a rate the bank never issued for that date.
Empty on a single-currency install. A sale that never converted has no rate to freeze, and nothing writes a trivial 1.0.
billing_exchange_rates
from_currency · to_currency · rate_date · basis · rate_scaled · source
Unique on (from_currency, to_currency, rate_date, basis), indexed the same way for the read path.
Empty until you import into it. This package ships no rates and will not: which rate is the correct one is jurisdiction knowledge, and the rules contradict each other — German domestic turnover takes the ministry's monthly average, the EU option takes the central bank's rate at the tax point, and the one-stop-shop takes the central bank's rate at period end while expressly excluding monthly averages. On the same turnover. A shipped default would be wrong for somebody by law rather than by oversight, and it would be wrong on documents that get held against the official series years later.
That is why basis is part of the key rather than beside it. The same pair on the same day genuinely has
more than one correct rate; keyed without it, importing the ministry's average would collide with the
central bank's daily rate and one would silently replace the other.
rate_date is the date the publisher stated, never the day the row was written. Fetched on a Saturday,
a central bank's daily file answers HTTP 200 carrying Friday's data — no error, no 404, the real date inside
the document — so an importer that stamped the clock would record a rate for a day the bank never published.
A monthly average is stored against the first day of the month it covers; that is a storage convention, not
a claim about that day.
from_currency → to_currency reads as the publisher states it: how many units of to_currency one
unit of from_currency buys. The central bank publishes "1 EUR = 11.0550 SEK", so that is stored as a
rate from EUR to SEK, and a row is never turned around. Asking for a direction nobody published is a
refusal, not a division — the rounded inverse is a figure the publisher never issued, and multiplied back
it does not return the amount you started from. Whoever needs the other direction converts deliberately, at
a scale they chose, and owns the rounding.
rate_scaled is the rate at eight decimal places as an integer, like every other figure on the money path.
Reading is separate from importing, and neither happens on the critical path of a sale: a rate for a past date does not change, so a live call would buy nothing there except a way to fail. A rate that was never imported is a refusal naming the currency, the day and the rule — never a zero, and never the nearest rate you happen to hold. Both of those put a plausible figure on a tax document and neither announces itself.
Not owner-scoped, and untouched by erasure: a published rate is a fact about a currency, not about a person.
billing_creator_tax_statuses
merchant_type? + merchant_id? · merchant_erased_at? · status · effective_from · effective_to? ·
source · evidence_ref? · business_founded_year? · attested_until? · hold_announced_at? ·
reattestation_due_announced_at? · expiry_reminded_at?
Unique on (merchant_type, merchant_id, effective_from), indexed the same way for the read path.
There is deliberately no current-status column anywhere. A document never asks what a merchant's standing
is — it asks what it was on the day the supply happened, and a single overwritable column cannot answer
that: a change in March would silently rewrite how every document from January should have looked, with the
old value gone. So the standing is a series of dated intervals and the current one is a query with a moment
in it. effective_to null is the open interval; the boundaries are half-open, so one instant belongs to
exactly one state.
created_at is not effective_from. A status can be recorded weeks after it started applying, and the gap
between the two is what makes a retroactive change visible as one.
It is retained on the merchant axis rather than purged, because it is the evidence justifying how the documents about that merchant were taxed — and those documents are kept for years. Delete it and a retained document survives with nothing left to explain it.
billing_self_billing_agreements
merchant_type? + merchant_id? · merchant_erased_at? · accepted_at · terms_version · evidence? ·
revoked_at?
Indexed on (merchant_type, merchant_id, accepted_at) for the read path — every settlement asks for one
merchant's agreements. The creator is the merchant, so the morph follows the merchant erasure axis.
A self-billed document is an invoice only if both sides agreed to the arrangement before the supply, so the
agreement is a real dated record, not a UI checkbox. accepted_at is the ex-ante anchor a document is
checked against — never created_at, which only says when the row was written. It is a framework agreement:
one row covers every future settlement. A clause change appends a new row rather than editing the last, so
which wording was in force when a past document was produced can always be read back. revoked_at
terminates the arrangement going forward and never touches documents already issued.
It is retained on the merchant axis rather than purged, for the same reason as the tax standing: it is the evidence that the self-billed documents about that creator were valid invoices, and those documents are kept for years.
billing_settlement_restatements
original_invoice_id · queued_for · state · blocked_reason? · cancellation_invoice_id? ·
replacement_invoice_id? · processed_at?
One row per settlement a corrected creator standing has made wrong, written when the standing changes and
worked off by billing:settlements:restate. state is pending, restated, unchanged or blocked, and
blocked_reason says why a settlement could not be issued again. Unique on original_invoice_id: a
settlement is canceled at most once, and a second correction re-opens its row. The row names no person;
the settlement it points at names the creator.
A settlement issued in place of a canceled one carries the canceled settlement's id in
billing_invoices.restates_invoice_id. Unlike a reissue it is booked and counted, because the cancellation
has taken the original's amounts back.
billing_seller_data_escalations
merchant_type? + merchant_id? · merchant_erased_at? · stage · incomplete_since · missing_fields (JSON) ·
missing_required (default false) · first_reminded_at? · second_reminded_at? · deliveries? (JSON) ·
measure? · measure_started_at? · ended_measures? (JSON) · resolved_at?
One row per episode of a seller's record staying incomplete, written by billing:seller-data:escalate.
stage is clear, first_reminder, second_reminder or measure_active, stored rather than worked out
from the dates. missing_fields names what was missing at the last assessment and missing_required
whether any of it is required of this seller now. deliveries lists each channel a reminder was handed
to, with merchant as the recipient where it went to the seller themselves and never an identifier.
measure is the measure in force; ended_measures keeps every one that ended, with the reason. An open
episode has no resolved_at, and a seller has at most one.
billing_invoices
owner_type? + owner_id? · owner_erased_at? · erased_owner_key? · provider? · provider_id? · number? · pdf_path? ·
total_minor ·
subtotal_minor? · tax_minor? · currency · locale? · status (default draft) · issued_at? ·
credited_invoice_id? · credited_invoice_number? · refund_attempt_id? · correction_key? · buyer? (JSON) · lines? (JSON) · reverse_charge
(default false) · buyer_reference? · vat_note? · oss (default false) · ioss (default false) · deemed_supply? · goods_located_in? · destination_country? ·
oss_rate? · tax_archetype? · sold_alongside_archetype? · place_of_supply_rule? ·
tax_rate_category? · tax_rate_bps? · supply_rate_bps? ·
platform_reporting? · rate_matrix_version? · recipient_tax_status? · supply_regime? ·
seller_posture? ·
document_series? · settlement_document_type? · receipt_tier? · order_id? · reissue_of_invoice_id? · restates_invoice_id? · reinstates_invoice_id? · settled_charge_reference? · charge_claim_key? · commission_bps? · commission_flat_minor? · commission_residual? · fan_gross_minor? · margin_minor? · due_at? · service_period_start? · service_period_end? · delivered_on? · settlement_period? · tax_exempt (default false) · tax_exemption_reason? · taxation_basis? · tax_base_change_reason? · destination_subdivision? · correction_kind? · seller? (JSON) · invoice_effect_revoked_at? · invoice_effect_revoked_channel? · write_off_recovered_at?
Unique on number, on (provider, provider_id) and on order_id — one document per order.
deemed_supply and goods_located_in are written on both documents of a supply of goods the platform is treated as
making itself, the buyer's receipt and the seller's settlement, from one classification: the paragraph, and for goods
already in the Union the member state they were in when the transport began. Both are frozen and copied onto a
correction.
locale is the language the document is written in, set when it is created: the owner's preferred language
where the owner implements HasLocalePreference, the application's otherwise, unless the creating code passes
one. It is frozen with the amounts, and the readable document renders in it, so one issued document reads the
same to everybody who opens it. A row from before the column has none and renders in the current language.
Two further composite keys stop a redelivered payment event from becoming a second numbered document,
which a return would then count twice. Unique on (owner_type, owner_id, document_series,
settlement_period) covers everything that claims a stretch of time, and on (owner_type, owner_id,
document_series, charge_claim_key) covers one-off sales, which carry no period. The series is part of
both because a creator's settlement and a buyer's receipt can name the same owner and the same period and
are two different documents.
The nulls in those two are the point rather than a loophole: every engine treats null as distinct in a
unique index, so only rows that really claim a period or really are a sale's first document are
constrained. charge_claim_key is null on a reissue, on every correction, on a settlement issued in place
of a canceled one and on anything carrying a
period — a prepaid term is twelve monthly documents over one charge, and claiming the charge would refuse
eleven of them.
A correction without a reversal row behind it names the event it corrects in correction_key, and unique on
(correction_key, credited_invoice_id) one event corrects each document once. A prepaid term cancellation
writes it, as prepaid-term-cancellation: and the charge's id, on the documents of both sides. The correction a
redeemed proration credit owes writes credit-offset:, the ledger entry and the invoice. The credit note
for a refund of an invoice this package raised writes the refunded state it documents: the payment, :credited:
and the cumulative refunded total, followed by :after: and the amount charged again once an invoice charged a
credited amount again. A partial refund and a later one name different totals, so both notes stand. Every other
correction leaves it null, and null is distinct in the index, so the corrections of a partial refund and of a
later one can both name the same document.
tax_rate_bps is the rate a document states and stays empty on one that states no tax. On a cycle or purchase
the package bills itself, priced gross, it is the quotient of the two rounded amounts, the rate the customer was
charged: 19.98 % for 16.67 and 3.33. supply_rate_bps is the rate the supply is taxable at, whoever supplies
it: on a settlement, which the DATEV export books a reverse charge by, and on a document the package bills
itself, where it is the statutory rate the supply was taxed at (20 % there). The periodic return groups its lines
by it and the DATEV export picks the reduced revenue account by it; a document that records none falls back to
tax_rate_bps.
refund_attempt_id is the one of those three that points at money rather than at another document. A
correcting settlement carries it to name the reversal it documents, and it is reachable as
$invoice->refundAttempt. Nothing inside this package reads it: the withheld-fee counter finds a charge's
reversals by (provider, charge_reference), because it wants every succeeded confirmation for that charge
and not the single one a given credit note happens to name. The column is there so that a document can be
traced back to the payment event that caused it — by you, in an admin screen or an audit, which is the one
reader it has.
write_off_recovered_at is set on a correction written off as uncollectible when a later payment reopens it,
at the moment WriteOffRecovered goes out. A reopened write-off is no longer a candidate for the next payment
of the same amount and no longer on RecoveredReceivable::provisionalWriteOffs().
reinstates_invoice_id is set on an invoice that charges again what a locally raised invoice was credited for a
chargeback the bank later reversed, and names that invoice. It is an invoice, not a correction: booked and counted
like any, with type code 380, and it names the invoice it charges again in BT-25. What an invoice stands credited
at is its credit notes less these invoices, and the credit note effect reads it that way.
Two of those columns answer where a document came from and where it is kept, and both are yours to fill in
one direction only. provider_id is written by this package when a webhook mirrors a provider's own
invoice, and by nothing else — so provider_id === null IS "issued locally", which is why there is no
locally_generated column to disagree with it. pdf_path is the opposite: the package never writes it, but it does read it — name the disk in
billing.invoices.pdf_disk and the download route serves the file you kept instead of rendering a new
one. Record there wherever you kept the issued PDF — a disk-relative path, a media
id, an object key; the package takes no view. It stores no PDF itself, because storage is your decision with
a disk and a retention policy behind it.
The tax characteristics — what was sold, which rule decided where it was taxed, which rate band applied, the rate in basis points, whether the sale is reportable, and which revision of the rate table answered — are frozen alongside the amounts rather than read back from the product on demand, because products change legitimately: an author adds a video to a text-only work and it loses its reduced band, a creator turns a broadcast into a private session and it stops being taxed where the buyer is. Both changes are right going forward and neither may reach backwards.
sold_alongside_archetype is what a voluntary payment was paid on, and it is kept because it is what those
characteristics were derived FROM. A tip has no treatment of its own — the taxonomy delegates all five of its
consequences to the thing it accompanied — and the document states only two of them. The one nothing states
is asked latest: whether the seller behind the tip has to be reported, decided months afterwards by a run
that has these rows and nothing else. Two tips of the same amount in the same quarter, one on commissioned
work and one on a download, are the same row once the reference is gone. It is null on every ordinary sale,
because an archetype that answers for itself needs no reference and must not be given one.
recipient_tax_status sits among them because it outranks them: a validated business in another country
moves the place of supply whatever the product said, so reading the product first gives a consumer answer
for a business buyer — a real rate, charged to a real country, remitted to an authority that was never owed
it, and reported into a scheme that exists only for consumers.
supply_regime and seller_posture are one decision seen twice — the regime is how the books read, the
posture is who the receipt names — so they arrive together and are frozen together. Re-classifying a settled
transaction does not adjust a number: it makes every document already issued about it describe a transaction
that did not happen, and the only correct correction is to cancel both chains and re-issue. Both are null on
a row that never had a regime, which is a real answer: defaulting them would assert a classification about
transactions nobody classified.
What makes them worth freezing rather than deriving is that the damage has no symptom. The old document still looks correct; only its relationship to the product has stopped holding, so a refund months later reverses an amount that was never declared — as a clean, balanced reversal nobody flags. Every column is additive and nullable, so an invoice written before them is unchanged, and this is right for a single seller too: a rate that changes next year must not rewrite last year's invoice.
The owner columns are nullable because a retained invoice outlives the owner. total_minor and
subtotal_minor are signed: a credit note is a negative document, not a positive one with a flag.
buyer and lines are snapshots taken when the invoice is finalized — the document does not change when a
customer later edits their address. buyer_reference carries the routing id a public buyer requires
(EN 16931 BT-10) and vat_note the exemption reason (BT-120).
billing_addon_purchases
owner_type? + owner_id? · owner_erased_at? · reference (unique) · addon_key · amount_minor ·
currency · payment_reference? (indexed) · reversed_minor (default 0) · clawed_back_units (default 0) ·
revoked_at? ·
revoked_reason? ·
declaration_reference? · money_credit? · granted_meter_key? · granted_units?
Unique on reference — the idempotency key of a one-off add-on purchase.
money_credit records whether the purchase put money on the buyer's credit balance, and granted_meter_key and
granted_units the usage units it granted, so the prepaid volume and the refund read what the purchase did, not
what the catalog says about its key today. Null on a purchase recorded before the columns existed; the units are
also null on a purchase that granted none.
reference is the idempotency key: a redelivered purchase event lands on the same row instead of granting a
second time.
billing_credit_balances
owner_type + owner_id · currency · balance_minor (default 0)
Unique on (owner_type, owner_id, currency) — one balance per currency, never a mixed-currency total.
billing_credit_ledger_entries
owner_type? + owner_id? · owner_erased_at? · erased_owner_key? · amount_minor (signed) · currency ·
reason · source_type + source_id (nullable) · created_at
The movements the balance above is the running total of. Amounts are signed, so the balance is the plain sum of the entries for a currency; a debit is a negative entry rather than a magnitude plus a direction flag, which would make the same sum a two-step calculation.
Every entry is written in the same transaction as the balance it moves, so the two cannot disagree, and
each carries a reason — the balance alone says what somebody has, never why. Append-only: no update, no
model-level delete. Entries are owner-scoped, and they outlive the owner where the balance does not: a paid
top-up books money in transit against the credit liability and a spend books the liability against the customer
account, so the booking batch of a period reads its movements from here, whoever has been erased since. An erasure
unlinks them like an invoice and stamps owner_erased_at, and erased_owner_key keeps one erased owner's entries
together under a random key that names nobody, because a spend is booked by the share of that owner's balance it
consumed. billing:prune removes them with the other erased financial records.
source_id is an integer on every installation, whatever billing.schema.host_key_type and the framework's
morph key type say: every cause is a row of this package, an order, an add-on purchase or a refund attempt.
An installation that called Schema::morphUsingUuids() or morphUsingUlids() before 0.43.0 got a character
column there, which PostgreSQL refused every cause for; a migration of 0.43.0 retypes it.
reason takes the values something in the package actually writes: proration_credit when unused time on
a swapped-away plan is credited back, addon_topup for an add-on that grants spendable balance, and
addon_reversal when such an add-on is refunded or clawed back, and addon_reversal_undone when a chargeback the
bank took back gives that reversal back.
billing_payment_mandates
owner_type + owner_id · provider · mandate_reference · method · status (default valid) ·
is_default (default false) · customer_reference (nullable) · card_brand (nullable) ·
card_last4 (nullable) · card_exp_month (nullable) · card_exp_year (nullable)
Unique on (provider, mandate_reference) — per provider, not globally, because two providers' id spaces
are unrelated and the same string under both is a coincidence rather than a duplicate.
The first mandate an owner stores with a provider becomes the default, and a later one does not.
billing_payment_mandate_anchors holds one row per owner and provider, unique on (owner_type, owner_id,
provider), which the storing creates and locks before it asks whether a default exists, so that two mandates
stored at once take turns and only the first becomes it.
The four card_* columns describe the card behind a card mandate, as the provider names it when the mandate is
granted: its brand, the last four digits and its expiry, which the expiring-card warning reads. They stay null for
a direct debit and for a mandate stored before they existed.
Where a driver whose billing engine is local keeps the mandates it may charge off-session. A provider-driven driver reads its stored methods back over the API; a local engine cannot do that on the hot path, because the scheduled run collects due cycles unattended and a call that hangs there stalls every subscription behind it.
Column names are provider-neutral so a second driver shares the table rather than growing its own. Only a
mandate whose status is valid is ever charged, and a revoked one is never returned as a fallback: the
honest answer for an owner whose mandates were all withdrawn is that they currently have no way to pay.
There is deliberately no partial unique index on is_default, though one would express "a single default
per owner and provider" exactly. MySQL has none, and a constraint that holds on one of the two supported
engines is worse than none — it makes the invariant true where it is tested and merely likely where it is
not. The rule is enforced in the model and pinned by tests that run on both servers.
billing_prepaid_units
owner_type + owner_id · meter_key · balance (default 0) · granted_total (default 0) · debt (default 0)
Unique on (owner_type, owner_id, meter_key). Prepaid units never expire; the tier's per-cycle allowance
does, and usage spends the allowance first, then the grants of billing_usage_grants, then these units. debt is
the overdraft a blocking meter's grace let the owner run into; the next grant pays it off before any of its units
can be spent.
billing_usage_counters
owner_type + owner_id · meter_key · period · used (default 0) · reserved (default 0) ·
prepaid_used (default 0) · warned_at?
Unique on (owner_type, owner_id, meter_key, period).
billing_usage_events
owner_type + owner_id · meter_key · provider_meter? · quantity · prepaid_units (default 0) ·
occurred_at · period · identifier (unique) · source_key? · source_scope? (unique) · state (default pending) ·
reported_at? · attempts (default 0) · next_attempt_at? · last_error? · rolled_up_into? ·
is_rollup (default false)
source_scope is what a source key is unique by: the hash of owner, meter and the key the caller gave, which
source_key keeps as given. The same key can therefore stand for two meters, or for two owners.
Unique on identifier, and on source_scope where a source key was recorded. Both are dedup keys, and the
second is nullable on purpose: null is distinct from null in a unique index, so an event that names no source
never collides with another that names none either.
Indexed on (state, next_attempt_at) — the flush's own query — and on
(owner_type, owner_id, meter_key, period).
This is an outbox, not a log: a row stays until the provider has accepted it, and attempts plus
next_attempt_at carry the backoff. identifier makes recording idempotent, source_key deduplicates
against the caller's own key.
billing_spend_caps
owner_type + owner_id · amount_minor? · currency? · reached_period?
Unique on (owner_type, owner_id). Every metered request of the owner locks the row while it is measured against
the cap, so a row stands for every owner who metered with spend caps on, and amount_minor and currency are null
for one on the configured default.
billing_usage_reservations
token (ULID, unique) · owner_type + owner_id · meter_key · period · amount · included? · grace? ·
state (default pending) · expires_at
Unique on token — the handle a caller holds while a reservation is outstanding.
Indexed on (state, expires_at). Every hold expires, so a worker killed between claiming an allowance and
recording the usage cannot hold it forever.
billing_cancellation_surveys
owner_type + owner_id · reason (indexed) · detail?
Only written when an owner gives a reason. The survey never blocks or delays the cancellation.
billing_coupons and billing_coupon_redemptions
billing_coupons: code · merchant_uid (default platform) · type · value · currency? ·
duration · duration_in_cycles? · max_redemptions? · redeemed_count (default 0) · expires_at? ·
provider_coupon_id? · active (default true) · trial_days?, unique on (merchant_uid, code).
trial_days holds the days of trial the coupon gives a subscription, as a JSON object from a plan key to a number of
days, with * for every plan the object does not name. Null gives no trial. See
Trial codes.
type is percent, with a value from 1 to 100, or fixed, with a value in minor units. A row of any other
type, or a percentage outside those bounds, is no discount: the hosted checkout resolves it to nothing, minting
its provider coupon is refused, and a subscription that redeemed one is billed at the full price.
Give a fixed row its currency. The hosted checkout needs it to build the amount and resolves a fixed row
without one to nothing, while the local billing engine reads such a row in the currency of the cycle it
discounts. With a currency, the local engine applies the amount only to a cycle in that currency, and on any
other cycle it discounts nothing rather than converting.
merchant_uid is the issuer, and the uniqueness sits on the pair rather than on the code alone so two
sellers can each run a SUMMER25. It is the same not-null sentinel the subscription table keys on —
platform, or m:<type>#<id> for a merchant — and it is a string rather than a nullable merchant column
for an engine reason: on MySQL two NULLs do not collide, so a nullable column inside this index would make
the platform's own one-code-per-name rule vanish the day the first merchant coupon appeared.
Read a coupon with Coupon::issuedBy($merchant); a bare lookup on code finds any issuer's row of that
name. CouponRedeemer refuses a coupon whose issuer is not the seller of the sale it is being spent on, and
a caller that passes no scope gets the platform — so forgetting it makes a merchant's coupon refuse, never
apply.
A code matches whatever its case and the whitespace around it, as Stripe matches a promotion code, so save10
finds SAVE10 on every database. The comparison is made in PHP rather than left to the column's collation, under
which MySQL's default collations would also let an accented code find one without the accent. Where two codes of
one issuer differ only in case, which PostgreSQL and a binary collation allow, the exact spelling wins, and without
one neither does. The keys of billing.coupons are matched by the same rule.
A live row is a catalog entry of its own. The bound DiscountResolver reads the row issued by the seller of
the sale before billing.coupons, so a code a merchant issued resolves on that merchant's sales with no config
entry at all, and on nobody else's.
billing_coupon_redemptions: owner_type + owner_id · coupon_id (foreign key, cascades on delete) ·
subscription_id? (indexed) · redeemed_at, unique on (coupon_id, owner_type, owner_id) so a code
cannot be redeemed twice by the same owner.
billing_webhook_events
provider · event_id · type · owner_type? + owner_id? · payload? (JSON) · payload_removed_at? ·
status (default pending) · last_error? · handled_at? ·
account_reference
Unique on (provider, account_reference, event_id) — the idempotency key that makes a redelivery a
no-op. The account belongs in it because a provider's event ids are unique within the account that
produced them, not across a platform and all its merchants: on (provider, event_id) alone, two genuine
events from two accounts that share an id would leave the second swallowed as a redelivery, with nothing
retrying because the package believes it handled it. account_reference defaults to the empty string and
not to null for the same reason in reverse — every null is distinct in a unique index, so a nullable
column would stop deduplicating platform deliveries altogether — and indexed on
(status, created_at).
payload_removed_at says the payload was removed on purpose: scrubbed by an erasure or pruned by
billing:prune. A redelivery fills in a payload that was never kept, so a row recorded before payloads were
kept becomes replayable, and it never fills in one that carries this stamp.
The payload is stored without any client_secret, the field Stripe puts on a PaymentIntent, a SetupIntent
and an embedded Checkout Session and asks not to be stored. No effect reads it, so a replay runs the same
effects as the delivery did, and the data export leaves it out of a payload stored before the ledger did.
billing_webhook_effect_runs
provider · reference · effect · delivery_id? · status (default pending) · attempts (default
0) · last_error? · handled_at?
Unique on (provider, reference, effect), indexed on status and on delivery_id. Idempotency is per
effect, not per delivery, so replaying a delivery whose third effect failed re-runs only that one.
reference is the provider's event id, or what the effect acts on where it dedupes on that instead: the
invoice of a dunning notice, the dispute of a chargeback, and the refunded state of a payment as
<payment>:refunded:<cumulative minor units>:<currency>:<reason>. The last is what lets a second refund land
when a provider reports it under the key of the first, as Mollie does.
last_error names a failure without the data it carried, because the table holds nobody and no erasure
reaches it. A database exception is stored as its class, its SQLSTATE and the statement with its
placeholders, an exception the package raised with its message, and anything else as its class and code.
The full message goes with the exception to your exception handler, which the job rethrows it to.
billing_events
type (indexed) · source (default system, indexed) · subject_type? + subject_id? ·
actor_type? + actor_id? · payload (JSON)
Append-only: the model refuses an update and a delete. The package removes rows in two places, the
retention pass of billing:prune and the erasure of the owner a row names, and both delete by query.
source separates a system action from one a human took, and actor records who.
billing_number_sequences
scope (unique) · next_number (default 1)
Unique on scope — one running counter per scope, which is what makes a document series gapless.
The invoice-number counter. Numbering must be gapless, so the next number is claimed from this row inside a transaction rather than derived by counting invoices.
Your own table
billing:install generates one migration against billing.customer.model's table. Every column is guarded
by hasColumn(), so it is safe to run after Cashier's own customer migration:
- the tier column (
billing.tier_column, defaultplan) — the denormalized tier the hot path reads - the customer column (
billing.customer.column, defaultstripe_id), indexed pm_type,pm_last_four,trial_ends_at— Cashier's columns, added only if absent
Both column names come from the config the package reads at runtime, never from a literal: a consumer who renamed one and got a migration for the default name would end up with a column nothing writes and a package reading a column that does not exist.
The rollback drops only the columns that migration created, each guarded, so Cashier's own columns are never dropped by it.