#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 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. |
Every state transition emits a hook on Alepha's event bus:
1"payments:authorized" | "payments:captured" | "payments:failed";2"payments:voided" | "payments:refunded" | "payments:cancelled";3("payments:expired");
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. 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: create a checkout session, returns redirect URL.GET/POST/DELETE/PATCH /api/payments/payment-methods/...: list, add, remove, set default.POST /api/payments/webhook: PSP webhook ingress (no$securemiddleware; the provider verifies authenticity)./api/admin/payments/...: capture, void, refund, cancel, list intents, record cash payments.- A cron running every 15 minutes (
system.payments.expire-stale-intents, configurable via thepaymentsConfigatom'sexpireStaleIntentsCron) that expires intents stuck inprocessingfor more than 30 minutes.
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 at /payments/mock-checkout/:id. The page is gated on MemoryPaymentProvider outside production; mockCheckoutOptions.allowInProduction is the documented escape hatch if you truly need it live.
#Creating a payment
The high-level service is PaymentService. A typical "buy a one-off thing" flow:
1import { $inject, z } from "alepha"; 2import { $repository } from "alepha/orm"; 3import { $action } from "alepha/server"; 4import { $secure } from "alepha/security"; 5import { PaymentService } from "alepha/api/payments"; 6import { productEntity } from "./entities/product.ts"; 7 8class CheckoutController { 9 protected readonly payments = $inject(PaymentService);10 protected readonly products = $repository(productEntity);11 12 buy = $action({13 method: "POST",14 path: "/checkout",15 use: [$secure()],16 schema: {17 body: z.object({ productId: z.uuid() }),18 response: z.object({ url: z.text() }),19 },20 handler: async ({ body, user }) => {21 const product = await this.products.getById(body.productId);22 23 const intent = await this.payments.createIntent(24 product.priceCents,25 product.currency,26 { productId: product.id },27 { userId: user.id },28 );29 30 const session = await this.payments.createSession(31 intent.id,32 "https://app.example.com/orders/success",33 );34 35 return { url: session.url };36 },37 });38}
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}
#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). Higher amounts throw a PaymentError.
#Refunds
Refunds support partial amounts and multiple refunds against the same intent:
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 where you can confirm or cancel the payment manually - both cases drive the same hooks the real PSP would trigger.
In tests, inject a fresh memory provider and assert against its in-memory state:
1import { 2 AlephaApiPayments, 3 MemoryPaymentProvider, 4 PaymentProvider, 5} from "alepha/api/payments"; 6 7const alepha = Alepha.create().with(AlephaApiPayments).with({ 8 provide: PaymentProvider, 9 use: MemoryPaymentProvider,10});11 12const provider = alepha.inject(MemoryPaymentProvider);13expect(provider.wasCharged(intent.providerRef)).toBe(true);