Overview
A course checkout in Europe isn't just "enter your card". Buy-now-pay-later providers differ by country: PagoLight in Italy, SeQura in Spain, Alma in France, and Klarna across several markets. Business buyers need a VAT invoice. Some students pay a prepayment now and the rest before the course starts.
I built the payment step of the checkout (checkout-v3), then the billing API endpoints (api-v2) and the student and admin billing screens (academy-v2, admin-v3) that make those payments visible afterwards.
The payment step
The checkout keeps its state in a Zustand store. The payment step is a small router: the selected method decides the route, and each route owns its own flow.
- Stripe. An embedded Stripe Elements card form.
- Klarna. A session hook creates the Klarna session. It asks for the richest payment category the session offers (pay over time, then pay later, then pay now), so students see instalments whenever Klarna allows them.
- Alma, PagoLight, SeQura. A redirect flow. The API answers
requires_actionwith either aredirect_urlor, for SeQura, a form to render in place.
Starting a payment session is the dangerous part of a React checkout, because effects re-run. The redirect hook guards session creation with a useRef flag and an AbortController, so a re-render or a double click never opens a second session with the provider.
Later I added a company-details step for Italian business buyers: company name, VAT number, and billing information. The number of steps adapts to the user's locale and VAT choice. I also replaced loose any types in the payment hooks with explicit ones.
Billing after the checkout
Once students had paid, the platform had two billing systems side by side: legacy payments and V2 checkout orders with instalments. Students and support saw different, partial views of each.
One billing view. The team's unified billing service returns contracts and invoices (including instalments) from both systems in one payload. I rebuilt the student Contract & Invoices popup on it, typing each invoice's download descriptor ({type: legacy | v2, invoice_id}) as a discriminated union so the PDF download is routed to the right system. I then added GET /api/v2/admin/users/{id}/billing on the same service, so support sees exactly what the student sees.
The invoice download that 500'd. Students with V2-checkout purchases couldn't download invoices. The frontend posted the V2 invoice ID to the legacy endpoint, which found no legacy row and crashed with Attempt to read property "data" on null. I added a V2 download endpoint. It is scoped to the invoice owner through order → lead → user, returns 404 for foreign and unknown IDs alike, and returns 422 for unpaid invoices. It uses the same response format as the legacy endpoint, so the frontend treats both the same way.
Prepayment students. Students who paid only the prepayment saw "Installment 1 of 2" and had no way to pay the rest from the platform. The billing payload now exposes the order's payment structure, the course start date, and its canonical checkout link. The student app labels rows "Prepayment" and "Rest payment", shows a homepage card with the real due date and a Pay now button, and triggers the payment reminder on an outstanding rest invoice. Those labels are localized in all eight locales. The frontend shows nothing until the backend fields exist, so the backend could ship first safely.
Invoices. The student Contract & Invoices popup was rebuilt on the new endpoint, with a custom-invoice generator (company, address, VAT, payment reference) and currency formatting through Intl.NumberFormat.
Results
- Five payment providers behind one checkout step, with instalments wherever the provider supports them
- VAT-ready checkout for Italian businesses
- The same billing view for students and support across legacy and V2 payments
- A production invoice-download failure fixed with an ownership-scoped endpoint
