Merge branch 'feat/payment-microservice' into alpha

This commit is contained in:
Abubeker Yasin
2026-06-16 11:02:41 +03:00
14 changed files with 876 additions and 629 deletions

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "PaymentMethodType" ADD VALUE 'DMONEY';

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,8 @@ export enum PaymentMethodTypeEnum {
TELEBIRR = "TELEBIRR", // Ethiopia
CBE_BIRR = "CBE_BIRR", // Ethiopia
EBIRR = "EBIRR", // Ethiopia
WAAFI = "WAAFI", // Djibouti
WAAFI = "WAAFI",
DMONEY= "DMONEY",// Djibouti
CARD = "CARD", // International
WALLET = "WALLET", // Internal
}

View File

@@ -43,6 +43,14 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
/**
* DEMO ONLY: when true, a WALLET "payment" is treated as instantly successful — the wallet
* balance check and debit are skipped and the booking is confirmed + ticket issued as if fully
* paid. Lets the happy-path be demoed while a real provider (e.g. Telebirr) is unavailable.
* Never enable in production. Toggle with WALLET_DEMO_AUTO_SUCCEED in the env.
*/
private readonly walletDemoAutoSucceed = true;
constructor(
private prisma: PrismaService,
private seatsService: SeatsService,
@@ -196,6 +204,35 @@ export class PaymentsService {
private async initiateWalletPayment(
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
): Promise<InitiateResponseDto> {
// DEMO ONLY (WALLET_DEMO_AUTO_SUCCEED): pretend the payment succeeded — no balance check,
// no debit — and run the exact same finalize path a real successful payment uses
// (booking → CONFIRMED, seats confirmed, ticket issued). Remove once a real provider works.
if (this.walletDemoAutoSucceed) {
this.logger.warn(
`WALLET_DEMO_AUTO_SUCCEED enabled — faking a successful WALLET payment for booking ${booking.bookingRef} (${booking.id})`,
);
const demoIntent = await this.prisma.paymentIntent.upsert({
where: { bookingId: booking.id },
update: {
status: PaymentIntentStatus.PROCESSING,
failureCode: null,
method: PaymentMethodType.WALLET,
},
create: {
bookingId: booking.id,
amountMinor: booking.totalMinor,
method: PaymentMethodType.WALLET,
status: PaymentIntentStatus.PROCESSING,
providerRef: `WALLET-DEMO-${Date.now()}`,
},
});
await this.finalizePaymentSuccess({ intentId: demoIntent.id });
const settled = await this.prisma.paymentIntent.findUniqueOrThrow({
where: { id: demoIntent.id },
});
return this.formatIntentResponse(settled);
}
const debitResult = await this.prisma.$transaction(async (tx) => {
const wallet = await tx.walletAccount.findUnique({
where: { passengerId: booking.passengerId },

View File

@@ -2,9 +2,17 @@ import { registerAs } from "@nestjs/config";
export default registerAs("dmoney", () => ({
baseUrl: process.env.DMONEY_BASE_URL ?? "",
appId: process.env.DMONEY_APP_ID ?? "",
webBaseUrl: process.env.DMONEY_WEB_BASE_URL ?? "",
fabricAppId: process.env.DMONEY_FABRIC_APP_ID ?? "",
appSecret: process.env.DMONEY_APP_SECRET ?? "",
publicKey: process.env.DMONEY_PUBLIC_KEY ?? "",
privateKey: process.env.DMONEY_PRIVATE_KEY ?? "",
merchantAppId: process.env.DMONEY_MERCHANT_APP_ID ?? "",
merchantCode: process.env.DMONEY_MERCHANT_CODE ?? "",
notifyUrl: process.env.DMONEY_NOTIFY_URL ?? "",
returnUrl: process.env.DMONEY_RETURN_URL ?? "",
timeoutExpress: process.env.DMONEY_TIMEOUT_EXPRESS ?? "120m",
language: process.env.DMONEY_LANGUAGE ?? "en",
currency: process.env.DMONEY_CURRENCY ?? "FDJ",
privateKey: process.env.DMONEY_PRIVATE_KEY ?? "",
publicKey: process.env.DMONEY_PUBLIC_KEY ?? "",
insecureTls: process.env.DMONEY_INSECURE_TLS === "true",
}));

View File

@@ -13,22 +13,36 @@ export class DMoneyWebhookService {
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
const mapped = this.provider.mapWebhookStatus(payload.status);
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
const providerTxnId = payload.transId ?? payload.payment_order_id;
await this.processor.process({
provider: this.provider.method,
externalEventId: `${payload.orderId}_${payload.status}`,
merchantOrderId: payload.merchantOrderId,
providerTxnId: payload.transactionId,
externalEventId: `${payload.payment_order_id}_${payload.trade_status}`,
merchantOrderId: payload.merch_order_id,
providerTxnId,
signatureValid,
rawStatus: payload.status,
rawStatus: payload.trade_status,
payload: payload as unknown as Record<string, unknown>,
result: {
status: mapped,
providerTxnId: payload.transactionId,
paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined,
failureCode: payload.status,
providerTxnId,
paidAt: this.parseTransEndTime(payload.trans_end_time),
failureCode: payload.trade_status,
},
});
}
/** D-Money sends trans_end_time either as epoch ms/s or "YYYY-MM-DD HH:mm:ss". */
private parseTransEndTime(raw: string | undefined): Date | undefined {
if (!raw) return undefined;
if (/^\d+$/.test(raw)) {
const n = parseInt(raw, 10);
if (Number.isNaN(n)) return undefined;
// 13-digit value is milliseconds, otherwise seconds.
return new Date(raw.length >= 13 ? n : n * 1000);
}
const parsed = new Date(raw.replace(" ", "T"));
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
}
}

View File

@@ -136,7 +136,7 @@ export class WebhooksController {
} catch (err) {
this.logger.error(`D-Money webhook handler threw: ${this.message(err)}`);
}
return { success: true };
return { code: "0", msg: "Success", result: "SUCCESS" };
}
private message(err: unknown): string {