#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"
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 5 minutes (
api:payments:expireStaleIntents) 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 dev-only mock checkout page at /payments/mock-checkout/:id.
#Creating a payment
The high-level service is PaymentService. A typical "buy a one-off thing" flow:
1import { $inject } from "alepha"; 2import { $action } from "alepha/server"; 3import { $secure } from "alepha/security"; 4import { PaymentService } from "alepha/api/payments"; 5import { z } from "alepha"; 6 7class CheckoutController { 8 protected readonly payments = $inject(PaymentService); 9 10 buy = $action({11 method: "POST",12 path: "/checkout",13 use: [$secure()],14 schema: {15 body: z.object({ productId: z.uuid() }),16 response: z.object({ url: z.text() }),17 },18 handler: async ({ body, user }) => {19 const product = await this.products.getById(body.productId);20 21 const intent = await this.payments.createIntent(22 product.priceCents,23 product.currency,24 { productId: product.id },25 { userId: user.id },26 );27 28 const session = await this.payments.createSession(29 intent.id,30 "https://app.example.com/orders/success",31 );32 33 return { url: session.url };34 },35 });36}
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 { MemoryPaymentProvider, PaymentProvider } from "alepha/api/payments";2 3const alepha = Alepha.create().with({4 provide: PaymentProvider,5 use: MemoryPaymentProvider,6});7 8const provider = alepha.inject(MemoryPaymentProvider);9expect(provider.wasCharged(intent.providerRef)).toBe(true);