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 | Purged |
billing_payment_mandates | Stored mandates a local-engine driver may charge off-session | 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_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, 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) |
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_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_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_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_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 | 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 |
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_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?
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, 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.