Skip to main content

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.

TableWhat it holdsOn erasure
billing_subscriptionsOne row per owner and subscription type: the local mirror of the provider's subscriptionPurged
billing_subscription_itemsThe priced lines of a subscription, for a driver that bills the cycle locallyCascaded
billing_ordersThe operational billing unit a due cycle is assembled intoPurged
billing_order_itemsThe lines of an orderCascaded
billing_invoicesThe frozen financial document, with its buyer snapshot and linesRetained
billing_addon_purchasesA one-time purchase and any reversal of itRetained
billing_credit_balancesAn owner's money credit, per currencyPurged
billing_credit_ledger_entriesEvery movement behind that credit, with the reason it happenedPurged
billing_payment_mandatesStored mandates a local-engine driver may charge off-sessionPurged
billing_prepaid_unitsUsage units an owner bought that never expirePurged
billing_usage_countersThe per-period usage total per meterPurged
billing_usage_eventsThe metering outbox: each recorded usage and its reporting statePurged
billing_usage_reservationsShort-lived holds on a metered allowancePurged
billing_cancellation_surveysWhy an owner canceled, when they chose to sayPurged
billing_merchant_accountsA merchant's account at the provider and the capabilities it has confirmedPurged (merchant)
billing_merchant_customersWhich buyer a customer reference means inside one merchant accountPurged
billing_creator_tax_statusesA merchant's tax standing over time, as dated intervalsRetained (merchant)
billing_merchant_chargesA payment routed to a merchant, and what has since been reversed off itRetained (merchant)
billing_refund_attemptsOne intent to reverse money, recorded before the provider is calledNot owner-scoped
billing_access_grantsWhat somebody bought and may still open — ownership of a work, not a plan's permissionsPurged
billing_coupon_redemptionsWho redeemed which couponPurged
billing_couponsThe coupon definitions themselves, which belong to nobodyNot owner-scoped
billing_webhook_eventsOne row per delivery: the dedup key, the raw payload and the delivery stateScrubbed
billing_webhook_effect_runsOne row per effect per delivery, so a replay cannot double-applyNot owner-scoped
billing_eventsThe append-only audit ledgerNot owner-scoped
billing_number_sequencesThe gapless invoice-number counter, per scopeNot owner-scoped
billing_self_billing_agreementsA creator's standing agreement that the platform may self-bill them, versioned and revocableRetained (merchant)
billing_submitted_invoicesA creator's own invoice, submitted through the fallback lane and reconciled before payoutRetained
billing_subscription_intentsWhat 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 forPurged
billing_provider_feesWhat the payment provider charged the platform — a dispute fee, kept once per dispute so it can be posted and reconciledRetained (merchant)
billing_withdrawal_consentsThe 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 saleRetained
billing_tax_hold_warningsThat 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 movesPurged (merchant)
billing_filing_remindersThat 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 secondNot owner-scoped
billing_reporting_exportsWhat 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 answerableNot owner-scoped
billing_reporting_acknowledgementsOne 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 ruleNot owner-scoped
billing_reporting_filingsWhich 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 correctsNot owner-scoped
billing_merchant_balancesWhat a merchant owes the platform per currency when a clawback could not take it back — signed, offset against later settlementsRetained (merchant)
billing_vouchersA voucher somebody paid for, with what is left on it — redeemable on the platform only, never topped up, cashed out or handed onRetained
billing_voucher_movementsEach 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 itNot owner-scoped
billing_voucher_volume_noticesThat 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 answerNot owner-scoped
billing_market_access_logWhen each market was opened or closed and by whom — append-only, because the sales made while it was open need the record that explains themNot owner-scoped
billing_place_evidenceWhich country a sale was taxed in and what said so — country codes only, no address, card or connection dataRetained
billing_document_artifactsThe electronic document exactly as it was issued — bytes plus a fingerprint, never a later re-renderRetained
billing_tax_return_exportsEach produced tax-return file: when, what it contained, and a fingerprint of the exact bytes — a second run is a second row, never an overwriteNot owner-scoped
billing_us_tax_formsWhat a seller declared about where they are taxed, and when it expires — the declaration and a reference to the signed document, never the identifying numberRetained (merchant)
billing_buyer_protection_holdsA sale whose payout waits for the buyer to confirm, with the two deadlines it runs on — the money stays with the payment provider throughoutRetained (merchant)
billing_merchant_creditor_accountsThe ledger account a merchant's payables book against, where the installation keeps an account per merchant rather than one collective accountRetained (merchant)
billing_document_deliveriesWhen a settlement document was made available, when its recipient was told, and when they fetched it — append-onlyRetained (merchant)
billing_invoice_exchange_ratesThe rates a document was actually converted at, frozen onto it — one per conversion layer, append-onlyNot owner-scoped
billing_exchange_ratesPublished exchange rates you imported, one per pair per day per rule — reference data, with nobody's personal data in itNot owner-scoped

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.

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.

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?

