From d6c1dccded21453faa061d61a53c7b1d3e523ff1 Mon Sep 17 00:00:00 2001 From: Maxerns Date: Mon, 12 Oct 2026 15:29:58 +0200 Subject: [PATCH] send canonical offers from the web billing form The account panel exposes monthly and annual terms, while the billing API accepts canonical catalogue offer names. Map the selected term for quote requests and preserve the server returned offer for checkout. Keep founding quotes displayable, but leave cohort assignment to the shared personal and sponsored allocator work. --- web/src/CloudAccountPanel.tsx | 5 +++-- web/src/__tests__/CloudAccountPanel.test.tsx | 8 ++++---- web/src/api.ts | 16 ++++++++++++---- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/web/src/CloudAccountPanel.tsx b/web/src/CloudAccountPanel.tsx index f37bfa3b..992e8a19 100644 --- a/web/src/CloudAccountPanel.tsx +++ b/web/src/CloudAccountPanel.tsx @@ -16,6 +16,7 @@ import { type PersonalLifecycle, type PersonalOperation, type PersonalQuote, + type PersonalBillingTerm, type RefundRequest, } from "./api"; @@ -25,7 +26,7 @@ function date(epoch: number | null): string | null { return epoch === null ? nul export function CloudAccountPanel() { const [eligibility, setEligibility] = useState(null); - const [offer, setOffer] = useState("monthly"); + const [offer, setOffer] = useState("monthly"); const [quote, setQuote] = useState(null); const [lifecycle, setLifecycle] = useState(null); const [refund, setRefund] = useState(null); @@ -134,7 +135,7 @@ export function CloudAccountPanel() { {error !== null &&

{error}

}{notice !== null &&

{notice}

} {notices.length > 0 &&

Account notices

    {notices.map((item) =>
  • {item.content.title}

    {item.content.detail}

    {item.content.deadlineEpoch !== null &&

    Deadline: {date(item.content.deadlineEpoch)}

    }{item.status === "failed" &&

    This notice could not be delivered{item.lastErrorCode === null ? "." : ` (${item.lastErrorCode}).`}

    }
  • )}
}

Hosted access

{eligibility.state.replaceAll("_", " ")}{paidThrough !== null ? ` · paid through ${paidThrough}` : ""}

{recoveryUntil !== null &&

Recovery is available until {recoveryUntil}.

}{eligibility.payer === null && eligibility.state === "paid" &&

Your hosted access is sponsored or provided by another billing record. There is no personal upgrade to buy.

}{eligibility.state === "unavailable" &&

Billing evidence is temporarily unavailable. Checkout is hidden until the account can be checked safely.

}
- {eligibility.actions.billing && quote !== null &&

Choose hosted billing

{pounds(quote.amountPence)} per {quote.interval}. Tax is shown at checkout.

{quote.founding &&

Founding price: {quote.foundingRemainingPlaces ?? 0} places remain; {quote.foundingTerm}. Renews at {pounds(quote.nextRenewalAmountPence)}.

}
} + {eligibility.actions.billing && quote !== null &&

Choose hosted billing

{pounds(quote.amountPence)} per {quote.interval}. Tax is shown at checkout.

{quote.founding &&

Founding price: {quote.foundingRemainingPlaces ?? 0} places remain; {quote.foundingTerm}. Renews at {pounds(quote.nextRenewalAmountPence)}.

}
} {eligibility.state === "pending_initial_payment" &&

Payment confirmation pending

Your checkout is waiting for the verified payment webhook. Hosted access stays unchanged until it arrives.

{operation?.checkoutUrl !== null && operation?.checkoutUrl !== undefined &&

Return to checkout

}
} {(eligibility.state === "paid" || eligibility.state === "renewal_recovery") &&

Manage billing

{" "}{eligibility.actions.revoke && }{lifecycle?.cancelAtPeriodEnd &&

Cancellation is scheduled; access remains available until {lifecycle.paidThroughDate ?? "the paid period ends"}.

}{refund !== null &&

Refund request: {refund.state}. The paid term is preserved by default.

}
} {eligibility.actions.export &&

Recover your encrypted account

{exportUntil === null ? "Your export window is open." : `Export before ${exportUntil}.`}

{eligibility.accountInitialized ? :

Set up your account before exporting.

}
} diff --git a/web/src/__tests__/CloudAccountPanel.test.tsx b/web/src/__tests__/CloudAccountPanel.test.tsx index 5b382726..dcc1d9db 100644 --- a/web/src/__tests__/CloudAccountPanel.test.tsx +++ b/web/src/__tests__/CloudAccountPanel.test.tsx @@ -52,14 +52,14 @@ describe("CloudAccountPanel", () => { it("shows the server quote before offering a personal checkout", async () => { vi.mocked(api.fetchEligibility).mockResolvedValue({ ...base, state: "free", actions: { ...base.actions, billing: true } }); vi.mocked(api.fetchPersonalQuote).mockResolvedValue({ - offer: "monthly", amountPence: 299, currency: "gbp", interval: "month", taxTreatment: "shown_at_checkout", + offer: "founding_monthly", amountPence: 199, currency: "gbp", interval: "month", taxTreatment: "shown_at_checkout", quoteVersion: 1, quoteExpiresAtEpoch: 2_000_000_000, founding: true, - foundingRemainingPlaces: 12, foundingTerm: "through the founding monthly term", nextRenewalAmountPence: 499, + foundingRemainingPlaces: 12, foundingTerm: "through the founding monthly term", nextRenewalAmountPence: 299, }); render(); - expect(await screen.findByText(/£2\.99 per month/)).toBeInTheDocument(); + expect(await screen.findByText(/£1\.99 per month/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /secure checkout/i })).toBeInTheDocument(); expect(screen.getByText(/12 places remain/)).toBeInTheDocument(); }); @@ -67,7 +67,7 @@ describe("CloudAccountPanel", () => { it("keeps a pending checkout recoverable", async () => { vi.mocked(api.fetchEligibility).mockResolvedValue({ ...base, state: "pending_initial_payment" }); vi.mocked(api.fetchPersonalOperation).mockResolvedValue({ - operationId: "operation-1", offer: "monthly", state: "pending", checkoutUrl: null, + operationId: "operation-1", offer: "standard_monthly", state: "pending", checkoutUrl: null, }); sessionStorage.setItem("sotto_personal_operation_id", "operation-1"); diff --git a/web/src/api.ts b/web/src/api.ts index 96e2fa5e..73e8fbeb 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -219,8 +219,15 @@ export async function fetchCloudNotices(): Promise { })); } +export type PersonalBillingTerm = "monthly" | "annual"; +export type PersonalBillingOffer = + | "standard_monthly" + | "standard_annual" + | "founding_monthly" + | "founding_annual"; + export interface PersonalQuote { - offer: "monthly" | "annual"; + offer: PersonalBillingOffer; amountPence: number; currency: string; interval: string; @@ -233,9 +240,10 @@ export interface PersonalQuote { nextRenewalAmountPence: number; } -export async function fetchPersonalQuote(offer: PersonalQuote["offer"]): Promise { +export async function fetchPersonalQuote(term: PersonalBillingTerm): Promise { + const offer = term === "monthly" ? "standard_monthly" : "standard_annual"; const body = await authedJson<{ - offer: PersonalQuote["offer"]; + offer: PersonalBillingOffer; amount_pence: number; currency: string; interval: string; @@ -269,7 +277,7 @@ export interface PersonalCheckoutResult { } export async function createPersonalCheckout(input: { - offer: PersonalQuote["offer"]; + offer: PersonalBillingOffer; idempotencyKey: string; quoteVersion: number; quoteExpiresAtEpoch: number;