user-stories

US-01 — Invoicing

docs/user-stories/01-Invoicing.mdtype: user-storyupdated: 2026-05-03

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 opened time).
  • Fireblocks (or MockFireblocks) is reachable for sub-vault creation.

Happy path

  1. 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
  2. On submit, the server creates a payment_intents row (kind='invoice') with status='draft'.
  3. 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 + receiving address on the invoice.
    • Emits invoice.opened email + webhook.
  4. Merchant copies the public link (/pay/invoice/[id]) and sends it to the customer.
  5. Customer pays to the per-invoice address.
  6. Fireblocks webhook ingestion records each deposit against the sub-vault.
  7. On every confirmed deposit, worker/jobs/deposit.ts re-evaluates paid_minor:
    • If paid_minor >= total_minor: transition opened → paid, sweep funds to merchant's main wallet, emit invoice.paid.
    • Else: stay opened, surface "underpaid by X" on the public page.
  8. Merchant sees paid status on /invoices/[id]; balance lands in /wallet.

Alternate flows

  • Edit while draft: merchant can edit any field before Open invoice. After opened, only memo + due date are editable.
  • Top-up after partial payment: customer pays again to the same address; cumulative paid_minor advances 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 void an opened invoice; 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 on paymentOrderId).
  • 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_events row marked pending, 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 opened invoice has a unique sub-vault address.
  • Public invoice page renders all deposits, regardless of count.
  • paid transition is atomic and idempotent (advisory lock per invoice).
  • invoice.opened and invoice.paid emails land in mock_emails locally; live mode sends them through Resend with EMAIL_PROVIDER=resend.
  • audit_log row written on every state transition.

Out of scope

Drawbacks

Open questions

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 (migrations 0009_invoice_vault.sql, later)
  • Domain doc: ../03-Domains#Invoices