Merge pull request #1220 from Tria-plc/alpha

Alpha
This commit is contained in:
Abubeker Yasin
2026-08-10 15:45:44 +03:00
committed by GitHub
21 changed files with 1591 additions and 388 deletions

View File

@@ -87,12 +87,8 @@ CBE_SECRET_KEY=
CBE_NOTIFY_URL=
CBE_RETURN_URL=
# eBirr
EBIRR_BASE_URL=
EBIRR_MERCHANT_CODE=
EBIRR_SECRET_KEY=
EBIRR_NOTIFY_URL=
EBIRR_RETURN_URL=
# eBirr — credentials live in edr-payment-api only; the passenger API never calls providers
# directly. eBirr has no redirect, so there is no EBIRR_RETURN_URL. See docs/ebirr/INTEGRATION.md.
# Card Gateway (Stripe-like)
CARD_BASE_URL=
@@ -140,7 +136,6 @@ WAAFI_SUCCESS_REDIRECT=
WAAFI_FAIL_REDIRECT=
DMONEY_RETURN_URL=
CBE_RETURN_URL=
EBIRR_RETURN_URL=
CARD_RETURN_URL=
# Session Configuration

View File

@@ -23,7 +23,6 @@ import dbConfig from "./config/database.config";
import iamDatabaseConfig from "./config/iam-database.config";
import telebirrConfig from "./config/telebirr.config";
import cbeConfig from "./config/cbe.config";
import ebirrConfig from "./config/ebirr.config";
import cardConfig from "./config/card.config";
import waafiConfig from "./config/waafi.config";
import faydaConfig from "./config/fayda.config";
@@ -75,7 +74,6 @@ import { EOtpType } from "@tria-plc/iamapi-common";
iamDatabaseConfig,
telebirrConfig,
cbeConfig,
ebirrConfig,
cardConfig,
waafiConfig,
faydaConfig,

View File

@@ -1,9 +0,0 @@
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

