feat(signup): flag-gated paid account option #282

Öppen
supernaut vill sammanfoga 3 incheckningar från s[2]s in i main
Ägare

What

A flag-gated paid account option at sign-up.

PAYMENTS_ENABLED Sign-up offers
off trial account, participant account
on paid account, participant account
  • offeredAccountTypes() in src/lib/signup-access.ts makes the decision. The form and the API both read it, so paid and trial never render together.
  • The API refuses paid (status=input) when it is not offered. A forged trial with payments on falls back to a participant account.
  • "paid" joins PAYING_ACCOUNT_TYPES, so the country of residence is collected again for paid only. The country select shows, and is required, only while the paid option is chosen.
  • Checkout currently supports Swedish residents only. A paid sign-up from another eligible country is refused (status=paid.country) before anything is provisioned, with a message in both languages pointing at the participant account. Participant and trial sign-ups are unchanged.
  • A country error re-renders the form with paid still chosen, so the country field stays visible.
  • Paid copy now says what differs: it runs until you cancel, and you pay once you have chosen how you sign in. Both languages.
  • Version 1.16.0.

Hand-off

Checkout needs a signed-in session. A new user has none until they set a credential. So a paid sign-up:

  1. declares a country and is provisioned as a participant (trial: false, no paid group);
  2. sets a credential from the emailed link;
  3. signs in to /account and pays through the existing checkout.

The paid tier comes only from the billing webhook after payment. Sign-up never grants it. A paid sign-up lands on status=paid, which tells the user to sign in and pay, and links to the account page. /welcome links to the account page while checkout is available.

Deploying

Production is unchanged by this PR. Turning paid sign-up on needs PAYMENTS_ENABLED=true (plus the billing settings) in the web container. The bitborg-infra template roles/web/templates/bitborg-web.container.j2 sets none of these today.

How tested

  • Unit tests: offered types per flag, the eligibility binding (offeredAccountTypes().filter(requiresCountry) is ["paid"]), the API refusing paid when payments are off or checkout is unconfigured, the Sweden-only paid check (checkoutSupports()), no paid grant at sign-up, country recorded for paid and NULL otherwise, paid status maps to step 2. The country tests now use the real requiresCountry instead of a mock.
  • There is no Astro component test harness. The container API loads astro.config.mjs, which reads local dev certificates. Rendering was checked by hand on the built server: payments off renders trial + participant; on with billing renders paid + participant + country select; on without billing renders participant only. status=paid.country and status=ineligible render with paid checked and the country select visible and enabled.
  • pnpm test: 503 passed, 3 skipped. pnpm lint: clean. pnpm check: 0 errors, 0 warnings, 18 hints. pnpm build: ok. pnpm lang-check: clean.

Open questions

  • When is payment taken? This takes payment after credential set-up, on the account page. Taking it inside the sign-up form needs a checkout path without a session, the start-immediately consent on the sign-up form, and accepting payment before the email address is confirmed. Which is wanted?
  • Accounts already on the trial when the flag flips. Not handled here. They convert or downgrade per ADR 0036. That needs an explicit decision and operational work.
  • Paid needs checkout configured, not only the flag. With PAYMENTS_ENABLED=true but no billing settings, only the participant account is offered. Is that the wanted fallback?
  • Pre-selection. The participant account is pre-selected when paid is offered. The trial was pre-selected while open.
  • The paid choice is not stored. /welcome shows the payment link to anyone while checkout is available, and the set-up email does not mention payment.

Refs #149

