mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* 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]>