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"

Other modules (subscriptions, accounting, notifications) listen via $hook — they never call the PSP directly.

#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 daily cron (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 { 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:

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