Files
multica/packages/core/package.json
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

128 lines
5.0 KiB
JSON

{
"name": "@multica/core",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run"
},
"exports": {
".": "./index.ts",
"./markdown": "./markdown/index.ts",
"./types": "./types/index.ts",
"./types/*": "./types/*.ts",
"./api": "./api/index.ts",
"./api/client": "./api/client.ts",
"./api/schema": "./api/schema.ts",
"./api/schemas": "./api/schemas.ts",
"./api/ws-client": "./api/ws-client.ts",
"./config": "./config/index.ts",
"./auth": "./auth/index.ts",
"./workspace": "./workspace/index.ts",
"./workspace/queries": "./workspace/queries.ts",
"./workspace/mutations": "./workspace/mutations.ts",
"./workspace/hooks": "./workspace/hooks.ts",
"./workspace/avatar-url": "./workspace/avatar-url.ts",
"./issues": "./issues/index.ts",
"./issues/queries": "./issues/queries.ts",
"./issues/mutations": "./issues/mutations.ts",
"./issues/timeline-sort": "./issues/timeline-sort.ts",
"./issues/ws-updaters": "./issues/ws-updaters.ts",
"./issues/config": "./issues/config/index.ts",
"./issues/config/status": "./issues/config/status.ts",
"./issues/config/priority": "./issues/config/priority.ts",
"./issues/stores": "./issues/stores/index.ts",
"./issues/stores/view-store-context": "./issues/stores/view-store-context.tsx",
"./issues/stores/*": "./issues/stores/*.ts",
"./inbox": "./inbox/index.ts",
"./inbox/queries": "./inbox/queries.ts",
"./inbox/mutations": "./inbox/mutations.ts",
"./inbox/ws-updaters": "./inbox/ws-updaters.ts",
"./notification-preferences": "./notification-preferences/index.ts",
"./notification-preferences/queries": "./notification-preferences/queries.ts",
"./notification-preferences/mutations": "./notification-preferences/mutations.ts",
"./chat": "./chat/index.ts",
"./chat/queries": "./chat/queries.ts",
"./chat/mutations": "./chat/mutations.ts",
"./runtimes": "./runtimes/index.ts",
"./runtimes/queries": "./runtimes/queries.ts",
"./runtimes/mutations": "./runtimes/mutations.ts",
"./runtimes/hooks": "./runtimes/hooks.ts",
"./runtimes/custom-pricing-store": "./runtimes/custom-pricing-store.ts",
"./dashboard": "./dashboard/index.ts",
"./dashboard/queries": "./dashboard/queries.ts",
"./agents": "./agents/index.ts",
"./agents/queries": "./agents/queries.ts",
"./agents/derive-presence": "./agents/derive-presence.ts",
"./agents/use-agent-presence": "./agents/use-agent-presence.ts",
"./agents/visibility-label": "./agents/visibility-label.ts",
"./agents/stores": "./agents/stores/index.ts",
"./squads": "./squads/index.ts",
"./squads/stores": "./squads/stores/index.ts",
"./permissions": "./permissions/index.ts",
"./projects": "./projects/index.ts",
"./projects/queries": "./projects/queries.ts",
"./projects/mutations": "./projects/mutations.ts",
"./projects/config": "./projects/config.ts",
"./labels": "./labels/index.ts",
"./labels/queries": "./labels/queries.ts",
"./labels/mutations": "./labels/mutations.ts",
"./autopilots": "./autopilots/index.ts",
"./autopilots/queries": "./autopilots/queries.ts",
"./autopilots/mutations": "./autopilots/mutations.ts",
"./pins": "./pins/index.ts",
"./pins/queries": "./pins/queries.ts",
"./pins/mutations": "./pins/mutations.ts",
"./billing": "./billing/index.ts",
"./billing/queries": "./billing/queries.ts",
"./billing/mutations": "./billing/mutations.ts",
"./github": "./github/index.ts",
"./github/queries": "./github/queries.ts",
"./feedback": "./feedback/index.ts",
"./feedback/mutations": "./feedback/mutations.ts",
"./realtime": "./realtime/index.ts",
"./navigation": "./navigation/index.ts",
"./modals": "./modals/index.ts",
"./onboarding": "./onboarding/index.ts",
"./paths": "./paths/index.ts",
"./hooks": "./hooks.tsx",
"./hooks/*": "./hooks/*.ts",
"./query-client": "./query-client.ts",
"./provider": "./provider.tsx",
"./logger": "./logger.ts",
"./utils": "./utils.ts",
"./constants/*": "./constants/*.ts",
"./platform": "./platform/index.ts",
"./analytics": "./analytics/index.ts",
"./i18n": "./i18n/index.ts",
"./i18n/react": "./i18n/react.ts",
"./i18n/browser": "./i18n/browser.ts"
},
"dependencies": {
"@formatjs/intl-localematcher": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "^5.96.2",
"i18next": "catalog:",
"posthog-js": "catalog:",
"react-i18next": "catalog:",
"zod": "catalog:",
"zustand": "catalog:"
},
"peerDependencies": {
"react": "catalog:"
},
"devDependencies": {
"@multica/eslint-config": "workspace:*",
"@multica/tsconfig": "workspace:*",
"@testing-library/react": "catalog:",
"@types/react": "catalog:",
"jsdom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}