feat: ( payment ) create payment microservice

This commit is contained in:
Abubeker Yasin
2026-06-11 15:25:24 +03:00
parent 3235567b41
commit 2b430f8e76
54 changed files with 2809 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
import { registerAs } from "@nestjs/config";
export default registerAs("app", () => ({
port: parseInt(process.env.PORT ?? "3003", 10),
/**
* Shared secret for service-to-service auth (apps -> /payments/*, payment -> mark-paid).
* Required in production; in development an empty value disables the guard with a warning.
* TODO: integrate @tria-plc IAM / mTLS as the long-term mechanism (docs/payment-service §14).
*/
serviceAuthToken: process.env.SERVICE_AUTH_TOKEN ?? "",
reconciliation: {
/** How often the stale-intent sweep runs. */
sweepIntervalMs: parseInt(
process.env.RECONCILE_SWEEP_INTERVAL_MS ?? "60000",
10,
),
/** An intent is "stale" when non-terminal and untouched for this long. */
staleAfterMs: parseInt(process.env.RECONCILE_STALE_AFTER_MS ?? "60000", 10),
batchSize: parseInt(process.env.RECONCILE_BATCH_SIZE ?? "20", 10),
},
}));

View File

@@ -0,0 +1,9 @@
import { registerAs } from "@nestjs/config";
export default registerAs("card", () => ({
baseUrl: process.env.CARD_BASE_URL || "",
apiKey: process.env.CARD_API_KEY || "",
webhookSecret: process.env.CARD_WEBHOOK_SECRET || "",
webhookUrl: process.env.CARD_WEBHOOK_URL || "",
returnUrl: process.env.CARD_RETURN_URL || "",
}));

View File

@@ -0,0 +1,9 @@
import { registerAs } from "@nestjs/config";
export default registerAs("cbe", () => ({
baseUrl: process.env.CBE_BASE_URL || "",
merchantId: process.env.CBE_MERCHANT_ID || "",
secretKey: process.env.CBE_SECRET_KEY || "",
notifyUrl: process.env.CBE_NOTIFY_URL || "",
returnUrl: process.env.CBE_RETURN_URL || "",
}));

View File

@@ -0,0 +1,36 @@
import { registerAs } from "@nestjs/config";
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
import { DataSourceOptions } from "typeorm";
/**
* Shared connection options for the Nest TypeORM module and the standalone DataSource
* (migration CLI). Payment tables live in the SAME Postgres database as the domain system
* (edr_database by default) but in the dedicated `edr_payment` schema; logical ownership is
* enforced with a dedicated DB user in non-dev environments (grants only on this schema).
*/
export function buildDataSourceOptions(): DataSourceOptions {
return {
type: "postgres",
host: process.env.DB_HOST ?? "localhost",
port: parseInt(process.env.DB_PORT ?? "5432", 10),
username: process.env.DB_USER ?? "edr",
password: process.env.DB_PASSWORD ?? "",
database: process.env.DB_NAME ?? "edr_database",
schema: process.env.DB_SCHEMA ?? "edr_payment",
entities: [__dirname + "/../**/*.entity.{ts,js}"],
migrations: [__dirname + "/../migrations/*.{ts,js}"],
// Schema changes go through migrations only — never synchronize (house rule).
synchronize: false,
logging: process.env.NODE_ENV === "development",
};
}
export default registerAs(
"database",
(): TypeOrmModuleOptions => ({
...buildDataSourceOptions(),
autoLoadEntities: true,
// Run pending migrations on boot (main.ts ensures the database/schema exist first).
migrationsRun: true,
}),
);

View File

@@ -0,0 +1,10 @@
import { registerAs } from "@nestjs/config";
export default registerAs("dmoney", () => ({
baseUrl: process.env.DMONEY_BASE_URL ?? "",
appId: process.env.DMONEY_APP_ID ?? "",
appSecret: process.env.DMONEY_APP_SECRET ?? "",
publicKey: process.env.DMONEY_PUBLIC_KEY ?? "",
privateKey: process.env.DMONEY_PRIVATE_KEY ?? "",
notifyUrl: process.env.DMONEY_NOTIFY_URL ?? "",
}));

View File

@@ -0,0 +1,9 @@
import { registerAs } from "@nestjs/config";
export default registerAs("ebirr", () => ({
baseUrl: process.env.EBIRR_BASE_URL || "",
merchantCode: process.env.EBIRR_MERCHANT_CODE || "",
secretKey: process.env.EBIRR_SECRET_KEY || "",
notifyUrl: process.env.EBIRR_NOTIFY_URL || "",
returnUrl: process.env.EBIRR_RETURN_URL || "",
}));

View File

