Naalya Handbook
Payments

Gateway Config

How a campus stores which gateways it uses and the encrypted credentials to talk to them.

A campus's gateway configuration is two things: which payment provider(s) a campus charges through, and the secret credentials (API keys, merchant IDs) needed to talk to each one. Both live in one row.

The entity

Stored in the campus_payment_gateway entity (apps/server/src/app/payment-gateway/config/entities/) — one row per (campus, provider), enforced by a @Unique(['campusId', 'provider']) constraint (a DB rule that blocks a second row for the same pair). The row is tenant-scoped: the @TenantScoped() decorator means queries see only the current school's rows.

apps/server/src/app/payment-gateway/config/entities/campus-payment-gateway.entity.ts
@Entity('campus_payment_gateway')
@TenantScoped()
@Unique(['campusId', 'provider'])
@WithTimestamps()
export class CampusPaymentGatewayEntity
  extends DatabaseEntity
  implements CampusPaymentGateway
{
  @Column({ name: 'campus_id', type: 'uuid' })
  campusId: string;

  @Column({ type: 'enum', enum: TransactionProvider })
  provider: TransactionProvider;

  // AES-256-GCM ciphertext (iv.tag.data, base64). Never plaintext.
  @Column({ type: 'text' })
  credentials: string;

  @Column({ type: 'enum', enum: GatewayStatus, default: GatewayStatus.ENABLED })
  status: GatewayStatus;

  @Column({ name: 'is_default', default: false })
  isDefault: boolean;

  @Column({ nullable: true })
  label?: string;
}
FieldMeaning
campusIdWhich campus this config belongs to.
providerThe gateway, a TransactionProvider enum value (not a foreign key).
credentialsThe encrypted credentials blob — ciphertext, never readable plaintext.
statusGatewayStatus (ENABLED / DISABLED); defaults to ENABLED. A DISABLED row can't be charged through.
isDefaultUse this gateway when a payment is created without naming a provider.
labelOptional human name shown in the admin UI.

Why not a join table

The second axis is the provider enum, not another table — so there's no payment_providers table to join to. It's modeled on per-tenant school_config (a settings-per-tenant pattern) rather than an _x_ join table (the project's convention for many-to-many links between two entities). One settings row per provider, keyed by enum.

Credentials at rest

The credentials column never holds plaintext. Encryption is handled by CredentialCipherService (config/credential-cipher.service.ts).

  • AES-256-GCM — authenticated symmetric encryption: one key both encrypts and decrypts, and any tampering with the stored bytes is detected on decrypt (the call throws instead of returning garbage).
  • A fresh random 96-bit IV (initialization vector — a one-time random value that makes each encryption unique) per blob, so encrypting the same credentials twice yields different ciphertext.
  • The key is SHA-256(PAYMENT_CREDENTIALS_ENC_KEY env) — the configured secret is hashed to a 32-byte key, so operators can set any string rather than an exact-length key.

On-disk format

Each stored blob is three base64 parts joined by dots: iv.tag.data.

<iv-base64>.<authTag-base64>.<ciphertext-base64>
apps/server/src/app/payment-gateway/config/credential-cipher.service.ts
encrypt(payload: Record<string, unknown>): string {
  const iv = randomBytes(CredentialCipherService.IV_BYTES); // 12 bytes = 96 bits
  const cipher = createCipheriv(CredentialCipherService.ALGORITHM, this.key, iv);
  const data = Buffer.concat([
    cipher.update(JSON.stringify(payload), 'utf8'),
    cipher.final(),
  ]);
  const authTag = cipher.getAuthTag(); // the GCM tamper-detection tag
  return [
    iv.toString('base64'),
    authTag.toString('base64'),
    data.toString('base64'),
  ].join('.');
}

decrypt() reverses this and throws if the blob is malformed or the authTag doesn't match (i.e. the bytes were altered).

Validate, then encrypt

When a campus configures a gateway, the service runs the provider's validateCredentials() (a check the provider adapter — the per-provider class that knows that gateway's shape — runs against the raw input) first, then encrypts and saves. Bad credentials never reach the database.

apps/server/src/app/payment-gateway/config/campus-payment-gateway.service.ts
this.providerRegistry.get(dto.provider).validateCredentials(dto.credentials);
// ...only after validation passes:
const data = {
  credentials: this.cipher.encrypt(dto.credentials),
  status: dto.status ?? GatewayStatus.ENABLED,
  isDefault: dto.isDefault ?? false,
  label: dto.label,
};

update() follows the same order: if new credentials are sent, validate against the existing provider, then re-encrypt.

Managing config

Staff manage these rows through /api/v1/payment-gateways (POST / GET / PATCH / DELETE), guarded by Resource.PAYMENT_GATEWAY. Secrets are write-only — reads return the masked form, never plaintext.

Each provider validates its own credential shape — SchoolPay takes { schoolCode, password }; Pesapal takes { consumerKey, consumerSecret, ipnId, environment, currency? }, where environment is sandbox or live and the base URL is derived from it (never typed). So going live means live keys plus a live-registered IPN.

Reading credentials back

Three internal resolvers, none exposes plaintext to a client:

  • resolveProvider(campusId, requested?) — picks which gateway to charge through: the requested one, else the campus isDefault, else the single enabled gateway; throws if it's ambiguous or none is configured.
  • resolveCredentials(campusId, provider) — decrypts a row's credentials for the adapter (register / parseCallback). Runs unscoped (outside tenant filtering) because it's invoked from public webhook handlers — inbound HTTP callbacks from the gateway — that carry no tenant context; the explicit campusId keeps the lookup precise. Throws if the row is DISABLED; returns undefined when there's no row yet (triggering the fallback below).
  • toMaskedDto() — what the staff read APIs return: decrypts only to mask each value (e.g. ••••4242), so the admin UI can confirm what's set without leaking secrets.

Legacy fallback is transition-only

When a campus has no config row, adapters fall back to legacy credentials — Pesapal to the global pesapal env namespace, SchoolPay to a hardcoded per-campus map. Seed real rows via the management API; the fallback is removed in a later cleanup.

Where to go next

On this page