alepha@docs:~/docs/guides/payments$
cat 1-overview.md | pretty
3 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 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:

typescript
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

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 — 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 $secure middleware; 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 in processing for 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:

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

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}

#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). Higher amounts throw a PaymentError.

#Refunds

Refunds support partial amounts and multiple refunds against the same intent:

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

typescript
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);