Commit Graph

3 Commits

Author SHA1 Message Date
LinYushen
372a330707 fix(billing): wire test page through useT instead of silencing i18n rule (#3451)
The previous fix (#3446) for the i18next/no-literal-string CI failure
took the lazy route — added a file-level eslint-disable comment with
the rationale that the test page is slated for deletion when the real
billing UI ships, so investing in a translation namespace was wasted
churn.

That argument is weak in practice:

  * The test page has been around since #3442 and there is no
    concrete date for when the real billing UI lands. "Throwaway"
    surfaces tend to outlive their stated half-life.
  * File-level disables hide future violations of the same rule from
    review — anyone touching this file later inherits an opt-out
    they didn't ask for.
  * The translation namespace is genuinely cheap. The page has ~50
    distinct strings, all of which now have a single en.json/zh.json
    home that the locale-parity test guards.

This commit replaces the silencer with a real `billing` namespace:

  * packages/views/locales/en/billing.json + zh-Hans/billing.json —
    every literal English label from the page, plus interpolated
    sentences with i18next `{{var}}` placeholders. The zh-Hans
    bundle is a basic translation; not perfect prose, but the parity
    test passes and a Chinese reviewer testing the page won't see
    raw English mixed with native UI chrome.
  * packages/views/i18n/resources-types.ts — adds the namespace to
    `I18nResources` so `t($ => $.billing.x.y)` is type-checked.
  * packages/views/locales/index.ts — registers both bundles in
    `RESOURCES` so the parity test sees them.
  * packages/views/billing/billing-test-page.tsx — every JSX literal
    rewritten as `t(($) => $.path)`, including aria-label,
    interpolated sentences (balance meta line, transactions row meta,
    paging footer), and conditional branches (with-bonus vs
    without-bonus rendering goes through two distinct keys rather
    than two literals). The file-level eslint-disable is removed
    along with its multi-paragraph rationale comment.
  * formatDate now takes `t` as an argument so the en/zh "—" dash
    placeholder is itself translated; calling useT inside the util
    would have violated the rule of hooks (the function is called
    from conditional render branches).

Verification:
  * pnpm --filter @multica/views lint   — 0 errors (15 unrelated
    warnings, all pre-existing on main)
  * pnpm --filter @multica/views typecheck — clean
  * pnpm --filter @multica/views test    — 883/883 passing,
    including the locale-parity test (which fails the build if
    en and zh-Hans diverge)

When the real billing UI ships and this file is deleted, the
`billing` namespace JSONs and the `resources-types.ts` /
`locales/index.ts` entries get deleted with it — same blast radius
as the eslint-disable would have had, but with proper i18n along
the way.
2026-05-28 17:38:46 +08:00
Bohan Jiang
9b280bc631 chore(billing): silence i18next/no-literal-string in dev-only test page (#3446)
billing-test-page.tsx (introduced in #3442) ships dozens of raw English
JSX labels and is explicitly documented as "not a finished UI — slated
for deletion when the real billing UI ships". Routing every label
through useT() and inventing throwaway translation keys would be
wasted churn that gets deleted in the same week the real UI lands.

Add a file-level eslint-disable so CI passes on every PR that touches
unrelated files. When the real billing UI replaces this page, this
file (and the disable) gets deleted together.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 17:14:52 +08:00
LinYushen
7bbca54e92 feat(billing): test page consuming /api/cloud-billing/* (#3442)
* feat(billing): test page consuming /api/cloud-billing/*

Stuffs every cloud-billing endpoint onto a single dev page so we can
verify the proxy + Stripe end-to-end flow without a designed UI.
Reachable at /<workspaceSlug>/billing — the page is account-level
data but lives under the workspace dashboard layout because that's
where the authenticated shell sits. No sidebar entry on purpose;
this is test-quality and meant to be deleted when the real billing
UI ships.

What's there:

  * Balance card (GET /balance)
  * Stripe-success polling banner — visible only when ?session_id=
    is in the URL (Stripe substitutes it into checkout_success_url
    on its way back). React Query refetchInterval polls every 2s
    until the topup status reaches credited / failed / canceled,
    then a 'Clear from URL' button calls navigation.replace(pathname).
  * Buy section: server-authoritative price tier buttons (GET
    /price-tiers) → POST /checkout-sessions → window.location to the
    Stripe URL. We do NOT hard-code amounts on the frontend; tier
    config lives in cloud's billing.price_tiers.
  * Stripe Billing Portal button (POST /portal-sessions). Opens in a
    new tab so the originating page stays put for easy verification.
    Documented behaviour: 400 is expected for users with no Stripe
    customer record yet.
  * Three lists: transactions / batches / topups.

Plumbing:

  * packages/core/types/billing.ts — interfaces mirroring the cloud
    response shapes. Status / source / tx_type fields are typed
    'string' rather than enum unions to match the schemas' z.string()
    parsing (same convention as CloudRuntimeNode); the canonical
    enum values are exported as separate type aliases for callers
    that want to switch on them.
  * packages/core/api/schemas.ts — 9 zod schemas + 7 EMPTY_ fallbacks,
    all .loose() so a non-breaking cloud-side field addition doesn't
    crash the parser.
  * packages/core/api/client.ts — 8 methods using parseWithFallback,
    matching the existing cloud-runtime shape.
  * packages/core/billing/{queries,mutations,index}.ts — React Query
    queryOptions + mutations. Notable choices: balance / lists are
    NOT keyed on workspace (account-level data), and the
    checkout-session polling stops automatically when status is
    terminal so we don't poll forever after a user closes the tab.
  * packages/core/package.json + packages/views/package.json — exports
    map updated for @multica/core/billing and @multica/views/billing.

Verification:

  * pnpm --filter @multica/core typecheck clean
  * pnpm --filter @multica/views typecheck — only pre-existing
    hast-util-to-html error in editor code (exists on main)
  * pnpm --filter @multica/core test — 412 passing
  * pnpm --filter @multica/views test — 877 passing, 1 failure
    (editor/readonly-content) is also pre-existing on main, not
    caused by this change

Out of scope: real production-quality billing UI; sidebar entry; i18n
strings; mobile app. This is a single test page; it gets replaced
when the real UI ships.

* fix(billing): refetch balance/lists when checkout polling reaches terminal

Closes the second-half of the Stripe-return race the previous commit
left dangling.

Symptom:
  After Stripe redirects back with ?session_id=..., the banner polls
  /checkout-sessions/{id} every 2s and the rest of the page (balance,
  transactions, batches, topups) is fetched once on mount. The
  webhook race means those four queries usually see pre-credit state
  — but the banner is the only thing that keeps polling, so once it
  reads 'credited' nothing else on the page knows. The user would
  see 'Final status: credited' next to a stale balance card until
  they manually refresh.

Fix:
  Add useInvalidateBillingDataAfterCredit() in @multica/core/billing —
  a hook returning a callback that flushes balance / transactions /
  batches / topups (NOT the checkout-session itself; its
  refetchInterval already terminated, refetching would just confirm
  the same value). The Stripe-success banner runs this callback in
  a useEffect keyed on terminal-status transition, so it fires
  exactly once when the polling lands.

  Strict scope is documented in the hook's JSDoc:
    - balance/transactions/batches: only change at the 'credited'
      transition (cloud writes ledger + batch + wallet in one DB tx)
    - topups: changes on every terminal transition
    - For 'failed' / 'canceled' we technically over-fetch the first
      three; three cheap round-trips, simplifies the call site, fine
      on a test page.

  Effect dep is . terminal flips false→true at most once
  per session id (the polling stops when terminal is true so the
  data won't change again). If the user lands here with a session
  that is already terminal (re-opened tab on a credited URL), the
  effect still fires on first data load and we still re-fetch —
  correct, the cached snapshot is just as stale in that case.

go build / pnpm typecheck / pnpm test clean (core 412 passing; only
pre-existing hast-util-to-html error in unrelated editor code on
views, same as on main).
2026-05-28 16:48:04 +08:00