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.
@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;
}| Field | Meaning |
|---|---|
campusId | Which campus this config belongs to. |
provider | The gateway, a TransactionProvider enum value (not a foreign key). |
credentials | The encrypted credentials blob — ciphertext, never readable plaintext. |
status | GatewayStatus (ENABLED / DISABLED); defaults to ENABLED. A DISABLED row can't be charged through. |
isDefault | Use this gateway when a payment is created without naming a provider. |
label | Optional 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>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.
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: therequestedone, else the campusisDefault, 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 explicitcampusIdkeeps the lookup precise. Throws if the row isDISABLED; returnsundefinedwhen 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
Gateways
The provider contract — adapters, the registry, validateCredentials and register.
Secrets & Credentials
The wider rules for handling secrets and encryption keys.
Multi-Tenancy
How TenantScoped filters rows and what runUnscoped bypasses.
Payments Overview
How config fits the rest of the payments domain.