#Payments
Alepha provides a provider-agnostic payments layer through alepha/api/payments. The framework owns the data model and lifecycle (intents, captures, refunds, payment methods); concrete payment service providers (PSPs) like Stripe or Mollie plug in via the PaymentProvider abstract class.
The same application code works against any provider - swap implementations without touching controllers, services, or hooks.
#The model
Three entities anchor the data:
| Entity | Role |
|---|---|
paymentIntents |
The unit of value transfer. Tracks status across the lifecycle: created → processing → authorized → captured → refunded (with branches for partially_refunded, failed, voided, cancelled, expired). |
paymentMethods |
Saved cards / mandates tied to a user, with provider reference + masked metadata (brand, last4, expMonth, expYear). |
refunds |
Per-refund records linked to a captured intent. Supports partial and multi-step refunds. |
An intent moves through these statuses. Solid arrows are the normal path; dotted ones are a late webhook arriving after the intent was given up on:
recordCashPayment() creates an intent directly in captured. Currencies are stored lowercased ("EUR" becomes "eur").
Every state transition emits a hook on Alepha's event bus:
| Hook | Fires when |
|---|---|
payments:authorized |
The PSP holds the funds (authorize: true sessions). |
payments:captured |
The money is taken: webhook, capture(), or recordCashPayment(). |
payments:failed |
The PSP reports a failure or the buyer cancelled; the PSP session is closed. |
payments:voided |
void() released an authorization. |
payments:cancelled |
cancel() abandoned an intent still in created. |
payments:expired |
The stale-intent sweep gave up on an intent stuck in processing. |
payments:refunded |
A refund went through; fires once per refund. |
Every payload carries { intentId, amount, currency, metadata }. metadata is the intent's: what you passed to createIntent(), plus the checkoutUrl that createSession() records. payments:refunded adds refundId, sets amount to what this refund gave back, and adds refundedTotal, the sum of every refund on the intent. A refund event can be delivered twice, so a listener keeping a running total should set it from refundedTotal, not add amount.
payments:expired is emitted by the stale-intent sweep described below - wire it if your fulfilment or notification code needs to release a reservation when a checkout is abandoned.
An expiry is not always the last word. If the buyer pays on a PSP page that outlived it (the sweep raced the payment, or its call to close the session failed), the intent moves on to captured (or authorized) and the usual payments:captured fires. The same holds for a failed intent: a declined card can be retried on the same PSP page and succeed. Your payments:captured listener must therefore handle a capture for something it already released: record it, then refund it with PaymentService.refund() or keep it, but never ignore it.
Your own modules (accounting, notifications, fulfilment) listen via $hook - they never call the PSP directly.
Recurring billing is deliberately out of scope: let your PSP own it. Create the subscription with the provider (e.g. a Stripe Checkout in
mode: "subscription"), then reconcile its status from webhooks into whatever field gates access in your app. Charging on your own cron means holding cards, reimplementing dunning, and diverging from the PSP's source of truth.
#Registering the module
1import { Alepha } from "alepha";2import { AlephaApiPayments } from "alepha/api/payments";3 4const alepha = Alepha.create().with(AlephaApiPayments);
Out of the box this gives you:
POST /api/payments/checkout: body{ intentId, returnUrl, authorize? }, returns{ url, intentId }. It opens a PSP session for an intent your app already created (the amount must come from your server, never from the browser), and refuses an intent that belongs to another user. Requires a signed-in user.GET/POST/DELETE/PATCH /api/payments/payment-methods/...: list, add, remove, set default, plusPOST .../setup(open a PSP setup session to save a card without paying) andPOST .../reconcile(record the card that setup session saved).POST /api/payments/webhook: PSP webhook ingress (no$securemiddleware; the provider verifies authenticity)./api/admin/payments/...: list and read intents (permissionadmin:payment:read); capture, void, refund, cancel and record cash payments (admin:payment:write).- A cron running every 15 minutes (
system.payments.expire-stale-intents) that looks at intents stuck inprocessingfor more than 30 minutes. It asks the PSP first (syncIntent()), so a payment whose webhook was lost still settles, and expires only the intents the PSP does not know as paid.
Change the sweep's cadence through the paymentsConfig atom, before the module is wired:
1import { Alepha } from "alepha";2import { AlephaApiPayments, paymentsConfig } from "alepha/api/payments";3 4const alepha = Alepha.create();5alepha.store.mut(paymentsConfig, (config) => ({6 ...config,7 expireStaleIntentsCron: "*/5 * * * *",8}));9alepha.with(AlephaApiPayments);
AlephaApiPayments registers MemoryPaymentProvider as the default provider - you can boot the module with no PSP configured and exercise the full flow end-to-end via the mock checkout page (see Local development). The mock pages answer only while MemoryPaymentProvider is the provider, and refuse in production unless mockCheckoutOptions.allowInProduction is set.
⚠️ Never ship MemoryPaymentProvider to production. Its confirm page marks an intent as paid without any payment, and its webhook parser trusts any JSON body ({ providerRef, status }) with no signature. Without a real PSP, POST /api/payments/webhook lets anyone move an intent to captured.
#Creating a payment
The high-level service is PaymentService. A typical "buy a one-off thing" flow:
1import { $inject, z } from "alepha"; 2import { PaymentService } from "alepha/api/payments"; 3import { $repository } from "alepha/orm"; 4import { $secure } from "alepha/security"; 5import { $action } from "alepha/server"; 6 7import { productEntity } from "./entities/product.ts"; 8 9class CheckoutController {10 protected readonly payments = $inject(PaymentService);11 protected readonly products = $repository(productEntity);12 13 buy = $action({14 method: "POST",15 path: "/checkout",16 use: [$secure()],17 schema: {18 body: z.object({ productId: z.uuid() }),19 response: z.object({ url: z.text() }),20 },21 handler: async ({ body, user }) => {22 const product = await this.products.getById(body.productId);23 24 const intent = await this.payments.createIntent(25 product.priceCents,26 product.currency,27 { productId: product.id },28 { userId: user.id },29 );30 31 const session = await this.payments.createSession(32 intent.id,33 "https://app.example.com/orders/success",34 );35 36 return { url: session.url };37 },38 });39}
Amounts are integers in the currency's minor unit (cents). Passing userId to createIntent() ties the intent to its buyer: createSession() refuses it when called with another userId, as the built-in checkout endpoint does with the signed-in user. createSession(intentId, returnUrl, authorize?, userId?, options?) takes, in options, label (what the payer sees on the PSP page), customerEmail (pre-fills the payer's email), saveCard (store the card as one of the user's payment methods once the payment captures), idempotencyKey, and for Stripe Connect stripeAccount and applicationFeeAmount.
Then react to the captured payment to fulfil the order:
1import { $hook } from "alepha"; 2 3class OrderFulfillment { 4 protected readonly onPaid = $hook({ 5 on: "payments:captured", 6 handler: async (event) => { 7 const productId = (event.metadata as any)?.productId; 8 if (!productId) return; 9 await this.fulfill(productId, event.intentId);10 },11 });12}
The whole round trip, with the hook as the only place the payment is confirmed:
Do not fulfil on the redirect to returnUrl: the buyer can land there before the webhook arrives, or reach it without paying. Show a "payment pending" page there and let the hook do the work.
#Authorize then capture
Pass authorize: true when creating the session to hold funds without capturing immediately. Useful for marketplace flows where the final amount isn't known up front:
1await this.payments.createSession(intent.id, returnUrl, true /* authorize */);2// ... later ...3await this.payments.capture(intent.id, finalAmountCents);
capture() accepts an amount lower than the authorized amount (partial capture), and captures the full amount when you omit it. Higher amounts throw a PaymentError.
void(intentId) releases an authorization instead of capturing it, and cancel(intentId) abandons an intent still in created (before a session was opened for it). Every method checks the intent's current status first and throws a PaymentError on a transition that is not allowed.
#Embedded card fields
A redirect to the PSP's hosted page works with every provider. When the provider also has an embeddable card field (Stripe's Payment Element), PaymentService.supportsEmbeddedPayment() returns true and createElementSession(intentId) returns { clientSecret, publishableKey, provider, providerRef, intentId } for the browser SDK, keeping the payer on your own domain. The payment then settles through the same webhook and hooks as a redirect session.
#Refunds
Refunds support partial amounts and multiple refunds against the same intent, from captured or partially_refunded. A refund larger than what remains refundable throws a PaymentError.
1const refund = await this.payments.refund(intentId, 500, "Customer dispute");2// intent.status becomes "partially_refunded" until the full amount is reached.
#Cash / offline payments
Skip the PSP entirely for in-person sales:
1await this.payments.recordCashPayment(2500, "EUR", { invoice: "INV-001" });2// Creates an intent already in "captured" state and emits payments:captured.
#Local development
With no provider configured, the MemoryPaymentProvider is wired in. createSession returns a URL to the bundled mock checkout page, /payments/mock-checkout/:intentId (a plain page, outside /api). Its buttons drive the same code path a real webhook would, then send the buyer back to returnUrl with a booking query parameter:
| Button | Intent becomes | Hook | Redirect |
|---|---|---|---|
| Confirm | captured |
payments:captured |
returnUrl?booking=success |
| Defer | stays processing |
none (the sweep expires it later) | returnUrl?booking=pending |
| Cancel | failed |
payments:failed |
returnUrl?booking=cancel |
Confirm always captures, even for an authorize: true session. Card setup sessions get a matching page at /payments/mock-setup/:id.
In tests, skip the page and drive the transition yourself with handleWebhookEvent(), which is what the page calls. MemoryPaymentProvider is the module's default, so injecting it returns the provider in use:
1import { Alepha } from "alepha"; 2import { 3 AlephaApiPayments, 4 MemoryPaymentProvider, 5 PaymentService, 6} from "alepha/api/payments"; 7import { describe, expect, it } from "vitest"; 8 9describe("checkout", () => {10 it("captures, then refunds part of it", async () => {11 const alepha = Alepha.create().with(AlephaApiPayments);12 const payments = alepha.inject(PaymentService);13 const provider = alepha.inject(MemoryPaymentProvider);14 await alepha.start();15 16 const intent = await payments.createIntent(2500, "EUR");17 await payments.createSession(intent.id, "/orders/done");18 await payments.handleWebhookEvent(intent.id, "captured");19 expect((await payments.getIntent(intent.id)).status).toBe("captured");20 21 await payments.refund(intent.id, 500);22 expect((await payments.getIntent(intent.id)).status).toBe(23 "partially_refunded",24 );25 expect(provider.getRefunds()).toHaveLength(1);26 });27});
Besides getCharges() and getRefunds(), the provider answers wasCharged(providerRef), wasRefunded(providerRef) and wasExpired(providerRef). wasCharged reflects what the mock session was opened as, not the intent: it is already true once a non-authorize session exists, so assert on the intent status for the outcome. retrieveSessionStatus() always returns null on the memory provider, so the sweep expires every intent left in processing.