Unique on (owner_type, owner_id, type), indexed on (owner_type, owner_id, status).

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. synced_event_at is the ordering guard — an out-of-order webhook cannot move the row backwards.

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?

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.

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?

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 · transfer_reference? · gross_minor · fee_minor · fee_bps? · fee_flat_minor? · commission_tax_bps? · net_minor · currency · settlement_state · settled_at? · refunded_minor · transfer_reversed_minor · fee_refunded_minor

Unique on (provider, charge_reference), indexed on (merchant_type, merchant_id, settlement_state).

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 lanes are an exception, and it is structural rather than an omission. Stripe's hosted subscription expresses the fee as a percentage of the invoice total, which includes the buyer's tax; 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. Both therefore take the commission on the gross — 11.90 on that same sale — while the routed payment path, the one that writes these rows, takes 10.00 as described. Which answer the package should settle on is open, and until it is settled the difference is stated rather than left for a reconciliation to find.

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? · 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?

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?

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.

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_currencyto_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?

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_invoices

owner_type? + owner_id? · owner_erased_at? · provider? · provider_id? · number? · pdf_path? · total_minor · subtotal_minor? · tax_minor? · currency · status (default draft) · issued_at? · credited_invoice_id? · credited_invoice_number? · buyer? (JSON) · lines? (JSON) · reverse_charge (default false) · buyer_reference? · vat_note? · oss (default false) · destination_country? · oss_rate? · tax_archetype? · sold_alongside_archetype? · place_of_supply_rule? · tax_rate_category? · tax_rate_bps? · platform_reporting? · rate_matrix_version? · recipient_tax_status? · supply_regime? · seller_posture?

Unique on number and on (provider, provider_id).

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. They used to be read back from the product on demand, and 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) · revoked_at? · revoked_reason?

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 · 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 are purged with the owner, alongside the balance they explain.

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.

billing_payment_mandates

owner_type + owner_id · provider · mandate_reference · method · status (default valid) · is_default (default false) · customer_reference (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.

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)

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.

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? (unique) · state (default pending) · reported_at? · attempts (default 0) · next_attempt_at? · last_error? · rolled_up_into? · is_rollup (default false)

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_usage_reservations

token (ULID, unique) · owner_type + owner_id · meter_key · period · amount · included? · state (default pending) · expires_at

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 (unique) · type · value · currency? · duration · duration_in_cycles? · max_redemptions? · redeemed_count (default 0) · expires_at? · provider_coupon_id? · active (default true)

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) · status (default pending) · last_error? · handled_at?

Unique on (provider, event_id) — the idempotency key that makes a redelivery a no-op — and indexed on (status, created_at).

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.

billing_events

type (indexed) · source (default system, indexed) · subject_type? + subject_id? · actor_type? + actor_id? · payload (JSON)

Append-only: rows are never updated, and the only sanctioned deletion is the retention purge in billing:prune. source separates a system action from one a human took, and actor records who.

billing_number_sequences

scope (unique) · next_number (default 1)

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, default plan) — the denormalized tier the hot path reads
  • the customer column (billing.customer.column, default stripe_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.


← Back to the documentation index