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 all three hold: it is listed in
PAYMENTS_ENABLED, every one of its requiredEnv variables is present and
non-empty, and its optional validateEnv() finds nothing wrong with the values
that ARE set. 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. Missing variables and faulty ones are
reported separately, because “go and set this” and “what you set is wrong” send
an admin to different places. Names only, never values: that report is rendered
in the admin and would otherwise be a neat way to exfiltrate a secret through a
screenshot.
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.
PayPal: approve, then capture
PayPal orders are created with intent=CAPTURE. In that flow the buyer’s
approval moves no money: the shop has to capture the approved order. The
flow is:
-
PayPal sends
CHECKOUT.ORDER.APPROVED. It is verified and read back, and the order reads asAPPROVED: an approval, not a payment. -
The approved amount is checked against the order. A mismatch is rejected as suspicious, and nothing is captured.
-
The order must still be able to ship:
payment_statusalreadypaidorrefunded: ignored, nothing captured;- open (
pending,on-hold, …): capture; cancelledby the payment hold or the abandonment sweep: take the stock back first (reopen topending). Only if that works, capture;cancelledby staff, or cancelled with its stock sold to someone else: not captured. The approval lapses at PayPal, no money is taken, andpayment.approval_not_capturedrecords why;refunded— the order status, not just the payment one — is not captured either. Reopening is only ever attempted fromcancelled, so any stock-releasing status that is notcancelledends the same way, withpayment.approval_not_captured.cancelledandrefundedare the two (STOCK_RELEASED_STATUSES).
What tells the two cancellations apart is
cancelled_reason. It is written in the same step as the status move, and a move into or out ofcancelled/refundedreplaces it. A sweep’s cancel carries its reason; any other cancel, or a reopen, removes it. A reason can therefore never outlive the cancellation it describes, and an order the hold cancelled, staff reopened and staff cancelled again reads as staff’s. Putting an order back after a failed capture restores the reason it had, so PayPal’s redelivery can still be captured. -
While the capture runs,
payment_capture_started_atkeeps the hold sweep off the order (5-minute lease). -
POST /v2/checkout/orders/{id}/captureis sent with body{}andPayPal-Request-Id: astrobaas-capture-<our id>-<PayPal id>. Two deliveries of one approval, or a retry after a timeout, capture once. -
The capture response decides.
COMPLETEDwith the captured amount is applied as a normal payment, including the exact-amount check.ORDER_ALREADY_CAPTUREDis answered by reading the order again. Any other refusal (INSTRUMENT_DECLINED, …) is a counted declined attempt. A PayPal outage (5xx, network) answers the webhook500, so PayPal redelivers it. -
If nothing was captured, an order reopened in step 3 is cancelled again and its stock returned.
payment.capture_failedis audited.
A capture PayPal holds as PENDING (eCheck, review) is not money yet. The hold
may release that order meanwhile; when PayPal later completes the capture, it
arrives as a late payment and the order is reopened, or flagged needs_refund.
Before this change nothing captured PayPal approvals. An approved order read back as “ignored”, was never paid, and was then cancelled by the payment hold.
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, then capture | Verified via PayPal’s own endpoint, then the order is re-read from the API — money is taken from that answer, not the notification. An approved order is captured, and the capture response is the final word (see “PayPal: approve, then capture”) |
| 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.
Forged posts are throttled before they cost anything. Verification failures
are counted per provider and client address. After 20 in 10 minutes, the
address gets 429 with Retry-After, before anything is verified, so a
flood of forged PayPal or Klarna posts stops causing outbound calls. Only
failures count; a delivery that verifies is never throttled. Loopback, private
and unknown addresses are never throttled. Behind a proxy that is not
trusted, every provider looks like one of those, and throttling would let 20
forged posts shut out the real provider. Set TRUST_PROXY=1. Failures are
audited in aggregate: the first per window, and the moment the address is
throttled, with counts. A misconfigured webhook secret makes real deliveries
fail as well, and they are throttled the same way. Once the secret is fixed,
the provider’s retries get through after the window.
PayPal OAuth tokens are reused until a minute before their expires_in.
They used to be fetched for every operation, including every inbound webhook.
The cache is keyed by environment and a hash of the credentials, and a token
PayPal refuses (401) is dropped.
Session expiry and the payment hold
An unpaid order paid through a provider is cancelled when the shop’s payment
hold runs out (orders_payment_hold_minutes, default 120; see COMMERCE.md).
The provider page should close at the same time:
| Provider | Expiry |
|---|---|
| Stripe | expires_at is set to the end of the hold, clamped to Stripe’s 30 min – 24 h window, and stored on the order (payment_expires_at). The hold sweep waits for it, plus 5 minutes. checkout.session.expired then cancels the order as a failure. |
| PayPal | Orders v2 orders stay payable for 3 h by default, and PayPal alone can extend that. There is no expiry parameter to send. |
| Klarna HPP | The payment session lives 48 h and the hosted page closes 1 h before that. There is no expiry parameter. |
A payment that arrives after the hold is handled as the table above describes.
Once the hold is over, POST /api/payments/start refuses to open a new session
(409 payment.window_closed).
Return URLs (success_url, cancel_url, PayPal return_url, Klarna
merchant_urls) are built from the configured Site URL, then SITE_URL. The
request’s own origin is used only when neither is set, so a forged Host
header can no longer set where a real provider page sends the buyer.
The CMS serves no /checkout/success or /checkout/cancelled page; the
storefront does. Set Site URL to the storefront’s address. When an online
provider is enabled, /api/health/deep reports a payment_return_urls
warning (never a failure), and it is shown under the Site URL field in
Settings and as an “Unfinished” card on the dashboard, if:
- Site URL is empty;
- or buyers would be sent to the CMS’s own address:
public_site_url, or the host the CMS answered the check on.
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.
The event is claimed atomically (Storage.claimPaymentEvent): its id is
appended and the payment status written in one step, only if the id is not
already on the order AND the payment status is still the one the decision was
taken on. Two deliveries of one event arriving together apply it once. A
failure decided on a stale read can no longer write failed over a paid that
landed in between: it finds the status changed, decides again, and ignores
itself. On the relational driver the claim is one conditional UPDATE, so it
also holds across processes.
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 |
| Success on an order that was already cancelled (payment hold, abandonment, staff) | capture, then reopen and re-take the stock. If the stock is gone, the order stays cancelled, is marked needs_refund, audited payment.needs_refund, and the owner is emailed |
| Failure on an unpaid order (the session expired or failed) | failed + cancelled (returns stock) |
One declined card (Stripe payment_intent.payment_failed) | decline: counted on the order (payment_declines), nothing else changes, and the buyer can retry on the same page. Five declines flag the order card_testing |
| 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
The contract is the same whichever route you take:
- Implement
PaymentProvider:createSession(order, ctx)andverifyWebhook(rawBody, headers, ctx).verifyWebhookmust throwWebhookVerificationErroron any failure — returning'ignored'is for events that verified fine but are irrelevant. - Declare
requiredEnv, and optionallyvalidateEnv(env)for faults in values that are set. Enablement, the admin report, and the checkout method gate are all driven from those two. - Use
ctx.fetchandctx.now()rather than the globals, so your provider stays testable without network or clock access.
From a plugin — no fork required. Return your provider from the
payment_providers filter; the plugin bootstrap collects what every active
plugin offers and calls setPluginProviders() once. Nothing else changes: the
API, the admin, the webhook route and the payment hold all read
allProviders(), so a plugin gateway is offered, reported and held exactly like
a built-in one. An id that already exists is refused with an error and the
original stands — a provider silently replacing stripe would reroute live
payments, which has to fail loudly rather than be resolved by array order. A
method that takes no credentials and calls no API is not a provider; use
manual_methods for that.
In core — one file and one line. Add src/lib/payments/<id>.ts and append
it to BUILT_IN_PROVIDERS in registry.ts. ALL_PROVIDERS is still exported
but is @deprecated: it is a constant, so it cannot see plugin providers. Read
allProviders().
Testing
tests/payments.test.mjs (109 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.
A refund is appended atomically (Storage.appendRefund), and the payment
status is computed from the refunds stored on the order. Before, two partial
refunds sent at once each wrote their own copy of the list and the second
erased the first. A double-clicked refund, which the provider answers with the
same refund twice, is now recorded and audited once. Two DIFFERENT partial
refunds planned at the same instant can still both reach the provider; its own
check against the captured amount refuses any excess.
A full refund of an order flagged needs_refund clears the flag. The order
stays cancelled.
Known limits
- No partial capture. An authorised amount that does not match the order exactly is rejected.
- Settlement currency is not reconciled. An order is priced in the shop’s
shop_currency, or in an enabled presentment currency at the operator-entered rate, and that code is frozen onto the order. Every provider session is created inorder.currency— Stripe’sprice_data[currency], PayPal’scurrency_code, Klarna’spurchase_currency— and a webhook whose currency differs from the order’s is rejected rather than captured. What the acquirer actually converts and settles at is its own rate, which this system never sees:base_total_centsis an indicative accounting figure, not a settlement. See COMMERCE.md. - Stripe events outside the mapped set are ignored, including dispute/chargeback notifications. Watch those in Stripe.
- Klarna’s tax fields are sent as zero. The order’s tax IS computed (see
COMMERCE.md), but it is not itemised into the Klarna session:
total_tax_amountandtax_ratego out as0because Klarna requires the fields to be present. The amount charged is the order’s own total.