Commerce
Payments
Generated from PAYMENTS.md in the AstroBaaS repository. The repository is the source of truth; this page is a copy of it.
Stripe, PayPal, and Klarna ship in core. More are meant to follow — the whole
shape of src/lib/payments/ exists so that adding one costs a file and a line.
Status. The protocol logic, the security controls, and the whole webhook → capture → order-state chain are implemented and tested. The security-critical parts are tested adversarially and offline (see Testing). What is not verified is a round trip against the providers’ live sandboxes, which needs real credentials. Run each provider in its sandbox before taking real money. Treat this as “correct by construction and heavily tested”, not “battle-proven in production”.
What this design refuses to do
Most of the code here is shaped by things it deliberately will not do.
It never touches card data. Every provider uses hosted checkout: we create a
session server-side and redirect the buyer to the provider’s own page. No PAN,
CVV, or IBAN enters this process, which is what keeps a self-hosted AstroBaaS
out of PCI-DSS scope. A provider that wants raw card fields does not belong in
src/lib/payments/.
It never reads credentials from the database. Provider secrets come from environment variables only. The settings table is a schemaless bucket with a public read path, so a secret stored there is one mistake away from being world-readable — a mistake this codebase has already made once and fixed.
It never lets the client name the price. A session is built from a stored order whose total was computed server-side at checkout.
It never trusts a webhook body. Anyone can POST “payment succeeded” to a public URL. Verification is mandatory and there is no bypass flag — a “skip verification in development” switch is precisely the switch that ends up enabled in production.
It never assumes a valid signature means a relevant message. A verified event still has to match the order’s currency and total before it can mark anything paid. Signature validity proves who sent it, not what it is about. Without that second check, a genuine 1-cent event captures a 500-euro order.
Configure
PAYMENTS_ENABLED=stripe,paypal,klarna # nothing is on by default
# Stripe
STRIPE_SECRET_KEY=sk_live_…
STRIPE_WEBHOOK_SECRET=whsec_… # shown when you add the endpoint
# PayPal
PAYPAL_CLIENT_ID=…
PAYPAL_CLIENT_SECRET=…
PAYPAL_WEBHOOK_ID=…
PAYPAL_ENV=sandbox # or 'live'
# Klarna
KLARNA_USERNAME=…
KLARNA_PASSWORD=…
KLARNA_REGION=eu # eu | na | oc
KLARNA_ENV=playground # or 'live'
KLARNA_COUNTRY=DE
KLARNA_LOCALE=en-DE
A provider is offered to buyers only when it is both listed in
PAYMENTS_ENABLED and has every credential present. Half-configured is the
dangerous state — a provider that appears at checkout and fails mid-flow has
already taken the buyer’s attention and reserved stock — so it is reported as
misconfigured under Admin → Settings → Payment providers, naming the
variables it still needs.
Point each provider’s webhook at:
https://your-site.example/api/payments/webhook/<provider>
Manual methods (bank-transfer, cod) are always available and need no setup.
The buyer’s path
POST /api/orders— checkout. Prices computed server-side, stock reserved atomically, order createdpending/unpaid.POST /api/payments/startwithorder_number,email,provider→ returnsredirect_url. Order becomespendingpayment.- Buyer pays on the provider’s page.
- Provider calls the webhook. It is verified, then the amount is checked, then
the order becomes
paid+processing.
Step 4 is what moves the order — not the buyer landing back on
/checkout/success. A redirect proves nothing; anyone can visit that URL.
Why payment_status is separate from status
status answers are we working on it. payment_status answers did the money
arrive. Conflating them is how orders get shipped unpaid, so they are two
fields and both show on every row in the admin.
A confirmed payment moves an order to processing, never straight to
completed — fulfilment stays a human decision.
Authorisation on /api/payments/start
Order numbers are sequential. Number alone would let anyone walk the sequence and open payment links for other people’s orders, which leaks basket contents and totals through the provider’s checkout page. So the endpoint requires the number and the email the order was placed with, and answers a generic 404 for either failure so it cannot be used to enumerate valid numbers.
Verification styles
| Provider | Style | Why |
|---|---|---|
| Stripe | Signed payload | HMAC-SHA256 over t.rawBody, constant-time compare, 5-minute replay window |
| PayPal | Verify API + fetch-back | Verified via PayPal’s own endpoint, then the order is re-read from the API — money is taken from that answer, not the notification |
| Klarna | Fetch-back only | Klarna’s push is historically unsigned and varies by product; the push is treated as a hint and the authoritative order is fetched with our credentials |
Fetch-back is the safer default. It cannot be forged by anyone who does not already hold our API credentials, and it does not depend on reconstructing a signing string correctly. When a provider’s signing scheme is anything less than unambiguous, use fetch-back. Never invent a signature scheme — a verification you guessed at is a verification that always passes.
Two details that matter for signed payloads:
- The webhook route reads
request.text()and passes the bytes through untouched. Re-serialising parsed JSON changes them and every signature fails. - PayPal’s
paypal-cert-urlheader is pinned to*.paypal.combefore use. An attacker-supplied cert URL is a classic SSRF and spoofing vector.
Idempotency and out-of-order delivery
Providers deliver at least once and retry on any non-2xx. Every applied event id is recorded on the order (bounded to the last 50) and a repeat is a no-op.
Delivery order is not guaranteed either, so the decision table in
capture.ts is deliberately conservative:
| Situation | Outcome |
|---|---|
| Success, amount matches | capture → paid + processing |
| Success, amount or currency differs | reject, audit-logged as suspicious |
| Success on an already-paid order | ignore |
| Success arriving after a refund | ignore |
| Failure on an unpaid order | failed + cancelled (returns stock) |
| Failure arriving after a success | ignore — never cancel a paid order |
| Refund of a paid order | refunded (returns stock) |
| Refund of an order never paid | reject, audit-logged as suspicious |
| Anything unrecognised | ignore |
Ignoring a real event costs a support ticket. Acting on a misread one costs shipped goods or lost money — so anything ambiguous does nothing.
A verified-but-rejected event is logged to the audit trail as
payment.rejected. That is either a serious misconfiguration or an attack, and
it must never be silent.
Adding a provider
- Implement
PaymentProviderinsrc/lib/payments/<id>.ts:createSession(order, ctx)andverifyWebhook(rawBody, headers, ctx).verifyWebhookmust throwWebhookVerificationErroron any failure — returning'ignored'is for events that verified fine but are irrelevant. - Add it to
ALL_PROVIDERSinregistry.ts. - Declare
requiredEnv. Enablement, the admin report, and the checkout method gate are all driven from it.
Nothing else changes — the API, admin, and webhook route are all driven off that
list. Use ctx.fetch and ctx.now() rather than the globals so your provider
stays testable without network or clock access.
Testing
tests/payments.test.mjs (57 assertions) attacks the pure logic offline:
forged signatures, wrong secrets, tampered bodies, replayed and future-dated
timestamps, non-integer timestamps, secret rotation, underpayment and
overpayment, wrong currency, duplicate delivery, refunds of unpaid orders, and
half-configured providers.
The smoke suite runs the whole chain live on all three storage drivers. Stripe’s verification is a local HMAC with no network call, so the smoke server enables Stripe with a test secret and then proves, against a real order:
- unsigned, wrong-secret, tampered, and stale-timestamp webhooks are all
401and move nothing; - a correctly signed event for the wrong amount is rejected, not captured;
- a correctly signed, correctly priced event captures the payment and moves the
order to
processing; - re-delivering the same event id is a no-op;
- a late failure does not cancel the now-paid order.
What that leaves untested is session creation, which needs the providers’ live APIs. Hence the status note at the top.
Refunds
Admin-only, from the order detail view or the API:
GET /api/orders/{id}/refund # what is refundable, and what has been refunded
POST /api/orders/{id}/refund # { "amount_cents": 1250 } — omit for the full remainder
amount_cents is optional: omit it to refund everything still outstanding.
Partial refunds are supported, and a partial refund cannot quietly become a full
one — a second call is required to refund the remainder. Refunding is
deliberately admin-only and separate from order editing, because moving money is
a financial control and does not belong to the same role by default.
Refunds reported by the provider’s webhook are still recognised independently, so a refund issued from the provider’s dashboard reconciles correctly too.
Known limits
- No partial capture. An authorised amount that does not match the order exactly is rejected.
- Currency is per-order and hardcoded EUR at checkout (see COMMERCE.md).
- Stripe events outside the mapped set are ignored, including dispute/chargeback notifications. Watch those in Stripe.
- Klarna tax fields are zero. There is no tax model yet.