Naalya Handbook
Payments

Gateways

What a payment gateway is, the provider contract every one implements, and how to register a new one.

A gateway is a payment provider — an external service that actually moves money. Today: Pesapal and SchoolPay; Stripe and Flutterwave are the planned next ones.

In code each gateway is an adapter — a class that implements one shared interface, PaymentProvider, in apps/server/src/app/payment-gateway/_contract/payment-provider.interface.ts. The adapter holds every provider-specific quirk (its URLs, its payload shapes, its signature format) so the rest of the payment pipeline stays provider-agnostic.

The provider contract

A contract here is just a TypeScript interface — a list of methods every adapter must supply. One clause per member:

MemberWhat it does
idThe provider's TransactionProvider enum value (e.g. PESAPAL, SCHOOL_PAY) — the key the registry looks it up by.
register(ctx)Tell the provider a payment is coming: reserve an external reference, or return a hosted redirectUrl the payer is sent to.
requestInstantPayment?(ctx)Optional — trigger an instant charge (e.g. SchoolPay push-to-phone). The ? means not every provider supplies it.
extractCallbackReference(payload, headers)Pull the transaction reference out of a raw webhook without using credentials.
parseCallback(payload, headers, credentials)Turn a webhook into the canonical CallbackOutcome and verify the provider's signature.
validateCredentials(raw)Throw 400 if a credentials config is malformed; runs before the config is saved.

A webhook is a provider-to-us HTTP callback — the gateway POSTs to our server when a payment settles, instead of us polling. extractCallbackReference runs first and without credentials so the service can find the transaction (and therefore its owning campus), then resolve that campus's credentials, then call parseCallback. Per-campus credentials are their own topic — see Gateway Config.

apps/server/src/app/payment-gateway/_contract/payment-provider.interface.ts
interface PaymentProvider {
  readonly id: TransactionProvider;

  register(ctx: RegisterContext): Promise<RegisterResult>;

  requestInstantPayment?(
    ctx: InstantPaymentContext,
  ): Promise<InstantPaymentResult>;

  extractCallbackReference(
    payload: unknown,
    headers?: Record<string, string>,
  ): string | null;

  parseCallback(
    payload: unknown,
    headers?: Record<string, string>,
    credentials?: GatewayCredentials,
  ): Promise<CallbackOutcome> | CallbackOutcome;

  validateCredentials(raw: GatewayCredentials): void;
}

Adapters never touch TransactionService

An adapter only implements the contract above — it never imports or calls TransactionService. The service drives the adapter, not the other way round, so a buggy gateway can't reach into the transaction lifecycle.

The registry

PaymentProviderRegistry collects every adapter into a Map<id, provider> — a lookup keyed by TransactionProvider id — and resolves the right one on demand. TransactionService asks the registry for an adapter; it never references a concrete provider class.

apps/server/src/app/payment-gateway/_contract/payment-provider.registry.ts
get(id: TransactionProvider): PaymentProvider {
  const provider = this.providers.get(id);
  if (!provider) {
    throw new BadRequestException(`Unsupported payment provider: ${id}`);
  }
  return provider;
}

The registry receives the adapters through PAYMENT_PROVIDERS — a DI token (a key NestJS's dependency-injection container uses to supply a value at construction time). It's a multi-provider array: many adapters provided under one token, injected as ReadonlyArray<PaymentProvider>.

Step 1: Add the enum id

Add the provider to the TransactionProvider enum (apps/server/src/app/transaction/dto/transaction.types.ts). This becomes the adapter's id. Generate a migration to widen the transaction_provider_enum column so the database accepts the new value.

Step 2: Write the adapter

Create a class that implements PaymentProvider. Set readonly id to the new enum value and implement every required method. Model the module on pesapal/ or school-pay/ — each gateway lives in its own submodule (its HTTP client, its adapter, its DTOs) and exports the adapter.

apps/server/src/app/payment-gateway/school-pay/school-pay.provider.ts
@Injectable()
export class SchoolPayProvider implements PaymentProvider {
  readonly id = TransactionProvider.SCHOOL_PAY;
  // register / parseCallback / validateCredentials …
}

Step 3: Provide it under the token

Add the adapter to the PAYMENT_PROVIDERS factory in apps/server/src/app/payment-gateway/payment-gateway.module.ts. Import its submodule, then return the adapter in the array:

apps/server/src/app/payment-gateway/payment-gateway.module.ts
{
  provide: PAYMENT_PROVIDERS,
  useFactory: (
    schoolPay: SchoolPayProvider,
    pesapal: PesapalProvider,
  ): ReadonlyArray<PaymentProvider> => [schoolPay, pesapal],
  inject: [SchoolPayProvider, PesapalProvider],
},

That's it. The registry rebuilds its map from the array at startup, so no change to the registry or TransactionService is needed — the new gateway resolves through registry.get(id) like every other.

Where to go next

On this page