#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"
Other modules (subscriptions, accounting, notifications) listen via $hook — they never call the PSP directly.
#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 daily cron (
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 { t } 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: t.object({ productId: t.uuid() }),16 response: t.object({ url: t.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);