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

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

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_refund_attempts

provider · charge_reference · amount_minor · currency · reverses_transfer · refunds_fee · 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.

billing_creator_tax_statuses

merchant_type? + merchant_id? · merchant_erased_at? · status · effective_from · effective_to? · source · evidence_ref? · attested_until?

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

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.

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_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