## What A flag-gated paid account option at sign-up. | `PAYMENTS_ENABLED` | Sign-up offers | | ------------------ | ---------------------------------- | | off | trial account, participant account | | on | paid account, participant account | - `offeredAccountTypes()` in `src/lib/signup-access.ts` makes the decision. The form and the API both read it, so paid and trial never render together. - The API refuses `paid` (`status=input`) when it is not offered. A forged `trial` with payments on falls back to a participant account. - `"paid"` joins `PAYING_ACCOUNT_TYPES`, so the country of residence is collected again for paid only. The country select shows, and is required, only while the paid option is chosen. - Checkout currently supports Swedish residents only. A paid sign-up from another eligible country is refused (`status=paid.country`) before anything is provisioned, with a message in both languages pointing at the participant account. Participant and trial sign-ups are unchanged. - A country error re-renders the form with paid still chosen, so the country field stays visible. - Paid copy now says what differs: it runs until you cancel, and you pay once you have chosen how you sign in. Both languages. - Version 1.16.0. ## Hand-off Checkout needs a signed-in session. A new user has none until they set a credential. So a paid sign-up: 1. declares a country and is provisioned as a participant (`trial: false`, no paid group); 2. sets a credential from the emailed link; 3. signs in to `/account` and pays through the existing checkout. The paid tier comes only from the billing webhook after payment. Sign-up never grants it. A paid sign-up lands on `status=paid`, which tells the user to sign in and pay, and links to the account page. `/welcome` links to the account page while checkout is available. ## Deploying Production is unchanged by this PR. Turning paid sign-up on needs `PAYMENTS_ENABLED=true` (plus the billing settings) in the web container. The bitborg-infra template `roles/web/templates/bitborg-web.container.j2` sets none of these today. ## How tested - Unit tests: offered types per flag, the eligibility binding (`offeredAccountTypes().filter(requiresCountry)` is `["paid"]`), the API refusing paid when payments are off or checkout is unconfigured, the Sweden-only paid check (`checkoutSupports()`), no paid grant at sign-up, country recorded for paid and NULL otherwise, `paid` status maps to step 2. The country tests now use the real `requiresCountry` instead of a mock. - There is no Astro component test harness. The container API loads `astro.config.mjs`, which reads local dev certificates. Rendering was checked by hand on the built server: payments off renders trial + participant; on with billing renders paid + participant + country select; on without billing renders participant only. `status=paid.country` and `status=ineligible` render with paid checked and the country select visible and enabled. - `pnpm test`: 503 passed, 3 skipped. `pnpm lint`: clean. `pnpm check`: 0 errors, 0 warnings, 18 hints. `pnpm build`: ok. `pnpm lang-check`: clean. ## Open questions - **When is payment taken?** This takes payment after credential set-up, on the account page. Taking it inside the sign-up form needs a checkout path without a session, the start-immediately consent on the sign-up form, and accepting payment before the email address is confirmed. Which is wanted? - **Accounts already on the trial when the flag flips.** Not handled here. They convert or downgrade per ADR 0036. That needs an explicit decision and operational work. - **Paid needs checkout configured, not only the flag.** With `PAYMENTS_ENABLED=true` but no billing settings, only the participant account is offered. Is that the wanted fallback? - **Pre-selection.** The participant account is pre-selected when paid is offered. The trial was pre-selected while open. - **The paid choice is not stored.** `/welcome` shows the payment link to anyone while checkout is available, and the set-up email does not mention payment. Refs #149
supernaut lade till 3 incheckningar 2026-10-03 00:12:45 +00:00
With payments on, sign-up offers a paid account beside the participant
account and the trial is no longer offered. With payments off it offers
the trial and participant accounts as before. offeredAccountTypes() is
the single decision; the form and the API both read it, so paid and trial
cannot render together and the API refuses a paid sign-up that is not
offered.

Paid joins PAYING_ACCOUNT_TYPES, so the country of residence is collected
again for that type only. A test binds the offered paid option to the
set.

Checkout needs a signed-in session, which a new user does not have until
a credential is set. A paid sign-up is therefore provisioned as a
participant, and the paid tier comes only from the billing webhook after
payment. The success notice and /welcome point to the account page, where
the existing checkout takes payment.

Refs #149
Checkout currently supports Swedish residents only, so a paid sign-up
from any other eligible country is refused before anything is
provisioned. The user is told why and pointed at the participant
account. Participant and trial sign-ups are unchanged: they declare no
country.

A rejected country re-renders the form with the paid option still
chosen, so the country field the error points at stays visible. The
paid success notice now links to the account page.

Refs #149
docs(signup): note the Swedish-residents limit on paid sign-up
Alla kontroller lyckades
ci / ci (pull_request) Successful in 1m54s
795626d137
Refs #149
Alla kontroller lyckades
ci / ci (pull_request) Successful in 1m54s
Obligatorisk
Detaljer
Den här ändringsförfrågningen har ändringar som är i konflikt med målgrenen.
  • package.json
Visa kommandoradsinstruktioner

Manuell sammanfogningshjälp

Använd detta sammanfogningsmeddelande när sammanfogningen slutförs manuellt.

Checka ut

Checka ut en ny gren från din projektkatalog och testa ändringarna.
git fetch -u origin feat/149-paid-signup:feat/149-paid-signup
git switch feat/149-paid-signup
Logga in för att delta i denna konversation.
Inga granskare
Ingen milstolpe
Inget projekt
Inga tilldelade
1 deltagare
Notiser
Förfallodatum
Förfallodatumet är ogiltigt eller utanför gränserna. Använd formatet "åååå-mm-dd".

Inget förfallodatum satt.

Beroenden

Inga beroenden satta

Referens
bitborg/bitborg-web!282
Ingen beskrivning angiven.