Files
multica/server/cmd
Multica Eveandmultica-agent 2d711afdd5 MUL-5852: 新增 Workspace Billing summary/prices proxy 与 Stripe 回跳落点 (#6912)
* MUL-5852 feat(billing): proxy workspace billing reads and add the Stripe return route

Middle layer between multica-cloud's subscription contract and the Billing UI.

Backend: two member-readable proxies, GET /api/cloud-subscriptions/summary and
/prices, alongside the existing entitlements read. Both go through
requireCloudSubscriptionWorkspace, so the workspace comes from the authenticated
context, the rollout flag is enforced in the handler as well as the router, and
machine credentials stay rejected. Neither exposes a Stripe Price or Product ID;
cloud keeps those server-side.

Core: types, zod schemas, client methods and query hooks for entitlements,
summary and prices. The schemas are the compatibility boundary and have no
fallback value: an older cloud, a 503, a renamed field or a non-positive price
all yield null so the caller renders "unavailable". A synthetic Free shape would
turn an upstream outage into a silent downgrade of a paying workspace. Unknown
plans and statuses pass through as-is, and .loose() keeps additive cloud fields
working on older clients. Prices get a long stale time and no retry — the
failure mode is a misconfigured Price, which retrying cannot fix.

Return route: /billing/return resolves the workspace_id cloud authorized into a
slug and replaces to that workspace's Billing settings, carrying only the result
marker. A deployment has one set of Stripe return URLs, so they cannot contain a
slug; without this page an operator has to hardcode one workspace and strand the
rest. Only workspace_id and result are read and the destination is rebuilt from
paths, so a crafted return link cannot become an open redirect. Unresolvable
workspace falls back to the app root; an expired session goes to login. Desktop
needs no route: Stripe opens in the system browser, so the return lands on web.

Co-authored-by: multica-agent <[email protected]>

* MUL-5852 fix(billing): read has_stripe_customer and stop losing return context

The summary field is has_stripe_customer, not can_open_portal. Cloud renamed it
before #47 merged, and because the schema is .loose() with the field optional, a
real response did not fail validation — it silently produced
hasStripeCustomer=false, so an owner/admin with a Stripe customer would have been
told Billing Portal was unavailable. Renamed through types, schema and tests. It
stays a fact, not a permission: callers must still gate the control on
owner/admin, and the server remains the final boundary.

Return page failure states, both of which previously ended in a dead end:

- An expired session redirected to /login and dropped the return URL, so a user
  whose session lapsed during Checkout came back to their default workspace with
  no indication whether the payment landed. The current relative return URL is
  now passed as ?next=; the login page's sanitizer accepts relative paths only,
  so this cannot become an off-site redirect.
- A failed workspace-list query left the page spinning forever, since resolving
  the ID to a slug is what produces a destination. It now shows an error with a
  retry.
- An unresolvable workspace (deleted, access revoked, edited link) no longer
  redirects silently; it says the billing change was saved and offers a way back
  into the app.

Also from review: prices intervals are now pinned to the slot they arrive in, so
a yearly Price under `month` is rejected rather than quoted as monthly, and the
misleading "older cloud" test is replaced by one asserting the real behaviour —
non-2xx throws ApiError before schema parsing, while a non-conforming 2xx body
returns null. Both render unavailable; neither invents Free.

Co-authored-by: multica-agent <[email protected]>

* MUL-5852 fix(billing): stop the return page claiming an outcome it cannot know

Two problems on the payment result screen, where being wrong is least
affordable.

The failure copy asserted "your billing change was saved". The page cannot know
that: `cancel` means the user backed out, `portal` may have been opened and
closed, and even `success` only means Stripe finished — the subscription is not
recorded until the webhook lands, which the file's own comment already said. Both
messages now state what happened (came back from Stripe, could not open the
billing page) and where to check the real state, and claim nothing. A test helper
asserts the alert never matches saved/success/upgraded/activated/subscribed,
applied across cancel, portal, missing and invalid result.

The login continuation forwarded the whole return query as `next`. The login page
embeds `next` verbatim in the Google OAuth `state`, so Stripe's `session_id` and
any future cloud param would be handed to a third party for no functional
reason. `next` is now rebuilt from only the two values this page uses to resume:
`workspace_id`, newly shape-checked as a UUID, and an allowlisted `result`.
Unknown params are dropped. This was never an open redirect — sanitizeNextUrl
already rejects non-relative paths — it was unnecessary data leaving the app.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-13 18:35:00 +08:00
..