#509·sphere

No billing history: a paying user cannot see what they bought, when, or why their limits dropped

Author: KruGoLCreated Sep 7, 2026Updated Sep 8, 2026
Labelsenhancement

The wallet can buy a subscription but never shows what was bought. Three questions a paying user will ask have no answer anywhere in the UI:

  • "I paid — where is my order?" If the tab was closed and the order id is gone, the only remaining trace is Paymento's email. order-status is keyed by orderId alone and there is no list endpoint.
  • "What did I buy, and when?" Nothing records it. Settings → Subscription shows the CURRENT plan, its expiry and two usage bars — no history.
  • "Why did my limits drop?" The gateway lazily demotes a lapsed paid subscription to free with no error — silently lower limits. PlanDowngradeWatcher catches it and opens the upgrade modal with the 'expired' banner, but nothing says which plan expired or when.

The data already exists

paymento_orders carries order_id, token, plan_id, email, price_cents, status, status_name, api_key / upgrade_api_key, key_revealed, settlement_json, fulfilled, awaiting_confirmation, created_at, updated_at.

GET /api/paymento/order-status?orderId=… already returns orderId, status (created | pending | paid | failed), statusName (raw Paymento, incl. UserCanceled), fulfilled, confirming, settlement (on-chain details from /verify), upgrade, maskedKey and planName. GET /api/paymento/key-info (by x-api-key) returns maskedKey, planName, subscriptionState (active | expired | inactive) and activeUntil.

So date, plan, amount, status, settlement and expiry are all available today. What is missing is a way to ask for them per wallet, and a surface to show them.

The purchase IS linkable to the wallet on the normal path

PlanScreen.startCheckout always asks for an in-place upgrade of the key the wallet already holds ("same key, new plan, fresh 30 days"); a fresh key is minted only against a pre-upgrade gateway or when the user explicitly picks forceNewKey. That existing key is the identity-bound one provisioned at onboarding through /auth/verify, which carries api_keys.owner_id = "<network>:<pubkey>".

So for a normal purchase the chain already closes server-side:

paymento_orders.upgrade_api_key → api_keys.owner_id → network:pubkey

No schema change is needed to answer "the orders belonging to this wallet" for the common case. Only a fresh-key purchase stays unlinked (api_keys rows created outside getOrCreateOwnerKey leave owner_id NULL).

Options

A. Local ledger (wallet only). Stop deleting the PendingOrderRecord (src/sdk/subscription/pendingOrder.ts, added by #504) at terminal status; fold it into a compact completed entry — order id, plan name, price, created, settled, status, key mask, never the key — and render Billing from it. No gateway change, works for fresh-key purchases too. Cost: the history lives in one browser and cannot be recovered after a wipe or on another device.

B. Server-side history endpoint (gateway). A read endpoint listing orders whose upgrade_api_key belongs to the caller's owner key. Covers the normal path with no schema change; survives reinstalls and works across devices.

Authentication must be the pubkey challenge (/auth/challenge + /auth/verify), NOT x-api-key. Keys are deliberately portable — the wallet offers "Use a different key" and the timeout path offers "I have a key" — so a key-authenticated endpoint would hand anyone holding a pasted key the buyer's order history and their email. Signature auth exposes nothing new: only the holder of the private key can read, and they already know both halves.

C. Bind fresh-key purchases too. The wallet already calls order-key-ack?orderId=… after storing a purchased key durably. Signing that call would let the gateway set owner_id on the purchased key. Note two costs: it ends the key's portability, and api_keys has a UNIQUE partial index on owner_id, so a wallet cannot own two keys as the schema stands.

Recommended: A + B — the ledger for the surface and for offline/fresh-key coverage, the endpoint so the history survives a reinstall. C only if key portability is being dropped anyway.

Two related facts the page should state honestly

  • There is no cancellation and no refund. The Paymento store has no cancel endpoint; payment_sessions / refund_amount belong to the legacy /api/payment/* flow, not to this one. "Cancel this payment and start over" sets a local abandonedAt only — the order lives on and can still settle (which is #508). Nothing to cancel in the subscription sense either: it is not recurring, it simply expires after 30 days.
  • A downgrade costs the remaining days. isPlanSelectable blocks only re-buying the CURRENT active plan, so a cheaper plan is purchasable; on fulfilment changePlan(key, planId, expiry) sets the new plan with a fresh 30 days and no proration. Buying down silently discards whatever was left. Either warn in the UI or refuse the move while a paid plan is active.

Bonus: this gives #508's orphaned key a home

#508 describes a key delivered by a cancelled-but-settled order that has nowhere to land: the toast points at Settings → Subscription, which cannot reach it, so a paid key is announced and then lost. A Billing list is exactly that missing home — the order appears as a row with "key delivered" and an "install this key" action.

Out of scope, worth filing separately

The browser learns about payment only by polling: the gateway has an IPN callback from Paymento and its own 5-minute re-verification for 24h, but no SSE or WebSocket anywhere, so the page updates on the next poll tick rather than when the payment confirms.

Related: #501, #504, #508.


Where the gateway-side half is tracked

Everything this page needs from the SGW — the per-wallet order listing, a push so the screen reacts when a payment confirms, cancellation, proration and an expiry signal — is collected in unicitynetwork/aggregator-subscription#83. Options B and C above are items 1 and 6 there; option A (the local ledger) needs nothing from the gateway and can ship on its own.

The downgrade warning this page would have to explain is filed separately as #510.