fix(billing): scope Stripe linking, paywall and billing pages to this deployment's product (#10331)

Product-scoped (hosted plan only) Stripe webhook linking, signup paywall, lazy tenant link and /billing routes; checkout-session proof at register/onboarding; BILLING_PRODUCT, BILLING_SIGNUP_PAYWALL and BILLING_WEBHOOK_LINKING (default off); 402 payment_method_required on a paid upgrade without a card. See the PR description for details.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ruslan Konviser
2026-09-29 11:34:26 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 367a01a66d
commit ba62a02001
27 changed files with 3447 additions and 106 deletions
@@ -121,12 +121,29 @@ export class BillingComponent extends TranslationBaseComponent implements OnInit
if (this.working || this.isCurrentPlan(plan)) return;
this.working = true;
try {
this.subscription = await firstValueFrom(this.billingService.changePlan(plan.lookupKey));
this.subscription = await firstValueFrom(
this.billingService.changePlan(plan.lookupKey, window.location.href)
);
this.toastrService.success('SETTINGS_MENU.BILLING_PLAN_CHANGED', { name: plan.productName });
// Switching plans issues an invoice, so the list below is now stale.
this.invoices = await this.safe(() => firstValueFrom(this.billingService.getInvoices()), this.invoices);
} catch (error) {
this.errorHandlingService.handleError(error);
// An upgrade to a paid plan from one with no card on file: the API refuses it rather than
// leave an invoice nobody can pay, and hands back a Stripe portal link to add a card. Send the
// admin there; they return to this page and switch again.
const body = (error as { status?: number; error?: { code?: string; portalUrl?: string } })?.error;
if ((error as { status?: number })?.status === 402 && body?.code === 'payment_method_required') {
// The API still answers 402 when Stripe could not open a portal session, just without the
// link — then say so, and point at the page's own "manage billing" action instead.
if (body.portalUrl) {
this.toastrService.info('SETTINGS_MENU.BILLING_PAYMENT_METHOD_REQUIRED', 'TOASTR.TITLE.INFO');
window.location.href = body.portalUrl;
return;
}
this.toastrService.warning('SETTINGS_MENU.BILLING_PAYMENT_METHOD_REQUIRED_NO_PORTAL');
} else {
this.errorHandlingService.handleError(error);
}
} finally {
this.working = false;
}
@@ -74,8 +74,12 @@ export class BillingService {
return this.http.get<IBillingPlan[]>(`${this.endpoint}/plans`);
}
changePlan(lookupKey: string): Observable<IBillingSubscription> {
return this.http.post<IBillingSubscription>(`${this.endpoint}/subscription/change`, { lookupKey });
/**
* Switch plans. `returnUrl` is where Stripe's portal sends the admin back to if the API answers 402
* `payment_method_required` — an upgrade to a paid plan from one with no card on file.
*/
changePlan(lookupKey: string, returnUrl?: string): Observable<IBillingSubscription> {
return this.http.post<IBillingSubscription>(`${this.endpoint}/subscription/change`, { lookupKey, returnUrl });
}
cancel(): Observable<IBillingSubscription> {