AstroBaaS

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

  1. POST /api/orders — checkout. Prices computed server-side, stock reserved atomically, order created pending / unpaid.
  2. POST /api/payments/start with order_number, email, provider → returns redirect_url. Order becomes pending payment.
  3. Buyer pays on the provider’s page.
  4. 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

ProviderStyleWhy
StripeSigned payloadHMAC-SHA256 over t.rawBody, constant-time compare, 5-minute replay window
PayPalVerify API + fetch-backVerified via PayPal’s own endpoint, then the order is re-read from the API — money is taken from that answer, not the notification
KlarnaFetch-back onlyKlarna’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-url header is pinned to *.paypal.com before 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:

SituationOutcome
Success, amount matchescapture → paid + processing
Success, amount or currency differsreject, audit-logged as suspicious
Success on an already-paid orderignore
Success arriving after a refundignore
Failure on an unpaid orderfailed + cancelled (returns stock)
Failure arriving after a successignore — never cancel a paid order
Refund of a paid orderrefunded (returns stock)
Refund of an order never paidreject, audit-logged as suspicious
Anything unrecognisedignore

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

  1. Implement PaymentProvider in src/lib/payments/<id>.ts: createSession(order, ctx) and verifyWebhook(rawBody, headers, ctx). verifyWebhook must throw WebhookVerificationError on any failure — returning 'ignored' is for events that verified fine but are irrelevant.
  2. Add it to ALL_PROVIDERS in registry.ts.
  3. 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 401 and 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.