@@ -0,0 +1,52 @@
import { Client } from "pg";
const IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
function connectionEnv() {
return {
host: process.env.DB_HOST ?? "localhost",
port: parseInt(process.env.DB_PORT ?? "5432", 10),
user: process.env.DB_USER ?? "edr",
password: process.env.DB_PASSWORD ?? "",
};
}
/**
* Dev/bootstrap convenience: make sure the `edr_payment` schema exists in the shared
* database before TypeORM initializes (the migrations table itself lives in the schema, so
* migrations cannot create it). In production the schema/grants are provisioned out-of-band
* by ops; this is then a no-op.
*/
export async function ensurePaymentSchema(): Promise<void> {
const database = process.env.DB_NAME ?? "edr_database";
const schema = process.env.DB_SCHEMA ?? "edr_payment";
if (!IDENTIFIER.test(database) || !IDENTIFIER.test(schema)) {
throw new Error(
`Invalid DB_NAME/DB_SCHEMA identifier: ${database}/${schema}`,
);
}
let client = new Client({ ...connectionEnv(), database });
try {
await client.connect();
} catch (err) {
// 3D000 = database does not exist — create it from the maintenance DB, then reconnect.
if ((err as { code?: string }).code !== "3D000") throw err;
await client.end().catch(() => undefined);
const admin = new Client({ ...connectionEnv(), database: "postgres" });
await admin.connect();
try {
await admin.query(`CREATE DATABASE "${database}"`);
} finally {
await admin.end();
}
client = new Client({ ...connectionEnv(), database });
await client.connect();
}
try {
await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
} finally {
await client.end();
}
}

View File

@@ -0,0 +1,15 @@
import { registerAs } from "@nestjs/config";
export default registerAs("notifier", () => ({
/** mark-paid callback URL per owning service (PaymentService discriminator routes here). */
passengerUrl:
process.env.PAYMENT_NOTIFY_PASSENGER_URL ??
"http://localhost:3002/internal/payments/mark-paid",
freightUrl:
process.env.PAYMENT_NOTIFY_FREIGHT_URL ??
"http://localhost:3001/internal/payments/mark-paid",
relayIntervalMs: parseInt(process.env.OUTBOX_RELAY_INTERVAL_MS ?? "5000", 10),
maxAttempts: parseInt(process.env.OUTBOX_MAX_ATTEMPTS ?? "10", 10),
httpTimeoutMs: parseInt(process.env.NOTIFY_HTTP_TIMEOUT_MS ?? "10000", 10),
relayBatchSize: parseInt(process.env.OUTBOX_RELAY_BATCH_SIZE ?? "20", 10),
}));

View File

@@ -0,0 +1,16 @@
import { registerAs } from "@nestjs/config";
export default registerAs("telebirr", () => ({
baseUrl: process.env.TELEBIRR_BASE_URL ?? "",
webBaseUrl: process.env.TELEBIRR_WEB_BASE_URL ?? "",
fabricAppId: process.env.TELEBIRR_FABRIC_APP_ID ?? "",
appSecret: process.env.TELEBIRR_APP_SECRET ?? "",
merchantAppId: process.env.TELEBIRR_MERCHANT_APP_ID ?? "",
merchantCode: process.env.TELEBIRR_MERCHANT_CODE ?? "",
notifyUrl: process.env.TELEBIRR_NOTIFY_URL ?? "",
returnUrl: process.env.TELEBIRR_RETURN_URL ?? "",
timeoutExpress: process.env.TELEBIRR_TIMEOUT_EXPRESS ?? "15m",
privateKey: process.env.TELEBIRR_PRIVATE_KEY ?? "",
publicKey: process.env.TELEBIRR_PUBLIC_KEY ?? "",
insecureTls: process.env.TELEBIRR_INSECURE_TLS === "true",
}));

View File

@@ -0,0 +1,27 @@
import { registerAs } from "@nestjs/config";
export default registerAs("waafi", () => ({
// `/asm` is appended in the provider; use sandbox by default, switch to
// https://api.waafipay.net in production.
baseUrl: process.env.WAAFI_BASE_URL ?? "https://sandbox.waafipay.net",
// HPP credentials (Hosted Payment Page family).
merchantUid: process.env.WAAFI_MERCHANT_UID ?? "",
storeId: process.env.WAAFI_STORE_ID ?? "",
hppKey: process.env.WAAFI_HPP_KEY ?? "",
// HMAC secret returned once by WEBHOOK_REGISTER; verifies inbound webhooks.
webhookSecret: process.env.WAAFI_WEBHOOK_SECRET ?? "",
// Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front.
paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? "MWALLET_ACCOUNT",
// Waafi has no ETB; when set this overrides the asserted currency (USD/DJF/SLSH).
currency: process.env.WAAFI_CURRENCY ?? "DJF",
// Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth).
successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? "",
failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? "",
// Callback data format: 1 = POST, 2 = GET, 4 = Result Token.
respDataFormat: Number(process.env.WAAFI_HPP_RESP_FORMAT ?? "1"),
// Registered webhook URL (reference only; registration is performed out-of-band).
notifyUrl: process.env.WAAFI_NOTIFY_URL ?? "",
// DEV ONLY: disable TLS cert verification. The Waafi sandbox serves a *.waafi.com cert that
// does not match sandbox.waafipay.net (ERR_TLS_CERT_ALTNAME_INVALID). Never enable in prod.
insecureTls: process.env.WAAFI_INSECURE_TLS === "true",
}));