@@ -21,7 +21,7 @@ export enum PaymentMethodTypeEnum {
CBE_BIRR = "CBE_BIRR", // Ethiopia
EBIRR = "EBIRR", // Ethiopia
WAAFI = "WAAFI",
DMONEY= "DMONEY",// Djibouti
DMONEY = "DMONEY", // Djibouti
CAC_BANK = "CAC_BANK", // Djibouti (OTP debit)
CARD = "CARD", // International
WALLET = "WALLET", // Internal
@@ -57,9 +57,10 @@ export class InitiatePaymentDto {
platform?: PaymentPlatformDto;
@ApiPropertyOptional({
description:
"Payer account / mobile number. Required for OTP-debit methods (CAC_BANK) — " +
"the bank sends the OTP to this number.",
example: "77112233",
"Payer account / mobile number. Required for push-debit methods: CAC_BANK (the bank " +
"sends an OTP to this number) and EBIRR (the wallet pushes a USSD PIN prompt to it). " +
"Ethiopian numbers are accepted as +251…, 251…, 09… or 9… and normalised server-side.",
example: "+251923582676",
})
@IsOptional()
@IsString()
@@ -125,6 +126,7 @@ export class ClientActionDto {
"LAUNCH_APP",
"INVOKE_BRIDGE",
"COLLECT_OTP",
"AWAIT_PUSH",
"SHOW_BILL_REFERENCE",
],
})
@@ -133,6 +135,7 @@ export class ClientActionDto {
| "LAUNCH_APP"
| "INVOKE_BRIDGE"
| "COLLECT_OTP"
| "AWAIT_PUSH"
| "SHOW_BILL_REFERENCE";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
url?: string;
@@ -160,10 +163,22 @@ export class ClientActionDto {
"to the host bridge (js_fun_start_pay). NOT a URL — never navigate to it.",
})
rawRequest?: string;
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" })
@ApiPropertyOptional({
description: "Set when type=COLLECT_OTP (e.g. CAC Bank)",
})
providerOrderId?: string;
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
@ApiPropertyOptional({
description:
"Set when type=COLLECT_OTP or type=AWAIT_PUSH — text to show the payer",
})
message?: string;
@ApiPropertyOptional({
description:
"Set when type=AWAIT_PUSH (eBirr). Masked wallet number the PIN prompt was pushed to, " +
"so the payer can confirm it is their handset. Nothing to navigate to — poll the intent.",
example: "2519****2676",
})
payerAccountMasked?: string;
@ApiPropertyOptional({
description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)",
})
@@ -180,6 +195,9 @@ export class InitiateResponseDto {
@ApiPropertyOptional({ type: ClientActionDto })
clientAction?: ClientActionDto;
@ApiPropertyOptional() merchantOrderId?: string;
/** Set when initiate already settled terminally (eBirr debits synchronously — no webhook). */
@ApiPropertyOptional() failureCode?: string;
@ApiPropertyOptional() failureMessage?: string;
/** When this payment session stops being offered — PAYMENT_SESSION_MINUTES from initiation, capped at paymentDeadline. Drives the client-side countdown. */
@ApiPropertyOptional() sessionExpiresAt?: string;
/** The booking's payment deadline: after it, the booking is auto-cancelled. */
@@ -205,18 +223,43 @@ export class IntentStatusDto {
}
export class BookingAmountResponseDto {
@ApiProperty({ example: 'booking-uuid' }) booking_id: string;
@ApiProperty({ example: 'DJF', description: 'Currency of the returned amount' }) currency: string;
@ApiProperty({ example: 162.5, description: 'Booking total converted to the requested currency (major units)' }) amount: number;
@ApiProperty({ example: "booking-uuid" }) booking_id: string;
@ApiProperty({
example: "DJF",
description: "Currency of the returned amount",
})
currency: string;
@ApiProperty({
example: 162.5,
description:
"Booking total converted to the requested currency (major units)",
})
amount: number;
}
export class ForceConfirmDto {
@ApiPropertyOptional({ description: 'External payment reference / transaction ID from the vendor', example: 'TXN-123456' })
@IsOptional() @IsString() paymentReference?: string;
@ApiPropertyOptional({
description: "External payment reference / transaction ID from the vendor",
example: "TXN-123456",
})
@IsOptional()
@IsString()
paymentReference?: string;
@ApiPropertyOptional({ enum: PaymentMethodTypeEnum, description: 'Payment method used externally', example: 'TELEBIRR' })
@IsOptional() @IsEnum(PaymentMethodTypeEnum) paymentMethod?: PaymentMethodTypeEnum;
@ApiPropertyOptional({
enum: PaymentMethodTypeEnum,
description: "Payment method used externally",
example: "TELEBIRR",
})
@IsOptional()
@IsEnum(PaymentMethodTypeEnum)
paymentMethod?: PaymentMethodTypeEnum;
@ApiPropertyOptional({ description: 'Internal notes about why this was force-confirmed', example: 'Vendor confirmed via phone' })
@IsOptional() @IsString() notes?: string;
@ApiPropertyOptional({
description: "Internal notes about why this was force-confirmed",
example: "Vendor confirmed via phone",
})
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -86,8 +86,10 @@ export class PaymentsService {
) {}
async deletePayment(id: string) {
const intent = await this.prisma.paymentIntent.findUnique({ where: { id } });
if (!intent) throw new NotFoundException('Payment intent not found');
const intent = await this.prisma.paymentIntent.findUnique({
where: { id },
});
if (!intent) throw new NotFoundException("Payment intent not found");
await this.prisma.paymentIntent.delete({ where: { id } });
return { deleted: true, id };
}
@@ -147,20 +149,29 @@ export class PaymentsService {
// For package round-trip bookings the stored amountMinor may be the single-leg
// amount. Recompute from the tier price when applicable.
let amountMinor = item.amountMinor;
if (b?.packageId && b?.bookingType === 'ROUND_TRIP' && b?.priceTier?.priceMinor) {
if (
b?.packageId &&
b?.bookingType === "ROUND_TRIP" &&
b?.priceTier?.priceMinor
) {
const adultFare = b.priceTier.priceMinor * 2;
const childFare = Math.round(adultFare * 0.1);
const correctMinor = (b.adultCount || 1) * adultFare + (b.childCount || 0) * childFare;
const correctMinor =
(b.adultCount || 1) * adultFare + (b.childCount || 0) * childFare;
// Convert to the charge currency ratio: stored amountMinor is in charge currency
// (may be DJF/USD), but correctMinor is in ETB minor. Only override when the
// currency is ETB (most common case); for foreign currencies keep stored value.
if (item.currency === 'ETB') amountMinor = correctMinor;
if (item.currency === "ETB") amountMinor = correctMinor;
}
return {
id: item.id,
reference: item.id.substring(0, 8),
bookingId: item.bookingId,
booking: { bookingRef: b?.bookingRef, totalMinor: b?.totalMinor, currency: b?.currency },
booking: {
bookingRef: b?.bookingRef,
totalMinor: b?.totalMinor,
currency: b?.currency,
},
amountMinor,
currency: item.currency,
method: item.method,
@@ -187,7 +198,11 @@ export class PaymentsService {
priceTierId?: string | null;
displayTotalMinor?: number | null;
}): Promise<number> {
if (!booking.packageId || !booking.priceTierId || booking.bookingType !== 'ROUND_TRIP') {
if (
!booking.packageId ||
!booking.priceTierId ||
booking.bookingType !== "ROUND_TRIP"
) {
return booking.totalMinor;
}
// New bookings store displayTotalMinor from the frontend's reviewedTotalMinor; their
@@ -196,17 +211,30 @@ export class PaymentsService {
return booking.totalMinor;
}
// Legacy path: old bookings may have stored a single-leg totalMinor — recompute from tier.
const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: booking.priceTierId } });
const tier = await this.prisma.packagePriceTier.findUnique({
where: { id: booking.priceTierId },
});
if (!tier) return booking.totalMinor;
const seats = await this.prisma.bookingSeat.findMany({ where: { bookingId: booking.id, leg: 1 }, select: { passengerCategory: true } });
const adultCount = seats.filter(s => s.passengerCategory === 'ADULT').length || 1;
const childCount = seats.filter(s => s.passengerCategory === 'CHILD').length;
const seats = await this.prisma.bookingSeat.findMany({
where: { bookingId: booking.id, leg: 1 },
select: { passengerCategory: true },
});
const adultCount =
seats.filter((s) => s.passengerCategory === "ADULT").length || 1;
const childCount = seats.filter(
(s) => s.passengerCategory === "CHILD",
).length;
// tier.priceMinor may be in a non-ETB currency — convert to ETB so the result is
// always in the same units as totalMinor (which is always the ETB canonical).
const rawFare = tier.priceMinor * 2;
const adultFareMinor = tier.currency && (tier.currency as string) !== 'ETB'
? await this.currencyService.convertAmount(rawFare, tier.currency as any, 'ETB' as any)
: rawFare;
const adultFareMinor =
tier.currency && (tier.currency as string) !== "ETB"
? await this.currencyService.convertAmount(
rawFare,
tier.currency as any,
"ETB" as any,
)
: rawFare;
const childFareMinor = Math.round(adultFareMinor * 0.1);
return adultCount * adultFareMinor + childCount * childFareMinor;
}
@@ -233,6 +261,14 @@ export class PaymentsService {
);
}
// eBirr is a direct wallet debit — the PIN prompt is pushed to this number over USSD. There
// is no hosted page that could collect it later, so it must be supplied up front.
if (method === PaymentMethodType.EBIRR && !dto.payerAccount?.trim()) {
throw new BadRequestException(
"payerAccount (mobile wallet number) is required for eBirr",
);
}
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8). payerAccount is NOT
// required — CBE identifies the payer at its own channel.
if (
@@ -272,7 +308,9 @@ export class PaymentsService {
// Opening a session with less than MIN_PAYMENT_WINDOW_MINUTES left produces the worst possible
// outcome — the provider captures the money and the booking is already CANCELLED when the
// capture lands. WALLET is exempt (returned above): it is an instant internal balance debit.
const paymentDeadline = await this.computeBookingPaymentDeadline(booking.id);
const paymentDeadline = await this.computeBookingPaymentDeadline(
booking.id,
);
const sessionExpiresAt = paymentDeadline
? computePaymentSessionExpiry(paymentDeadline)
: undefined;
@@ -282,8 +320,8 @@ export class PaymentsService {
remainingMs <= 0
? "The payment window for this booking has expired. Please make a new booking."
: `Too little time is left to start a payment (${Math.ceil(remainingMs / 60000)} minute(s) ` +
`until this booking expires; at least ${MIN_PAYMENT_WINDOW_MINUTES} are required). ` +
`Please make a new booking.`,
`until this booking expires; at least ${MIN_PAYMENT_WINDOW_MINUTES} are required). ` +
`Please make a new booking.`,
);
}
@@ -307,8 +345,12 @@ export class PaymentsService {
? "ETB"
: (paymentMethod?.currency ?? booking.currency).toUpperCase();
const bookingDisplayCurrency = ((booking as any).displayCurrency ?? 'ETB').toUpperCase();
const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as number | null;
const bookingDisplayCurrency = (
(booking as any).displayCurrency ?? "ETB"
).toUpperCase();
const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as
| number
| null;
let chargeAmount: number;
if (method === PaymentMethodType.CBE_BILL) {
@@ -319,18 +361,24 @@ export class PaymentsService {
);
} else if (
chargeCurrency === bookingDisplayCurrency &&
chargeCurrency !== 'ETB' &&
chargeCurrency !== "ETB" &&
bookingDisplayTotalMinor != null
) {
// Display currency matches charge currency — use the pre-converted amount directly.
chargeAmount = this.currencyService.displayMinorToChargeMajor(bookingDisplayTotalMinor, chargeCurrency);
} else if (chargeCurrency === 'ETB') {
chargeAmount = this.currencyService.displayMinorToChargeMajor(booking.totalMinor, 'ETB');
chargeAmount = this.currencyService.displayMinorToChargeMajor(
bookingDisplayTotalMinor,
chargeCurrency,
);
} else if (chargeCurrency === "ETB") {
chargeAmount = this.currencyService.displayMinorToChargeMajor(
booking.totalMinor,
"ETB",
);
} else {
// Booking is in ETB — convert to the provider's settlement currency.
chargeAmount = await this.currencyService.convertMinorToChargeMajor(
booking.totalMinor,
booking.currency,
booking.currency,
chargeCurrency,
);
}
@@ -398,10 +446,15 @@ export class PaymentsService {
bookingId,
);
if (!snapshot) {
throw new NotFoundException("No active payment to confirm for this booking");
throw new NotFoundException(
"No active payment to confirm for this booking",
);
}
const confirmed = await this.paymentClient.confirmOtp(snapshot.intentId, otp);
const confirmed = await this.paymentClient.confirmOtp(
snapshot.intentId,
otp,
);
let intent = await this.syncIntentProjection(bookingId, confirmed);
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
@@ -553,9 +606,8 @@ export class PaymentsService {
[PaymentMethodType.CBE_BIRR]: {
returnUrl: process.env.CBE_RETURN_URL,
},
[PaymentMethodType.EBIRR]: {
returnUrl: process.env.EBIRR_RETURN_URL,
},
// No EBIRR entry: the payer never leaves the page — eBirr pushes a PIN prompt to their
// handset — so there is no browser bounce-back to configure.
[PaymentMethodType.CARD]: {
returnUrl: process.env.CARD_RETURN_URL,
},
@@ -633,7 +685,8 @@ export class PaymentsService {
failureCode: snapshot.failureCode ?? null,
failureMessage: snapshot.failureMessage ?? null,
rawInitiation: (snapshot as any).providerResponse
? ((snapshot as any).providerResponse as unknown as Prisma.InputJsonValue)
? ((snapshot as any)
.providerResponse as unknown as Prisma.InputJsonValue)
: Prisma.DbNull,
};
await this.prisma.paymentIntent.upsert({
@@ -739,6 +792,11 @@ export class PaymentsService {
status: intent.status,
clientAction,
merchantOrderId: intent.merchantOrderId ?? undefined,
// eBirr settles inside initiate (its purchase response is the settlement), so a FAILED
// verdict arrives here rather than through a later status poll. Without these the portal
// can only show a generic "please try again" instead of the actual cause.
failureCode: intent.failureCode ?? undefined,
failureMessage: intent.failureMessage ?? undefined,
};
}
@@ -792,7 +850,6 @@ export class PaymentsService {
where: { bookingId },
});
if (local?.status === PaymentIntentStatus.SUCCEEDED) {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
@@ -884,7 +941,12 @@ export class PaymentsService {
data: { status: "CANCELLED" },
});
}
await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'REFUNDED', bookingId: dto.bookingId } });
await this.auditService.log({
action: "UPDATE",
entityType: "Payment",
entityId: intent.id,
newData: { status: "REFUNDED", bookingId: dto.bookingId },
});
return { refunded: true, bookingRef: booking?.bookingRef };
}
@@ -906,17 +968,20 @@ export class PaymentsService {
}
async updatePaymentMethod(id: string, dto: Partial<AddPaymentMethodDto>) {
const existing = await this.prisma.paymentMethod.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('Payment method not found');
const existing = await this.prisma.paymentMethod.findUnique({
where: { id },
});
if (!existing) throw new NotFoundException("Payment method not found");
const updateData: any = {};
if (dto.displayName !== undefined) updateData.displayName = dto.displayName;
if (dto.region !== undefined) updateData.region = dto.region as unknown as PaymentRegion;
if (dto.region !== undefined)
updateData.region = dto.region as unknown as PaymentRegion;
if (dto.currency !== undefined) updateData.currency = dto.currency;
if (dto.providerId !== undefined) updateData.providerId = dto.providerId;
if (dto.enabled !== undefined) updateData.enabled = dto.enabled;
if (dto.sortOrder !== undefined) updateData.sortOrder = dto.sortOrder;
return this.prisma.paymentMethod.update({
where: { id },
data: updateData,
@@ -958,24 +1023,31 @@ export class PaymentsService {
displayTotalMinor: true,
},
});
if (!booking) throw new NotFoundException('Booking not found');
if (!booking) throw new NotFoundException("Booking not found");
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
const requestedCurrency = currency.toUpperCase();
// Source of truth: displayTotalMinor in displayCurrency when available,
// otherwise totalMinor in ETB (bookings with no display currency override).
const sourceCurrency = (booking.displayCurrency ?? 'ETB').toUpperCase();
const sourceCurrency = (booking.displayCurrency ?? "ETB").toUpperCase();
const sourceMinor = booking.displayTotalMinor ?? correctTotalMinor;
// Same currency — return directly, no conversion needed.
if (requestedCurrency === sourceCurrency) {
return { booking_id: bookingId, currency: requestedCurrency, amount: sourceMinor / 100 };
return {
booking_id: bookingId,
currency: requestedCurrency,
amount: sourceMinor / 100,
};
}
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
where: { fromCurrency: sourceCurrency as any, toCurrency: requestedCurrency as any },
orderBy: { effectiveDate: 'desc' },
where: {
fromCurrency: sourceCurrency as any,
toCurrency: requestedCurrency as any,
},
orderBy: { effectiveDate: "desc" },
});
let rate: number;
@@ -984,18 +1056,28 @@ export class PaymentsService {
} else {
// Try inverse rate
const inverseRate = await this.prisma.currencyExchangeRate.findFirst({
where: { fromCurrency: requestedCurrency as any, toCurrency: sourceCurrency as any },
orderBy: { effectiveDate: 'desc' },
where: {
fromCurrency: requestedCurrency as any,
toCurrency: sourceCurrency as any,
},
orderBy: { effectiveDate: "desc" },
});
if (inverseRate) {
rate = 1 / Number(inverseRate.rate);
} else {
// Bridge via ETB (e.g. DJF→USD = (DJF→ETB) × (ETB→USD))
rate = await this.currencyService.getRateOrThrow(sourceCurrency as any, requestedCurrency as any);
rate = await this.currencyService.getRateOrThrow(
sourceCurrency as any,
requestedCurrency as any,
);
}
}
const converted = (sourceMinor / 100) * rate;
return { booking_id: bookingId, currency: requestedCurrency, amount: converted };
return {
booking_id: bookingId,
currency: requestedCurrency,
amount: converted,
};
}
/**
@@ -1117,7 +1199,9 @@ export class PaymentsService {
select: { status: true },
});
if (idempotencyBooking?.status === "CONFIRMED") {
const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } });
const ticketCount = await this.prisma.ticket.count({
where: { bookingId: intent.bookingId },
});
if (ticketCount === 0) {
try {
await this.ticketsService.generate(intent.bookingId);
@@ -1127,7 +1211,9 @@ export class PaymentsService {
`Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`,
);
try {
await this.ticketsService.smartAssignAndGenerate(intent.bookingId);
await this.ticketsService.smartAssignAndGenerate(
intent.bookingId,
);
} catch (retryErr) {
this.logger.error(
`Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
@@ -1242,7 +1328,9 @@ export class PaymentsService {
);
}
} else {
this.logger.error(`Error generating ticket for booking ${booking.id}: ${msg}`);
this.logger.error(
`Error generating ticket for booking ${booking.id}: ${msg}`,
);
}
}
@@ -1262,22 +1350,40 @@ export class PaymentsService {
return { alreadyFinalized: false };
}
private async handleSupplementaryChargeEvent(event: PaymentEventDto): Promise<MarkPaidResponseDto> {
if (event.eventType === 'payment.failed') {
this.logger.warn(`supplementary charge ${event.referenceId} payment failed`);
private async handleSupplementaryChargeEvent(
event: PaymentEventDto,
): Promise<MarkPaidResponseDto> {
if (event.eventType === "payment.failed") {
this.logger.warn(
`supplementary charge ${event.referenceId} payment failed`,
);
return { processed: true };
}
const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id: event.referenceId } });
const charge = await this.prisma.supplementaryCharge.findUnique({
where: { id: event.referenceId },
});
if (!charge) {
this.logger.error(`mark-paid: no supplementary charge for reference ${event.referenceId}`);
return { processed: false, reason: 'charge-not-found' };
this.logger.error(
`mark-paid: no supplementary charge for reference ${event.referenceId}`,
);
return { processed: false, reason: "charge-not-found" };
}
if (charge.status === 'PAID') return { processed: true, alreadyFinalized: true };
if (charge.status === "PAID")
return { processed: true, alreadyFinalized: true };
await this.prisma.supplementaryCharge.update({
where: { id: charge.id },
data: { status: 'PAID', paidAt: new Date(), providerTxnId: event.providerTxnId ?? null },
data: {
status: "PAID",
paidAt: new Date(),
providerTxnId: event.providerTxnId ?? null,
},
});
await this.auditService.log({
action: "UPDATE",
entityType: "SupplementaryCharge",
entityId: charge.id,
newData: { status: "PAID", providerTxnId: event.providerTxnId },
});
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: charge.id, newData: { status: 'PAID', providerTxnId: event.providerTxnId } });
return { processed: true };
}
@@ -1339,7 +1445,8 @@ export class PaymentsService {
// normalize before comparing; a short payment must NOT confirm the booking. Amount-only —
// the display↔charge currency divergence is tracked separately under the USD/DJF
// findings. The 1% tolerance absorbs rounding.
const expectedMajor = (booking.displayTotalMinor ?? booking.totalMinor) / 100;
const expectedMajor =
(booking.displayTotalMinor ?? booking.totalMinor) / 100;
const shortPayTolerance = Math.max(0.01, expectedMajor * 0.01);
if (event.amountMinor < expectedMajor - shortPayTolerance) {
this.logger.error(
@@ -1433,15 +1540,22 @@ export class PaymentsService {
return { processed: true, alreadyFinalized };
}
async forceConfirmPayment(bookingId: string, dto: ForceConfirmDto = {}): Promise<{ alreadyFinalized: boolean }> {
const booking = await this.prisma.booking.findUnique({ where: { id: bookingId } });
if (!booking) throw new NotFoundException('Booking not found');
async forceConfirmPayment(
bookingId: string,
dto: ForceConfirmDto = {},
): Promise<{ alreadyFinalized: boolean }> {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
});
if (!booking) throw new NotFoundException("Booking not found");
const resolvedMethod = dto.paymentMethod
? (dto.paymentMethod as unknown as PaymentMethodType)
: PaymentMethodType.TELEBIRR;
let intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId } });
let intent = await this.prisma.paymentIntent.findUnique({
where: { bookingId },
});
if (!intent) {
intent = await this.prisma.paymentIntent.create({
data: {
@@ -1461,7 +1575,10 @@ export class PaymentsService {
if (dto.paymentReference) updateData.providerTxnId = dto.paymentReference;
if (dto.paymentMethod) updateData.method = resolvedMethod;
if (dto.notes) updateData.failureMessage = dto.notes;
if (intent.status === PaymentIntentStatus.CANCELLED || intent.status === PaymentIntentStatus.FAILED) {
if (
intent.status === PaymentIntentStatus.CANCELLED ||
intent.status === PaymentIntentStatus.FAILED
) {
updateData.status = PaymentIntentStatus.PROCESSING;
}
if (Object.keys(updateData).length) {
@@ -1477,7 +1594,17 @@ export class PaymentsService {
providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined,
force: true,
}).then(async (result) => {
await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'FORCE_CONFIRMED', bookingId, paymentMethod: dto.paymentMethod, paymentReference: dto.paymentReference } });
await this.auditService.log({
action: "UPDATE",
entityType: "Payment",
entityId: intent.id,
newData: {
status: "FORCE_CONFIRMED",
bookingId,
paymentMethod: dto.paymentMethod,
paymentReference: dto.paymentReference,
},
});
return result;
});
}
@@ -1548,78 +1675,106 @@ export class PaymentsService {
// Build per-leg definitions: { scheduleId, originStationId, destinationStationId, seatIds[] }
// BookingSeat.leg: 1=outbound/leg-1, 2=return/leg-2, 3=return leg-1 (transit), 4=return leg-2
type LegDef = { scheduleId: string; originStationId: string; destinationStationId: string; seatIds: string[] };
type LegDef = {
scheduleId: string;
originStationId: string;
destinationStationId: string;
seatIds: string[];
};
const legDefs: LegDef[] = [];
const seatsForLeg = (legNum: number) =>
booking.seats.filter((s: any) => s.leg === legNum).map((s: any) => s.seatId);
booking.seats
.filter((s: any) => s.leg === legNum)
.map((s: any) => s.seatId);
if (booking.bookingType === 'ONE_WAY') {
if (booking.bookingType === "ONE_WAY") {
legDefs.push({
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
destinationStationId: b.destinationStationId,
seatIds: booking.seats.map((s: any) => s.seatId),
seatIds: booking.seats.map((s: any) => s.seatId),
});
} else if (booking.bookingType === 'ROUND_TRIP') {
} else if (booking.bookingType === "ROUND_TRIP") {
legDefs.push({
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
destinationStationId: b.destinationStationId,
seatIds: seatsForLeg(1),
seatIds: seatsForLeg(1),
});
if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) {
if (
b.returnScheduleId &&
b.returnOriginStationId &&
b.returnDestinationStationId
) {
legDefs.push({
scheduleId: b.returnScheduleId,
originStationId: b.returnOriginStationId,
scheduleId: b.returnScheduleId,
originStationId: b.returnOriginStationId,
destinationStationId: b.returnDestinationStationId,
seatIds: seatsForLeg(2),
seatIds: seatsForLeg(2),
});
}
} else if (booking.bookingType === 'TRANSIT') {
} else if (booking.bookingType === "TRANSIT") {
legDefs.push({
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
destinationStationId: b.leg2OriginStationId, // transit station
seatIds: seatsForLeg(1),
seatIds: seatsForLeg(1),
});
if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) {
if (
b.leg2ScheduleId &&
b.leg2OriginStationId &&
b.leg2DestinationStationId
) {
legDefs.push({
scheduleId: b.leg2ScheduleId,
originStationId: b.leg2OriginStationId,
scheduleId: b.leg2ScheduleId,
originStationId: b.leg2OriginStationId,
destinationStationId: b.leg2DestinationStationId,
seatIds: seatsForLeg(2),
seatIds: seatsForLeg(2),
});
}
} else if (booking.bookingType === 'ROUND_TRIP_TRANSIT') {
} else if (booking.bookingType === "ROUND_TRIP_TRANSIT") {
legDefs.push({
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
destinationStationId: b.leg2OriginStationId,
seatIds: seatsForLeg(1),
seatIds: seatsForLeg(1),
});
if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) {
if (
b.leg2ScheduleId &&
b.leg2OriginStationId &&
b.leg2DestinationStationId
) {
legDefs.push({
scheduleId: b.leg2ScheduleId,
originStationId: b.leg2OriginStationId,
scheduleId: b.leg2ScheduleId,
originStationId: b.leg2OriginStationId,
destinationStationId: b.leg2DestinationStationId,
seatIds: seatsForLeg(2),
seatIds: seatsForLeg(2),
});
}
if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) {
if (
b.returnScheduleId &&
b.returnOriginStationId &&
b.returnDestinationStationId
) {
legDefs.push({
scheduleId: b.returnScheduleId,
originStationId: b.returnOriginStationId,
destinationStationId: b.returnLeg2OriginStationId ?? b.returnDestinationStationId,
seatIds: seatsForLeg(3),
scheduleId: b.returnScheduleId,
originStationId: b.returnOriginStationId,
destinationStationId:
b.returnLeg2OriginStationId ?? b.returnDestinationStationId,
seatIds: seatsForLeg(3),
});
}
if (b.returnLeg2ScheduleId && b.returnLeg2OriginStationId && b.returnLeg2DestStationId) {
if (
b.returnLeg2ScheduleId &&
b.returnLeg2OriginStationId &&
b.returnLeg2DestStationId
) {
legDefs.push({
scheduleId: b.returnLeg2ScheduleId,
originStationId: b.returnLeg2OriginStationId,
scheduleId: b.returnLeg2ScheduleId,
originStationId: b.returnLeg2OriginStationId,
destinationStationId: b.returnLeg2DestStationId,
seatIds: seatsForLeg(4),
seatIds: seatsForLeg(4),
});
}
}
@@ -1629,10 +1784,10 @@ export class PaymentsService {
const journey = await this.prisma.journey.create({
data: {
passengerId: booking.passengerId,
bookingId: booking.id,
status: 'CONFIRMED',
totalMinor: booking.totalMinor,
currency: booking.currency,
bookingId: booking.id,
status: "CONFIRMED",
totalMinor: booking.totalMinor,
currency: booking.currency,
} as any,
});
@@ -1644,23 +1799,27 @@ export class PaymentsService {
const stopTimes = await this.prisma.tripStopTime.findMany({
where: { scheduleId: leg.scheduleId },
orderBy: { sequence: 'asc' },
orderBy: { sequence: "asc" },
select: { stationId: true, sequence: true },
});
const originIdx = stopTimes.findIndex(st => st.stationId === leg.originStationId);
const destIdx = stopTimes.findIndex(st => st.stationId === leg.destinationStationId);
const originIdx = stopTimes.findIndex(
(st) => st.stationId === leg.originStationId,
);
const destIdx = stopTimes.findIndex(
(st) => st.stationId === leg.destinationStationId,
);
if (originIdx < 0 || destIdx < 0 || originIdx >= destIdx) continue;
for (const seatId of leg.seatIds) {
for (let i = originIdx; i < destIdx; i++) {
journeySegments.push({
journeyId: journey.id,
scheduleId: leg.scheduleId,
segmentOrder: segmentOrder++,
journeyId: journey.id,
scheduleId: leg.scheduleId,
segmentOrder: segmentOrder++,
seatId,
departureStationId: stopTimes[i].stationId,
arrivalStationId: stopTimes[i + 1].stationId,
arrivalStationId: stopTimes[i + 1].stationId,
});
}
}

View File

@@ -1,7 +1,3 @@
import { createRequire } from "module";
const require = createRequire(import.meta.url);
export default {
plugins: {
tailwindcss: {},

View File

@@ -29,6 +29,13 @@ import {
Check,
} from "lucide-react";
/**
* Poll budget for the eBirr push flow. The payer has to notice a USSD prompt and type a PIN, so
* this is far longer than the redirect flows' 15 attempts: 80 * 1.5s ≈ 2 min, matching the
* server's EBIRR_PUSH_TTL_MS.
*/
const PUSH_POLL_ATTEMPTS = 80;
const getIconForMethod = (methodId: string) => {
if (methodId.includes('CARD')) return CreditCard;
if (methodId.includes('WALLET')) return Wallet;
@@ -60,6 +67,13 @@ export default function PaymentPage() {
} | null>(null);
const [billCopied, setBillCopied] = useState(false);
// eBirr push debit: the wallet has prompted the payer on their own handset for a PIN. There is
// nothing to navigate to — we show this and poll until the intent settles.
const [pushAction, setPushAction] = useState<{
message: string;
payerAccountMasked?: string;
} | null>(null);
// Telebirr mini app: the SuperApp payment sheet is open (or just closed) and we're
// polling our own status endpoint for the webhook-backed outcome.
const [verifyingPayment, setVerifyingPayment] = useState(false);
@@ -176,12 +190,14 @@ export default function PaymentPage() {
const res: any = await apiClient.get(`/payments/status/${bookingId}`);
if (res?.status === 'SUCCEEDED') {
setVerifyingPayment(false);
setPushAction(null);
updateStatus("SUCCEEDED");
router.push("/booking/confirmation");
return;
}
if (res?.status === 'FAILED' || res?.status === 'CANCELLED') {
setVerifyingPayment(false);
setPushAction(null);
setIsProcessing(false);
updateStatus("FAILED");
setPaymentError(res?.failureMessage || "Payment was not completed. Please try again.");
@@ -192,9 +208,10 @@ export default function PaymentPage() {
}
if (attemptsLeft <= 0) {
// Don't call it failed: telebirr may have taken the money and the webhook is simply
// still in flight. Stop spinning, tell the truth, and let the payer re-check.
// Don't call it failed: the gateway may have taken the money and the confirmation is
// simply still in flight. Stop spinning, tell the truth, and let the payer re-check.
setVerifyingPayment(false);
setPushAction(null);
setIsProcessing(false);
setPaymentError(
"We haven't received confirmation yet. If you completed the payment, your booking " +
@@ -236,8 +253,27 @@ export default function PaymentPage() {
platform: isTelebirrMiniApp() ? 'inapp' : 'web',
});
},
onSuccess: async (data: any) => {
onSuccess: (data: any) => {
setPaymentError(null);
setPaymentIntent(data.paymentIntentId || data.intentId);
// A terminal verdict always wins over any clientAction, so this is checked FIRST.
// eBirr settles inside initiate — its debit response is the settlement, there is no
// webhook — so it can come back SUCCEEDED/FAILED while the intent still carries the
// AWAIT_PUSH action it was created with. Reading clientAction first would show "check
// your phone" for a payment that is already decided, and poll until it timed out.
if (data?.status === 'SUCCEEDED') {
updateStatus("SUCCEEDED");
router.push("/booking/confirmation");
return;
}
if (data?.status === 'FAILED' || data?.status === 'CANCELLED') {
setIsProcessing(false);
updateStatus("FAILED");
setPaymentError(data?.failureMessage || "Payment was not completed. Please try again.");
return;
}
// CAC Bank: no redirect — the bank SMS'd an OTP. Collect it in-app and confirm.
if (data?.clientAction?.type === 'COLLECT_OTP') {
@@ -278,6 +314,21 @@ export default function PaymentPage() {
return;
}
// eBirr fallback only. The debit is normally settled inside initiate and caught by the
// terminal check above; reaching here means the payer outlasted EBIRR_PURCHASE_TIMEOUT_MS
// while the PIN prompt was still on their handset. The money may since have moved, so poll
// rather than guess.
if (data?.clientAction?.type === 'AWAIT_PUSH') {
setPaymentIntent(data.intentId);
updateStatus("REQUIRES_ACTION");
setPushAction(data.clientAction);
setVerifyingPayment(true);
// Much longer budget than the redirect flows: the payer has to read a USSD prompt and
// type a PIN. PUSH_POLL_ATTEMPTS * 1.5s ≈ 2 min, matching EBIRR_PUSH_TTL_MS.
void pollPaymentStatus(PUSH_POLL_ATTEMPTS);
return;
}
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') {
setPaymentIntent(data.intentId);
updateStatus("REQUIRES_ACTION");
@@ -285,11 +336,13 @@ export default function PaymentPage() {
return;
}
setPaymentIntent(data.paymentIntentId || data.intentId);
// Not terminal, and no clientAction we know how to drive. Never assume success: this
// fallthrough used to sleep 2s and route to /booking/confirmation, which showed the payer
// a confirmed booking for a payment that had not happened. Poll for the truth, and if it
// never settles say so rather than inventing an outcome.
updateStatus("PROCESSING");
await new Promise((resolve) => setTimeout(resolve, 2000));
updateStatus("SUCCEEDED");
router.push("/booking/confirmation");
setVerifyingPayment(true);
void pollPaymentStatus(15);
},
onError: (error: any) => {
updateStatus("FAILED");
@@ -352,7 +405,12 @@ export default function PaymentPage() {
}
};
// Fire the actual initiate. `mobile` is only used for CAC (OTP debit).
// Methods that debit an account we must know up front: CAC Bank SMSes an OTP to it, eBirr
// pushes a USSD PIN prompt to it. Neither has a hosted page that could collect it later.
const requiresPayerMobile = (method: string | null): boolean =>
method === 'CAC_BANK' || method === 'EBIRR';
// Fire the actual initiate. `mobile` is only used by the push-debit methods above.
const startPayment = (mobile?: string) => {
if (!selectedMethod || !bookingId || !selectedPaymentMethod) return;
setIsProcessing(true);
@@ -363,7 +421,7 @@ export default function PaymentPage() {
paymentMethodId: selectedPaymentMethod.id,
currency: displayCurrency,
amountMinor: totalAmount,
payerAccount: selectedMethod === 'CAC_BANK' ? mobile?.trim() : undefined,
payerAccount: requiresPayerMobile(selectedMethod) ? mobile?.trim() : undefined,
});
};
@@ -378,9 +436,14 @@ export default function PaymentPage() {
}
setPaymentError(null);
// CAC Bank needs the payer's mobile for the OTP — collect it in a modal before initiating.
if (selectedMethod === 'CAC_BANK') {
if (requiresPayerMobile(selectedMethod)) {
setPhoneError(null);
// Prefill with the contact phone we already hold, but leave it editable — the wallet
// paying is often not the number the booking was made under.
if (!payerMobile.trim()) {
const contactPhone = passengers?.find((p) => p.phone)?.phone;
if (contactPhone) setPayerMobile(contactPhone);
}
setPhoneModalOpen(true);
return;
}
@@ -620,12 +683,26 @@ export default function PaymentPage() {
{isProcessing && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 max-w-sm w-full mx-4 text-center shadow-2xl">
{verifyingPayment ? (
{pushAction ? (
<>
<Smartphone className="w-14 h-14 text-primary mx-auto mb-4" />
<h3 className="text-lg font-bold mb-1 text-gray-900 dark:text-gray-100">Check your phone</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{pushAction.message}
</p>
{pushAction.payerAccountMasked && (
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">
Sent to {pushAction.payerAccountMasked}
</p>
)}
<Loader2 className="w-6 h-6 text-primary animate-spin mx-auto mt-4" />
</>
) : verifyingPayment ? (
<>
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
<h3 className="text-lg font-bold mb-1 text-gray-900 dark:text-gray-100">Confirming payment</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Checking with telebirr this only takes a moment.
Checking with your payment provider this only takes a moment.
</p>
</>
) : paymentMutation.isSuccess ? (
@@ -645,7 +722,7 @@ export default function PaymentPage() {
</div>
)}
{/* CAC Bank — collect payer mobile before initiating */}
{/* Push-debit methods (CAC Bank, eBirr) — collect payer mobile before initiating */}
{phoneModalOpen && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-sm w-full shadow-2xl">
@@ -654,7 +731,9 @@ export default function PaymentPage() {
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Your mobile number</h3>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
CAC Bank will send a one-time password to this number to authorize the payment.
{selectedMethod === 'EBIRR'
? "eBirr will prompt this number for your PIN to authorize the payment. Make sure it's the phone you have with you."
: "CAC Bank will send a one-time password to this number to authorize the payment."}
</p>
<input
type="tel"
@@ -663,7 +742,7 @@ export default function PaymentPage() {
value={payerMobile}
onChange={(e) => { setPayerMobile(e.target.value); setPhoneError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') submitPhone(); }}
placeholder="77 XX XX XX"
placeholder={selectedMethod === 'EBIRR' ? "09XX XXX XXX" : "77 XX XX XX"}
className="w-full px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none"
/>
{phoneError && (

View File

@@ -1,9 +1,26 @@
import { registerAs } from "@nestjs/config";
/**
* EbirrPay — direct mobile-wallet debit (docs/ebirr/INTEGRATION.md).
*
* Auth is plain credentials in the request body (§4): there is no signing key, and — because the
* flow has no hosted page and no callback — no notify URL and no return URL either.
*/
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 || "",
baseUrl: process.env.EBIRR_BASE_URL ?? "",
merchantUid: process.env.EBIRR_MERCHANT_UID ?? "",
apiKey: process.env.EBIRR_API_KEY ?? "",
apiUserId: process.env.EBIRR_API_USER_ID ?? "",
paymentMethod: process.env.EBIRR_PAYMENT_METHOD ?? "MWALLET_ACCOUNT",
channelName: process.env.EBIRR_CHANNEL_NAME ?? "WEB",
/**
* How long API_PURCHASE waits for the payer to read the USSD prompt and type their PIN. It is
* awaited on the request path, so it must stay under every timeout in front of it — the
* passenger API's PAYMENT_API_HTTP_TIMEOUT_MS (60s) and nginx's 60s default proxy_read_timeout.
* Raising it past those turns a slow payer into a dropped connection instead of a fallback.
*/
purchaseTimeoutMs: Number(process.env.EBIRR_PURCHASE_TIMEOUT_MS ?? 45_000),
/** How long the intent stays payable before the reconciliation sweep expires it. */
pushTtlMs: Number(process.env.EBIRR_PUSH_TTL_MS ?? 180_000),
insecureTls: process.env.EBIRR_INSECURE_TLS === "true",
}));

View File

@@ -7,7 +7,7 @@ import {
ProviderMethod,
ProviderPaymentStatus,
} from "@edr/types";
import { CacBankProvider } from "@edr/payment-providers";
import { CacBankProvider, EBirrProvider } from "@edr/payment-providers";
import { IntentsService } from "./intents.service";
import { IntentsRepository } from "./intents.repository";
import { BillReferenceService } from "./bill-reference.service";
@@ -61,6 +61,7 @@ describe("IntentsService CBE_BILL", () => {
{} as DataSource,
providers as never,
{} as CacBankProvider,
{} as EBirrProvider,
billReferenceService as unknown as BillReferenceService,
);
});

View File

@@ -6,7 +6,11 @@ import {
NotFoundException,
} from "@nestjs/common";
import { DataSource } from "typeorm";
import { createMerchantOrderId, CacBankProvider } from "@edr/payment-providers";
import {
createMerchantOrderId,
CacBankProvider,
EBirrProvider,
} from "@edr/payment-providers";
import {
ConfirmPaymentRequest,
InitiatePaymentRequest,
@@ -70,6 +74,9 @@ export class IntentsService {
@Inject(PAYMENT_PROVIDER_MAP)
private readonly providers: PaymentProviderMap,
private readonly cacBankProvider: CacBankProvider,
// eBirr's debit is awaited on the request path (see settleEBirrPurchase), so we need the
// concrete class for its non-interface `purchase()` — same pattern as CacBankProvider.
private readonly eBirrProvider: EBirrProvider,
private readonly billReferenceService: BillReferenceService,
) {}
@@ -78,7 +85,6 @@ export class IntentsService {
async initiate(
request: InitiatePaymentRequest,
): Promise<PaymentIntentSnapshot> {
if (request.idempotencyKey) {
const byKey = await this.intentsRepository.findByIdempotencyKey(
request.service,
@@ -105,17 +111,20 @@ export class IntentsService {
);
}
// Push-debit providers charge an account we must be told up front — there is no hosted page
// that could collect it later.
if (
request.provider === ProviderMethod.CAC_BANK &&
(request.provider === ProviderMethod.CAC_BANK ||
request.provider === ProviderMethod.EBIRR) &&
!request.payerAccount?.trim()
) {
throw new BadRequestException(
"payerAccount (customer mobile number) is required for CAC_BANK",
`payerAccount (customer mobile number) is required for ${request.provider}`,
);
}
const merchantOrderId = createMerchantOrderId();
const result = await provider.initiate({
const providerInput = {
merchantOrderId,
orderRef: request.orderRef ?? request.referenceId,
amountMinor: request.amountMinor,
@@ -125,7 +134,8 @@ export class IntentsService {
returnUrl: request.returnUrl,
redirectUrl: request.returnUrl,
failureUrl: request.failureUrl,
});
};
const result = await provider.initiate(providerInput);
const intent = await this.intentsRepository.create({
service: request.service,
@@ -145,9 +155,47 @@ export class IntentsService {
this.logger.log(
`intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via ${request.provider} (${merchantOrderId})`,
);
if (request.provider === ProviderMethod.EBIRR) {
return this.settleEBirrPurchase(intent.id, providerInput);
}
return this.toSnapshot(intent);
}
/**
* Issue the eBirr debit and hand back the settled intent.
*
* eBirr has no webhook: API_PURCHASE's response IS the settlement notification. So it is awaited
* here, on the request path, and the caller gets a terminal snapshot — the portal shows success
* or the real failure straight from the initiate response, with nothing to poll.
*
* The wait is bounded by EBIRR_PURCHASE_TIMEOUT_MS (45s), which must stay under the passenger
* API's 60s PAYMENT_API_HTTP_TIMEOUT_MS. A payer slower than that comes back PROCESSING and the
* intent keeps its AWAIT_PUSH client action, so the existing poll and the reconciliation sweep
* settle it as before. That fallback is rare but must not be removed: an unanswered purchase may
* still have moved money (vendor doc §10).
*
* purchase() does not throw — transport failures are already mapped to FAILED (never dispatched)
* or PROCESSING (sent, unanswered). The catch is for anything unforeseen: leaving the intent
* REQUIRES_ACTION also lands on the poll/sweep fallback, which is the safe direction.
*/
private async settleEBirrPurchase(
intentId: string,
providerInput: Parameters<EBirrProvider["purchase"]>[0],
): Promise<PaymentIntentSnapshot> {
try {
const status = await this.eBirrProvider.purchase(providerInput);
await this.applyProviderResult(intentId, status);
} catch (err) {
this.logger.error(
`eBirr purchase for intent ${intentId} (${providerInput.merchantOrderId}) could not be ` +
`settled: ${err instanceof Error ? err.message : err} — leaving it to the sweep`,
);
}
return this.snapshotOf(intentId);
}
/**
* CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md). Intent-first: the bill
* reference is created here, before CBE ever sees the bill; settlement arrives later through

View File

@@ -0,0 +1,374 @@
import { of, throwError } from "rxjs";
import { AxiosError } from "axios";
import {
EBirrProvider,
normalizeEthiopianMsisdn,
ProviderPaymentStatus,
} from "@edr/payment-providers";
/**
* eBirr is a direct wallet debit over the ASM envelope (docs/ebirr/INTEGRATION.md), not the
* Alipay-style redirect gateway the pre-rewrite provider was written against. These pin the
* things that were wrong before and the things that are easy to "fix" back by mistake:
*
* - the wire shape must match the request verified by hand against testpayments.ebirr.com,
* including `payerInfo.accountNo` (NOT the doc's `subscriptionId`) and no signature field;
* - the amount reaches eBirr unscaled — the old code divided by 100 and would have charged
* 1/100th of every booking (same class of bug as cac-bank-amount.spec.ts);
* - `initiate()` must not touch the network: the blocking debit is `purchase()`;
* - a timeout must NOT be reported as FAILED — the money may have moved (vendor doc §10).
*/
describe("EBirrProvider", () => {
const config = {
get: (key: string) =>
({
"ebirr.baseUrl": "https://testpayments.ebirr.com",
"ebirr.merchantUid": "M1000003",
"ebirr.apiKey": "API-1234560",
"ebirr.apiUserId": "10000008",
"ebirr.paymentMethod": "MWALLET_ACCOUNT",
"ebirr.channelName": "WEB",
"ebirr.purchaseTimeoutMs": 45_000,
"ebirr.pushTtlMs": 180_000,
})[key],
};
const input = {
merchantOrderId: "EDR-ORDER-1",
orderRef: "EDR-20240001",
amountMinor: 1500.5,
currency: "ETB",
payerAccount: "+251923582676",
};
function build(response?: unknown) {
const post = jest.fn().mockReturnValue(of({ data: response, status: 200 }));
const provider = new EBirrProvider(config as never, { post } as never);
return { provider, post };
}
/**
* Captured verbatim from the live sandbox (08/08/2026) across four scenarios. Note that the
* three failure envelopes are NOT 2001 yet still carry the authoritative `params.state` — the
* reason the provider reads `state` regardless of `responseCode`.
*/
const approved = {
schemaVersion: "1.0",
timestamp: "2026-08-08T06:40:42Z",
responseId: "REQ-001-20260506114500",
responseCode: "2001",
errorCode: "0",
responseMsg: "RCS_SUCCESS",
params: {
referenceId: "holyffuot",
transactionId: "619",
orderId: "521",
issuerTransactionId: "10000991513",
txAmount: "1.00",
state: "APPROVED",
},
};
const declined = {
schemaVersion: "1.0",
timestamp: "2026-08-08T06:41:58Z",
responseId: "REQ-001-20260506114500",
responseCode: "5206",
errorCode: "E10205",
responseMsg: "Payment Failed (Invalid Credentials)",
params: {
referenceId: "hoflyffuot",
transactionId: "621",
orderId: "522",
txAmount: "1.00",
state: "DECLINED",
description: "Invalid Credentials",
},
};
/** The payer aborted the USSD prompt, or let it lapse — eBirr reports both identically. */
const userAborted = {
schemaVersion: "1.0",
timestamp: "2026-08-08T06:42:51Z",
responseId: "REQ-001-20260506114500",
responseCode: "5001",
errorCode: "4004",
responseMsg: "User Aborted",
params: {
referenceId: "hoflyffuofft",
transactionId: "622",
orderId: "523",
txAmount: "1.00",
state: "TIMEOUT",
description: "User Aborted",
},
};
describe("initiate", () => {
it("issues no HTTP call and returns AWAIT_PUSH", async () => {
const { provider, post } = build();
const result = await provider.initiate(input);
expect(post).not.toHaveBeenCalled();
expect(result.clientAction).toEqual({
type: "AWAIT_PUSH",
message: expect.stringContaining("PIN"),
payerAccountMasked: "2519****2676",
});
expect(result.providerOrderId).toBe("EDR-ORDER-1");
});
it("rejects a missing payer account rather than charging nobody", async () => {
const { provider } = build();
await expect(
provider.initiate({ ...input, payerAccount: undefined }),
).rejects.toThrow(/payerAccount/);
});
it("never leaks the api key or the full MSISDN into the audit payload", async () => {
const { provider } = build();
const result = await provider.initiate(input);
const serialized = JSON.stringify(result.rawInitiation);
expect(serialized).not.toContain("API-1234560");
expect(serialized).not.toContain("251923582676");
});
});
describe("purchase", () => {
it("sends the ASM envelope verified against the live sandbox", async () => {
const { provider, post } = build(approved);
await provider.purchase(input);
const [url, body] = post.mock.calls[0];
expect(url).toBe("https://testpayments.ebirr.com/asm");
expect(body).toMatchObject({
schemaVersion: "1.0",
channelName: "WEB",
serviceName: "API_PURCHASE",
serviceParams: {
merchantUid: "M1000003",
apiKey: "API-1234560",
apiUserId: "10000008",
paymentMethod: "MWALLET_ACCOUNT",
// `accountNo`, not the vendor doc's `subscriptionId`
payerInfo: { accountNo: "251923582676" },
transactionInfo: {
referenceId: "EDR-ORDER-1",
invoiceId: "EDR-20240001",
currency: "ETB",
},
},
});
// eBirr wants `YYYY-MM-DD HH:mm:ss`, not the epoch seconds Waafi's /asm takes.
expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
expect(body.requestId).toHaveLength(36);
// Nothing is signed — there is no shared secret in this integration.
expect(JSON.stringify(body)).not.toContain("sign");
});
it("sends the amount unscaled — 1500.50 ETB, not 15.005", async () => {
const { provider, post } = build(approved);
await provider.purchase(input);
expect(post.mock.calls[0][1].serviceParams.transactionInfo.amount).toBe(
1500.5,
);
});
it("maps an APPROVED verdict to SUCCEEDED with the provider txn id", async () => {
const { provider } = build(approved);
const result = await provider.purchase(input);
expect(result.status).toBe(ProviderPaymentStatus.SUCCEEDED);
expect(result.providerTxnId).toBe("619");
expect(result.failureCode).toBeUndefined();
});
it("maps the live DECLINED response to FAILED with the specific cause", async () => {
const { provider } = build(declined);
const result = await provider.purchase(input);
expect(result.status).toBe(ProviderPaymentStatus.FAILED);
expect(result.failureCode).toBe("E10205");
// The specific cause, not the generic "Payment Failed (…)" wrapper.
expect(result.failureMessage).toBe("Invalid Credentials");
// eBirr issues a transaction id for failed attempts too — keep it for reconciliation.
expect(result.providerTxnId).toBe("621");
});
it("maps the live User-Aborted/TIMEOUT response to FAILED", async () => {
const { provider } = build(userAborted);
const result = await provider.purchase(input);
expect(result.status).toBe(ProviderPaymentStatus.FAILED);
expect(result.failureCode).toBe("4004");
expect(result.failureMessage).toBe("User Aborted");
expect(result.providerTxnId).toBe("622");
});
it("never promotes a rejected envelope to SUCCEEDED, even if state says APPROVED", async () => {
const { provider } = build({
...declined,
params: { state: "APPROVED" },
});
const result = await provider.purchase(input);
expect(result.status).toBe(ProviderPaymentStatus.FAILED);
});
it("falls back to the envelope when a rejection carries no params at all", async () => {
const { provider } = build({
schemaVersion: "1.0",
responseCode: "5001",
errorCode: "E10206",
responseMsg: "Failed to process request",
});
const result = await provider.purchase(input);
expect(result.status).toBe(ProviderPaymentStatus.FAILED);
expect(result.failureCode).toBe("E10206");
});
it("maps a timeout to PROCESSING, never FAILED — the money may have moved", async () => {
const post = jest
.fn()
.mockReturnValue(
throwError(() => new AxiosError("timeout of 45000ms exceeded")),
);
const provider = new EBirrProvider(config as never, { post } as never);
const result = await provider.purchase(input);
expect(result.status).toBe(ProviderPaymentStatus.PROCESSING);
});
/**
* The dispatch/no-dispatch split. A request that never left the process cannot have moved
* money and leaves NO transaction for API_GETTRANINFO to find — reporting it as PROCESSING
* stranded the payer on "check your phone" for a push that was never sent, until expiry.
* A request that did go out stays PROCESSING no matter how it broke (vendor doc §10).
*/
function purchaseWithTransportError(
message: string,
code?: string,
): Promise<{ status: ProviderPaymentStatus; failureMessage?: string }> {
const err = new AxiosError(message);
if (code) err.code = code;
const post = jest.fn().mockReturnValue(throwError(() => err));
return new EBirrProvider(
config as never,
{
post,
} as never,
).purchase(input);
}
it.each([
// Node's TCP connect timeout — the SYN was never answered (blocked port / no whitelist).
["connect ETIMEDOUT 197.156.83.125:443", "ETIMEDOUT"],
["connect ECONNREFUSED 10.0.0.1:443", "ECONNREFUSED"],
["getaddrinfo ENOTFOUND testpayments.ebirr.com", "ENOTFOUND"],
["Invalid URL", "ERR_INVALID_URL"],
["certificate has expired", "CERT_HAS_EXPIRED"],
])("fails fast on %s — it never reached eBirr", async (message, code) => {
const result = await purchaseWithTransportError(message, code);
expect(result.status).toBe(ProviderPaymentStatus.FAILED);
// A cause the payer can act on, not the generic "please try again".
expect(result.failureMessage).toMatch(/could not reach ebirr/i);
});
it.each([
// Axios's own read timeout reported with the ETIMEDOUT code (clarifyTimeoutError) — the
// request WAS sent, so this must not be confused with a connect timeout.
["timeout of 45000ms exceeded", "ETIMEDOUT"],
// Fired after the body went out; the debit may well have been processed.
["socket hang up", "ECONNRESET"],
["aborted", "ECONNABORTED"],
["something nobody anticipated", undefined],
])(
"keeps %s as PROCESSING — it may have been dispatched",
async (message, code) => {
const result = await purchaseWithTransportError(message, code);
expect(result.status).toBe(ProviderPaymentStatus.PROCESSING);
},
);
});
describe("queryStatus", () => {
it("looks the transaction up by referenceId via API_GETTRANINFO", async () => {
const { provider, post } = build({
schemaVersion: "1.0",
responseCode: "2001",
errorCode: "0",
responseMsg: "RCS_SUCCESS",
params: { status: "Approved", transactionId: "126895" },
});
const result = await provider.queryStatus("EDR-ORDER-1");
expect(post.mock.calls[0][1]).toMatchObject({
serviceName: "API_GETTRANINFO",
serviceParams: { referenceId: "EDR-ORDER-1" },
});
expect(result.status).toBe(ProviderPaymentStatus.SUCCEEDED);
expect(result.providerTxnId).toBe("126895");
});
it("treats an unknown transaction as REQUIRES_ACTION, not PROCESSING", async () => {
const { provider } = build({
schemaVersion: "1.0",
responseCode: "5001",
errorCode: "E10206",
responseMsg: "Failed to get transaction info",
});
const result = await provider.queryStatus("EDR-ORDER-1");
// The payer simply hasn't answered the prompt yet. Persisting a PROCESSING guess would
// let the sweep strand them on a push they never touched.
expect(result.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION);
});
it("honours a terminal state on a rejected envelope instead of hanging the payer", async () => {
// The purchase endpoint returns rejected envelopes that still carry a verdict
// (5206/DECLINED, 5001/TIMEOUT). If the query endpoint does the same, reading only the
// envelope would report REQUIRES_ACTION and leave the payer waiting until expiry.
const { provider } = build(userAborted);
const result = await provider.queryStatus("EDR-ORDER-1");
expect(result.status).toBe(ProviderPaymentStatus.FAILED);
expect(result.failureMessage).toBe("User Aborted");
});
});
describe("normalizeEthiopianMsisdn", () => {
it.each([
["+251923582676", "251923582676"],
["251923582676", "251923582676"],
["0923582676", "251923582676"],
["923582676", "251923582676"],
["+251 92 358 2676", "251923582676"],
["0712345678", "251712345678"],
])("normalises %s to %s", (raw, expected) => {
expect(normalizeEthiopianMsisdn(raw)).toBe(expected);
});
it.each(["", "not-a-number", "0812345678", "09123", "0912345678901"])(
"rejects %s rather than prompting a stranger's handset",
(raw) => {
expect(() => normalizeEthiopianMsisdn(raw)).toThrow();
},
);
});
});

View File

@@ -1,33 +0,0 @@
import { Injectable } from "@nestjs/common";
import { EBirrProvider, EBirrWebhookPayload } from "@edr/payment-providers";
import { WebhookProcessorService } from "../webhook-processor.service";
@Injectable()
export class EBirrWebhookService {
constructor(
private readonly provider: EBirrProvider,
private readonly processor: WebhookProcessorService,
) {}
async handle(payload: EBirrWebhookPayload): Promise<void> {
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
const mapped = this.provider.mapWebhookStatus(payload.tradeStatus);
await this.processor.process({
provider: this.provider.method,
externalEventId: `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`,
merchantOrderId: payload.orderNo,
providerTxnId: payload.tradeNo,
signatureValid,
rawStatus: payload.tradeStatus,
payload: payload as unknown as Record<string, unknown>,
result: {
status: mapped,
providerTxnId: payload.tradeNo,
failureCode: payload.tradeStatus,
},
});
}
}

View File

@@ -14,14 +14,12 @@ import {
CardWebhookPayload,
CbeBirrWebhookPayload,
DMoneyWebhookPayload,
EBirrWebhookPayload,
TelebirrWebhookPayload,
WaafiWebhookHeaders,
WaafiWebhookPayload,
} from "@edr/payment-providers";
import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service";
import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service";
import { EBirrWebhookService } from "./handlers/ebirr-webhook.service";
import { CardWebhookService } from "./handlers/card-webhook.service";
import { WaafiWebhookService } from "./handlers/waafi-webhook.service";
import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service";
@@ -40,7 +38,6 @@ export class WebhooksController {
constructor(
private readonly telebirr: TelebirrWebhookService,
private readonly cbeBirr: CbeBirrWebhookService,
private readonly eBirr: EBirrWebhookService,
private readonly card: CardWebhookService,
private readonly waafi: WaafiWebhookService,
private readonly dMoney: DMoneyWebhookService,
@@ -89,17 +86,9 @@ export class WebhooksController {
return { success: true };
}
@Post("ebirr")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "eBirr payment notification callback (Ethiopia)" })
async receiveEBirr(@Body() payload: EBirrWebhookPayload) {
try {
await this.eBirr.handle(payload);
} catch (err) {
this.logger.error(`eBirr webhook handler threw: ${this.message(err)}`);
}
return { code: "0000", message: "success" };
}
// No eBirr route by design: EbirrPay's API-payment flow has no callback. The API_PURCHASE
// response is the settlement notification, and API_GETTRANINFO is the authority for a missing
// or ambiguous one — see docs/ebirr/INTEGRATION.md.
@Post("card")
@HttpCode(HttpStatus.OK)

View File

@@ -8,7 +8,6 @@ import { WebhookProcessorService } from "./webhook-processor.service";
import { WebhooksController } from "./webhooks.controller";
import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service";
import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service";
import { EBirrWebhookService } from "./handlers/ebirr-webhook.service";
import { CardWebhookService } from "./handlers/card-webhook.service";
import { WaafiWebhookService } from "./handlers/waafi-webhook.service";
import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service";
@@ -25,7 +24,6 @@ import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service";
WebhookProcessorService,
TelebirrWebhookService,
CbeBirrWebhookService,
EBirrWebhookService,
CardWebhookService,
WaafiWebhookService,
DMoneyWebhookService,