Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions web/src/CloudAccountPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type PersonalLifecycle,
type PersonalOperation,
type PersonalQuote,
type PersonalBillingTerm,
type RefundRequest,
} from "./api";

Expand All @@ -25,7 +26,7 @@ function date(epoch: number | null): string | null { return epoch === null ? nul

export function CloudAccountPanel() {
const [eligibility, setEligibility] = useState<EligibilityView | null>(null);
const [offer, setOffer] = useState<PersonalQuote["offer"]>("monthly");
const [offer, setOffer] = useState<PersonalBillingTerm>("monthly");
const [quote, setQuote] = useState<PersonalQuote | null>(null);
const [lifecycle, setLifecycle] = useState<PersonalLifecycle | null>(null);
const [refund, setRefund] = useState<RefundRequest | null>(null);
Expand Down Expand Up @@ -134,7 +135,7 @@ export function CloudAccountPanel() {
{error !== null && <p role="alert">{error}</p>}{notice !== null && <p className="notice" role="status">{notice}</p>}
{notices.length > 0 && <section aria-labelledby="notices-heading"><h2 id="notices-heading">Account notices</h2><ul>{notices.map((item) => <li key={item.noticeId}><strong>{item.content.title}</strong><p>{item.content.detail}</p>{item.content.deadlineEpoch !== null && <p className="muted">Deadline: {date(item.content.deadlineEpoch)}</p>}{item.status === "failed" && <p role="alert">This notice could not be delivered{item.lastErrorCode === null ? "." : ` (${item.lastErrorCode}).`}</p>}</li>)}</ul></section>}
<section aria-labelledby="status-heading"><h2 id="status-heading">Hosted access</h2><p><strong>{eligibility.state.replaceAll("_", " ")}</strong>{paidThrough !== null ? ` · paid through ${paidThrough}` : ""}</p>{recoveryUntil !== null && <p className="muted">Recovery is available until {recoveryUntil}.</p>}{eligibility.payer === null && eligibility.state === "paid" && <p className="muted">Your hosted access is sponsored or provided by another billing record. There is no personal upgrade to buy.</p>}{eligibility.state === "unavailable" && <p>Billing evidence is temporarily unavailable. Checkout is hidden until the account can be checked safely.</p>}</section>
{eligibility.actions.billing && quote !== null && <section aria-labelledby="billing-heading"><h2 id="billing-heading">Choose hosted billing</h2><label>Term <select value={offer} onChange={(e) => setOffer(e.target.value as PersonalQuote["offer"])}><option value="monthly">Monthly</option><option value="annual">Annual</option></select></label><p>{pounds(quote.amountPence)} per {quote.interval}. Tax is shown at checkout.</p>{quote.founding && <p className="muted">Founding price: {quote.foundingRemainingPlaces ?? 0} places remain; {quote.foundingTerm}. Renews at {pounds(quote.nextRenewalAmountPence)}.</p>}<button className="primary" disabled={busy} onClick={() => void checkout()}>{busy ? "Opening checkout…" : "Continue to secure checkout"}</button></section>}
{eligibility.actions.billing && quote !== null && <section aria-labelledby="billing-heading"><h2 id="billing-heading">Choose hosted billing</h2><label>Term <select value={offer} onChange={(e) => setOffer(e.target.value as PersonalBillingTerm)}><option value="monthly">Monthly</option><option value="annual">Annual</option></select></label><p>{pounds(quote.amountPence)} per {quote.interval}. Tax is shown at checkout.</p>{quote.founding && <p className="muted">Founding price: {quote.foundingRemainingPlaces ?? 0} places remain; {quote.foundingTerm}. Renews at {pounds(quote.nextRenewalAmountPence)}.</p>}<button className="primary" disabled={busy} onClick={() => void checkout()}>{busy ? "Opening checkout…" : "Continue to secure checkout"}</button></section>}
{eligibility.state === "pending_initial_payment" && <section aria-labelledby="pending-heading"><h2 id="pending-heading">Payment confirmation pending</h2><p>Your checkout is waiting for the verified payment webhook. Hosted access stays unchanged until it arrives.</p>{operation?.checkoutUrl !== null && operation?.checkoutUrl !== undefined && <p><a href={operation.checkoutUrl}>Return to checkout</a></p>}<button disabled={busy} onClick={() => void refreshOperation()}>Refresh payment status</button></section>}
{(eligibility.state === "paid" || eligibility.state === "renewal_recovery") && <section aria-labelledby="manage-heading"><h2 id="manage-heading">Manage billing</h2><button disabled={busy} onClick={() => void portal()}>Open billing portal</button>{" "}{eligibility.actions.revoke && <button disabled={busy} onClick={() => void cancel()}>Cancel at the end of the paid period</button>}<button disabled={busy} onClick={() => void askRefund()}>Request a refund</button>{lifecycle?.cancelAtPeriodEnd && <p className="muted">Cancellation is scheduled; access remains available until {lifecycle.paidThroughDate ?? "the paid period ends"}.</p>}{refund !== null && <p className="muted">Refund request: {refund.state}. The paid term is preserved by default.</p>}</section>}
{eligibility.actions.export && <section aria-labelledby="export-heading"><h2 id="export-heading">Recover your encrypted account</h2><p>{exportUntil === null ? "Your export window is open." : `Export before ${exportUntil}.`}</p>{eligibility.accountInitialized ? <button disabled={busy} onClick={() => void exportAccount()}>Download encrypted export</button> : <p>Set up your account before exporting.</p>}</section>}
Expand Down
8 changes: 4 additions & 4 deletions web/src/__tests__/CloudAccountPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,22 +52,22 @@ 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(<CloudAccountPanel />);

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();
});

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");

Expand Down
16 changes: 12 additions & 4 deletions web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,15 @@ export async function fetchCloudNotices(): Promise<CloudNotice[]> {
}));
}

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;
Expand All @@ -233,9 +240,10 @@ export interface PersonalQuote {
nextRenewalAmountPence: number;
}

export async function fetchPersonalQuote(offer: PersonalQuote["offer"]): Promise<PersonalQuote> {
export async function fetchPersonalQuote(term: PersonalBillingTerm): Promise<PersonalQuote> {
const offer = term === "monthly" ? "standard_monthly" : "standard_annual";
const body = await authedJson<{
offer: PersonalQuote["offer"];
offer: PersonalBillingOffer;
amount_pence: number;
currency: string;
interval: string;
Expand Down Expand Up @@ -269,7 +277,7 @@ export interface PersonalCheckoutResult {
}

export async function createPersonalCheckout(input: {
offer: PersonalQuote["offer"];
offer: PersonalBillingOffer;
idempotencyKey: string;
quoteVersion: number;
quoteExpiresAtEpoch: number;
Expand Down
Loading