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_prepaid_units | Usage units an owner bought that never expire | 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_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_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_coupon_redemptions | Who redeemed which coupon | Purged |
billing_coupons | The coupon definitions themselves, which belong to nobody | 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) |
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, 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.