user-stories
US-01 — Invoicing
US-01 — Invoicing
Merchant creates a fiat-quoted crypto invoice, shares a link with their customer, and receives funds into a per-invoice sub-vault that settles to their main wallet.
Persona
Merchant — a business account (users.kind = 'company') with a verified KYB record. Already onboarded to a fee tier (silver / gold / platinum).
Goal
"I want to bill a customer in EUR/USD and have them pay me in crypto, without me touching addresses or rates myself."
Preconditions
- Merchant is signed in and not blocked.
- Merchant's KYC/KYB state is
approved(KYB still warns at create-time but does not block in MVP). - Oracle is reachable (rate quote at
openedtime). - Fireblocks (or
MockFireblocks) is reachable for sub-vault creation.
Happy path
- Merchant goes to
/invoices/new, fills:- Customer email (optional)
- Asset + network (e.g.,
USDC/ETH) - Fiat amount + fiat currency (e.g.,
100.00 EUR) - Memo + due date
- On submit, the server creates a
payment_intentsrow (kind='invoice') withstatus='draft'. - Merchant clicks Open invoice → server transitions
draft → opened:- Quotes the oracle rate, freezes
crypto_amount_minor,platform_fee_minor,total_minor. - Calls
Fireblocks.createSubVault({ parentRef, paymentOrderId: invoiceId, asset })— see ../05-External Providers#Mocked Fireblocks behaviour. - Persists
vault_id+ receivingaddresson the invoice. - Emits
invoice.openedemail + webhook.
- Quotes the oracle rate, freezes
- Merchant copies the public link (
/pay/invoice/[id]) and sends it to the customer. - Customer pays to the per-invoice address.
- Fireblocks webhook ingestion records each deposit against the sub-vault.
- On every confirmed deposit,
worker/jobs/deposit.tsre-evaluatespaid_minor:- If
paid_minor >= total_minor: transitionopened → paid, sweep funds to merchant's main wallet, emitinvoice.paid. - Else: stay
opened, surface "underpaid by X" on the public page.
- If
- Merchant sees
paidstatus on/invoices/[id]; balance lands in/wallet.
Alternate flows
- Edit while draft: merchant can edit any field before
Open invoice. Afteropened, only memo + due date are editable. - Top-up after partial payment: customer pays again to the same address; cumulative
paid_minoradvances and re-triggers settlement check. - Customer overpays: invoice settles at
paid, residual stays in the sub-vault and sweeps with the rest. - Manual void: merchant can
voidanopenedinvoice; sub-vault is retained but no further deposits are credited to the invoice.
Edge cases & failure modes
- Sub-vault creation fails → invoice stays
draft, error toast, retry-safe (idempotent onpaymentOrderId). - Rate-frozen invoice edit: math is not re-quoted on edit. Mismatch with oracle is accepted. See ../08-Risks and Drawbacks#Invoices.
- Late deposit after
expired: deposit is credited to the address but does not flip the invoice; ops can manually re-open. - Webhook delivery fails:
webhook_eventsrow markedpending, exponential backoff retry. Pre-GA SLA pending — ../09-Open Questions#Webhook reliability. - Underpaid invoice: public page shows remaining amount + same address; customer top-ups settle the invoice.
Acceptance criteria
- Draft invoices are editable; opened invoices freeze rate and amount.
- Each
openedinvoice has a unique sub-vault address. - Public invoice page renders all deposits, regardless of count.
-
paidtransition is atomic and idempotent (advisory lock per invoice). -
invoice.openedandinvoice.paidemails land inmock_emailslocally; live mode sends them through Resend withEMAIL_PROVIDER=resend. -
audit_logrow written on every state transition.
Out of scope
- Partial refunds (manual SQL today; admin tool deferred to GA — see ../09-Open Questions#Admin gaps)
- Tax line items (memo only)
Drawbacks
- Per-invoice sub-vault costs one Fireblocks vault per
openedinvoice. ../08-Risks and Drawbacks#Invoices - Rate-frozen invoices never re-quote — math may diverge from oracle if edited.
- Webhook reliability SLA not yet committed — ../08-Risks and Drawbacks#Domains.
Open questions
- ../09-Open Questions#Webhook reliability
- ../09-Open Questions#Refunds
- ../09-Open Questions#Custody (vault cost model at scale)
Implementing surfaces
- UI:
app/my/invoices/page.tsx,app/my/invoices/new/page.tsx,app/my/invoices/[id]/page.tsx,app/my/invoices/[id]/edit/page.tsx - Public:
app/(public)/pay/invoice/[id]/page.tsx - Jobs:
worker/jobs/provider-webhooks.ts,worker/jobs/deposit.ts - Mail:
lib/mail/notifications/invoice.tsx - Schema:
payment_intents(kind='invoice'),invoice_line_items,invoice_rate_history,observed_deposits,webhook_events(migrations0009_invoice_vault.sql, later) - Domain doc: ../03-Domains#Invoices