alepha@docs:~/framework/docs/guides/features$
cat 5-payments.md | pretty
8 min read
Last commit:

#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:

Flowchart diagramcreateSessioncancelwebhook or sweepwebhook or sweepwebhook or sweepsweepcapture or webhookvoidwebhookrefundfull refundrefundlate webhooklate webhooklate webhooklate webhookcreatedprocessingcancelledauthorizedcapturedfailedexpiredvoidedpartially_refundedrefunded

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

typescript
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, plus POST .../setup (open a PSP setup session to save a card without paying) and POST .../reconcile (record the card that setup session saved).
  • POST /api/payments/webhook: PSP webhook ingress (no $secure middleware; the provider verifies authenticity).
  • /api/admin/payments/...: list and read intents (permission admin: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 in processing for 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:

typescript
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:

typescript
 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:

typescript
 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:

Sequence diagramopt[no webhook after 30 minutes]BuyerBuyerYour controllerYour controllerPaymentServicePaymentServicePSPPSPYour hooksYour hooksPOST /checkoutcreateIntent(), then createSession()open a hosted checkout sessionurl, providerRefredirect to the PSP pagepaysredirect to returnUrlPOST /api/payments/webhookpayments:capturedsweep asks for the session statusthe hook for what the PSP reports,else payments:expiredintent is processingparseWebhook verifies it,handleWebhookEvent moves theintent

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:

typescript
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.

typescript
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:

typescript
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:

typescript
 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.