mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -91,11 +91,15 @@ export class LastMileRequestsController {
|
||||
return this.contractService.sign(id, dto, user?.id ?? null);
|
||||
}
|
||||
|
||||
// Customer-facing like :id/contract/view — the portal's confirm page opens
|
||||
// this straight from the departure notification link before the customer
|
||||
// has done anything else, so it can't be staff-only. Service ownership-
|
||||
// checks against the resolved company; staff may also open it.
|
||||
@Get(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
||||
@MixedAudience(FREIGHT_PERMS.lastMile.requestView)
|
||||
@ApiOperation({ summary: 'Get a last-mile confirmation request by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.requestsService.findById(id);
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) {
|
||||
return this.requestsService.findById(id, user?.id ?? null);
|
||||
}
|
||||
|
||||
// No @BookingStaff — the customer (portal) fills this, not backoffice staff.
|
||||
|
||||
@@ -199,11 +199,25 @@ export class LastMileRequestsService {
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<LastMileRequest> {
|
||||
/**
|
||||
* `userId` is set only when a portal customer calls this directly (the
|
||||
* confirm-page deep link from the departure notification, before they've
|
||||
* submitted or signed anything) — staff and every internal caller pass
|
||||
* nothing and skip the check, same convention as submit()/sign().
|
||||
*/
|
||||
async findById(id: string, userId?: string | null): Promise<LastMileRequest> {
|
||||
const record = await this.requestsRepository.findById(id, {
|
||||
relations: { booking: { company: true } },
|
||||
});
|
||||
if (!record) throw new NotFoundException(`Last-mile request ${id} not found`);
|
||||
|
||||
if (userId) {
|
||||
const companyId = await this.bookingsService.resolveCustomerCompanyId(userId);
|
||||
if (companyId && record.booking?.companyId && companyId !== record.booking.companyId) {
|
||||
throw new BadRequestException('This request does not belong to your company');
|
||||
}
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
|
||||
@@ -224,10 +224,10 @@ export const URL_CONSTANTS = {
|
||||
},
|
||||
|
||||
LAST_MILE_REQUESTS: {
|
||||
BY_ID: (id: string) => `/last-mile-requests/${id}`,
|
||||
SUBMIT: (id: string) => `/last-mile-requests/${id}/submit`,
|
||||
CONTRACT_VIEW: (id: string) => `/last-mile-requests/${id}/contract/view`,
|
||||
CONTRACT_DOCUMENT: (id: string) => `/last-mile-requests/${id}/contract/document`,
|
||||
CONTRACT_SIGN: (id: string) => `/last-mile-requests/${id}/contract/sign`,
|
||||
BY_ID: (id: string) => `/api/last-mile-requests/${id}`,
|
||||
SUBMIT: (id: string) => `/api/last-mile-requests/${id}/submit`,
|
||||
CONTRACT_VIEW: (id: string) => `/api/last-mile-requests/${id}/contract/view`,
|
||||
CONTRACT_DOCUMENT: (id: string) => `/api/last-mile-requests/${id}/contract/document`,
|
||||
CONTRACT_SIGN: (id: string) => `/api/last-mile-requests/${id}/contract/sign`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -769,7 +769,14 @@ export default function CompanyProfileForm({
|
||||
// themselves, so there is no delegation to evidence. Mirrors the API's own
|
||||
// waiver in `assertPoaDelegationSatisfied` — the two must agree, or this
|
||||
// demands a file the server would accept the submission without.
|
||||
const delegationRequired = (poaProvided || requirePoa) && !poaSameAsOwner;
|
||||
//
|
||||
// Split from `poaDue` — "there is a representative, so their details are
|
||||
// owed" — because a self-PoA keeps the second while dropping the first. The
|
||||
// API draws the same line (`poaDue` / `delegationDue` in
|
||||
// getOnboardingRequirements); anything that is about the *details* must key
|
||||
// on `poaDue`, only the paper keys on this.
|
||||
const poaDue = poaProvided || requirePoa;
|
||||
const delegationRequired = poaDue && !poaSameAsOwner;
|
||||
const delegationPresent =
|
||||
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
||||
(() => {
|
||||
@@ -853,9 +860,15 @@ export default function CompanyProfileForm({
|
||||
// here.
|
||||
if (gmGaps.name) requiredKeys.push("generalManagerName");
|
||||
if (gmGaps.phone) requiredKeys.push("generalManagerPhone");
|
||||
} else if (step === "poa" && delegationRequired) {
|
||||
} else if (step === "poa" && poaDue) {
|
||||
// Only once a PoA is required or provided: an untouched optional PoA is
|
||||
// still a step the customer may walk straight past.
|
||||
//
|
||||
// `poaDue`, NOT `delegationRequired`: the paper is waived for a self-PoA
|
||||
// but `REQUIRED_POA_FIELDS` is not, and the API reports every one of them
|
||||
// missing (`missingPoaFields` keys on its own `poaDue`) — which fails the
|
||||
// submit and clamps the resume back here. Keying this on the paper let the
|
||||
// customer walk past an input this step had already put on screen.
|
||||
if (poaGaps.name) requiredKeys.push("poaName");
|
||||
if (poaGaps.email) requiredKeys.push("poaEmail");
|
||||
if (poaGaps.phone) requiredKeys.push("poaPhone");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || '',
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ describe("PaymentsService", () => {
|
||||
findUniqueOrThrow: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
paymentMethod: {
|
||||
@@ -81,6 +82,7 @@ describe("PaymentsService", () => {
|
||||
const mockPaymentClient = {
|
||||
initiate: jest.fn(),
|
||||
getIntentByReference: jest.fn(),
|
||||
reconcileByReference: jest.fn(),
|
||||
};
|
||||
|
||||
// Mirrors the real ETB→major conversion: minor units → major price (TELEBIRR settles in ETB).
|
||||
@@ -242,6 +244,14 @@ describe("PaymentsService", () => {
|
||||
merchantOrderId: "PSG-MERCH-123",
|
||||
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
|
||||
});
|
||||
// syncIntentProjection applies the status in a separate guarded write (never demoting a
|
||||
// SUCCEEDED row), then reads the projection back — so this is what it returns.
|
||||
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
|
||||
id: "intent-1",
|
||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||
merchantOrderId: "PSG-MERCH-123",
|
||||
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
|
||||
});
|
||||
|
||||
const result = await service.initiatePayment({
|
||||
bookingId: "booking-1",
|
||||
@@ -479,6 +489,14 @@ describe("PaymentsService", () => {
|
||||
merchantOrderId: "PSG-MERCH-123",
|
||||
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
|
||||
});
|
||||
// See above: the projection is read back after the guarded status write.
|
||||
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
|
||||
id: "intent-1",
|
||||
bookingId: "booking-1",
|
||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||
merchantOrderId: "PSG-MERCH-123",
|
||||
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
|
||||
});
|
||||
|
||||
const result = await service.getIntentByBookingId("booking-1");
|
||||
|
||||
@@ -499,4 +517,49 @@ describe("PaymentsService", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: a booking confirmed between a sweep's candidate query and its turn in the loop
|
||||
// used to have its SUCCEEDED projection demoted to PROCESSING by the re-sync, with nothing
|
||||
// able to restore it (finalizePaymentSuccess only writes SUCCEEDED while the booking is still
|
||||
// PENDING_PAYMENT). A later stale payment.failed from an abandoned sibling attempt could then
|
||||
// push that same row to FAILED, because markPaymentFailed only shields SUCCEEDED/CANCELLED.
|
||||
describe("confirmed-booking projection integrity", () => {
|
||||
it("does not re-sync or cancel a booking confirmed since the caller's snapshot", async () => {
|
||||
mockPrisma.booking.findUnique.mockResolvedValue({ status: "CONFIRMED" });
|
||||
|
||||
const result = await service.reconcileAndConfirmIfPaid("booking-1");
|
||||
|
||||
expect(result).toEqual({ paid: true, verified: true });
|
||||
// Neither the payment service nor the projection is touched.
|
||||
expect(mockPaymentClient.reconcileByReference).not.toHaveBeenCalled();
|
||||
expect(mockPrisma.paymentIntent.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes the mirrored status only where the row is not already SUCCEEDED", async () => {
|
||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
||||
mockPaymentClient.getIntentByReference.mockResolvedValue(
|
||||
requiresActionSnapshot(ProviderMethod.TELEBIRR),
|
||||
);
|
||||
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
|
||||
id: "intent-1",
|
||||
bookingId: "booking-1",
|
||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||
});
|
||||
|
||||
await service.getIntentByBookingId("booking-1");
|
||||
|
||||
// The upsert must never carry a status on its update path...
|
||||
const upsertArg = mockPrisma.paymentIntent.upsert.mock.calls[0][0];
|
||||
expect(upsertArg.update).not.toHaveProperty("status");
|
||||
// ...the status arrives through a write guarded on the row not being SUCCEEDED, which is
|
||||
// what makes demoting the confirming payment structurally impossible.
|
||||
expect(mockPrisma.paymentIntent.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: { not: PaymentIntentStatus.SUCCEEDED },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
@@ -616,12 +668,13 @@ export class PaymentsService {
|
||||
bookingId: string,
|
||||
snapshot: PaymentIntentSnapshot,
|
||||
) {
|
||||
// Writing SUCCEEDED is finalizePaymentSuccess's job alone — it is the only place that can
|
||||
// enforce confirm-once atomically — so a SUCCEEDED snapshot syncs as PROCESSING here.
|
||||
const status =
|
||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
||||
? PaymentIntentStatus.PROCESSING
|
||||
: (snapshot.status as unknown as PaymentIntentStatus);
|
||||
const data = {
|
||||
status,
|
||||
method: snapshot.provider as unknown as PaymentMethodType,
|
||||
merchantOrderId: snapshot.merchantOrderId,
|
||||
clientAction: snapshot.clientAction
|
||||
@@ -632,10 +685,11 @@ 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,
|
||||
};
|
||||
return this.prisma.paymentIntent.upsert({
|
||||
await this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId },
|
||||
// amountMinor/currency are refreshed on update too: a cross-currency method switch
|
||||
// (e.g. Waafi/USD → Telebirr/ETB) re-initiates over the same row, and the projection
|
||||
@@ -649,9 +703,17 @@ export class PaymentsService {
|
||||
bookingId,
|
||||
amountMinor: snapshot.amountMinor,
|
||||
currency: snapshot.currency,
|
||||
status,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.paymentIntent.updateMany({
|
||||
where: { bookingId, status: { not: PaymentIntentStatus.SUCCEEDED } },
|
||||
data: { status },
|
||||
});
|
||||
|
||||
return this.prisma.paymentIntent.findUniqueOrThrow({ where: { bookingId } });
|
||||
}
|
||||
|
||||
private async initiateWalletPayment(
|
||||
@@ -730,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -783,7 +850,6 @@ export class PaymentsService {
|
||||
where: { bookingId },
|
||||
});
|
||||
|
||||
|
||||
if (local?.status === PaymentIntentStatus.SUCCEEDED) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
@@ -875,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 };
|
||||
}
|
||||
|
||||
@@ -897,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,
|
||||
@@ -949,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;
|
||||
@@ -975,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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1029,6 +1120,14 @@ export class PaymentsService {
|
||||
async reconcileAndConfirmIfPaid(
|
||||
bookingId: string,
|
||||
): Promise<{ paid: boolean; verified: boolean }> {
|
||||
const current = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
select: { status: true },
|
||||
});
|
||||
if (current?.status === "CONFIRMED") {
|
||||
return { paid: true, verified: true };
|
||||
}
|
||||
|
||||
const settlement = await this.paymentClient.reconcileByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
bookingId,
|
||||
@@ -1100,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);
|
||||
@@ -1110,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)}`,
|
||||
@@ -1161,12 +1264,31 @@ export class PaymentsService {
|
||||
});
|
||||
|
||||
if (confirmed === 0) {
|
||||
// Booking already confirmed by another payment (or not payable and not forced). This capture
|
||||
// is registered on the payment-api ledger; do not confirm, ticket, or touch this row.
|
||||
this.logger.error(
|
||||
`Capture on non-payable booking ${booking.id} (status=${booking.status}), intent ${intent.id} ` +
|
||||
`txn=${input.providerTxnId ?? intent.providerTxnId ?? "n/a"} — registered in payment-api; not confirming`,
|
||||
);
|
||||
const recordsConfirmingCapture =
|
||||
booking.status === "CONFIRMED" && intent.paidAt != null;
|
||||
if (recordsConfirmingCapture) {
|
||||
const { count } = await this.prisma.paymentIntent.updateMany({
|
||||
where: { id: intent.id, status: { not: PaymentIntentStatus.SUCCEEDED } },
|
||||
data: { status: PaymentIntentStatus.SUCCEEDED },
|
||||
});
|
||||
if (count > 0) {
|
||||
this.logger.warn(
|
||||
`Restored demoted payment projection for booking ${booking.id} ` +
|
||||
`(intent ${intent.id}): ${intent.status} → SUCCEEDED`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const duplicateCapture =
|
||||
input.providerTxnId != null &&
|
||||
intent.providerTxnId != null &&
|
||||
input.providerTxnId !== intent.providerTxnId;
|
||||
if (duplicateCapture || !recordsConfirmingCapture) {
|
||||
this.logger.error(
|
||||
`Capture on non-payable booking ${booking.id} (status=${booking.status}), intent ${intent.id} ` +
|
||||
`txn=${input.providerTxnId ?? intent.providerTxnId ?? "n/a"} — registered in payment-api; not confirming`,
|
||||
);
|
||||
}
|
||||
return { alreadyFinalized: true };
|
||||
}
|
||||
|
||||
@@ -1206,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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1226,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 };
|
||||
}
|
||||
|
||||
@@ -1303,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(
|
||||
@@ -1397,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: {
|
||||
@@ -1425,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) {
|
||||
@@ -1441,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;
|
||||
});
|
||||
}
|
||||
@@ -1512,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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1593,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,
|
||||
});
|
||||
|
||||
@@ -1608,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -60,6 +60,15 @@ export type {
|
||||
WaafiGetTranInfoResponse,
|
||||
} from './providers/waafi/waafi.types';
|
||||
|
||||
// EbirrPay API-payment request/response types (same ASM envelope as Waafi — see ebirr.types.ts)
|
||||
export type {
|
||||
EbirrState,
|
||||
EbirrPurchaseRequest,
|
||||
EbirrPurchaseResponse,
|
||||
EbirrGetTranInfoRequest,
|
||||
EbirrGetTranInfoResponse,
|
||||
} from './providers/ebirr/ebirr.types';
|
||||
|
||||
// CAC Bank request/response types
|
||||
export type {
|
||||
CacSigninRequest,
|
||||
@@ -76,7 +85,7 @@ export type {
|
||||
// Webhook payload types
|
||||
export type { TelebirrWebhookPayload } from './webhooks/telebirr-webhook.types';
|
||||
export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types';
|
||||
export type { EBirrWebhookPayload } from './webhooks/ebirr-webhook.types';
|
||||
// (no eBirr webhook type: EbirrPay has no callback — the API_PURCHASE response is the result)
|
||||
export type { CardWebhookPayload } from './webhooks/card-webhook.types';
|
||||
export type {
|
||||
WaafiWebhookPayload,
|
||||
@@ -88,5 +97,8 @@ export type {
|
||||
} from './webhooks/waafi-webhook.types';
|
||||
export type { DMoneyWebhookPayload } from './webhooks/dmoney-webhook.types';
|
||||
|
||||
// Phone-number helpers
|
||||
export { normalizeEthiopianMsisdn, maskMsisdn } from './utils/msisdn';
|
||||
|
||||
// DI token for injecting all providers as an array (future multi-provider wiring)
|
||||
export const PAYMENT_PROVIDERS = Symbol('PAYMENT_PROVIDERS');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import {
|
||||
@@ -12,225 +12,568 @@ import {
|
||||
import { AxiosError, AxiosRequestConfig } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import * as crypto from "node:crypto";
|
||||
import * as https from "node:https";
|
||||
import { maskMsisdn, normalizeEthiopianMsisdn } from "../../utils/msisdn";
|
||||
import {
|
||||
EbirrGetTranInfoRequest,
|
||||
EbirrGetTranInfoResponse,
|
||||
EbirrPurchaseRequest,
|
||||
EbirrPurchaseResponse,
|
||||
} from "./ebirr.types";
|
||||
|
||||
interface EBirrInitiateRequest {
|
||||
merchantCode: string;
|
||||
orderNo: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
notifyUrl: string;
|
||||
returnUrl: string;
|
||||
timestamp: number;
|
||||
sign: string;
|
||||
}
|
||||
/** EbirrPay's "request processed" envelope code. Says nothing about the payment outcome. */
|
||||
const EBIRR_SUCCESS_CODE = "2001";
|
||||
/** Status queries are quick lookups — they must not inherit the long purchase timeout. */
|
||||
const EBIRR_QUERY_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface EBirrInitiateResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
data?: {
|
||||
orderNo: string;
|
||||
payUrl: string;
|
||||
expireTime: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface EBirrQueryResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
data?: {
|
||||
orderNo: string;
|
||||
tradeStatus: string;
|
||||
tradeNo?: string;
|
||||
totalAmount?: number;
|
||||
payTime?: number;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Transport errors that prove the request **never reached eBirr**: a bad URL, DNS, the TCP
|
||||
* connect and the TLS handshake all fail before a single byte of the body is written.
|
||||
*
|
||||
* This distinction is the whole reason the set exists. No debit can exist for a request that was
|
||||
* never sent, so there is nothing for `API_GETTRANINFO` to find — reporting these as PROCESSING
|
||||
* leaves the payer staring at "check your phone" for a prompt that was never pushed, until the
|
||||
* intent expires. They are a payment failure, and the payer should be told immediately.
|
||||
*
|
||||
* Deliberately an allowlist. Anything not named here — notably `ECONNRESET` and `ECONNABORTED`,
|
||||
* which can both fire *after* the body went out — falls through to PROCESSING, because vendor doc
|
||||
* §10 is explicit that an ambiguous send must be resolved by querying, never by assuming failure.
|
||||
*/
|
||||
const EBIRR_UNDISPATCHED_CODES = new Set([
|
||||
"ERR_INVALID_URL", // empty/misconfigured EBIRR_BASE_URL — no socket is ever opened
|
||||
"ENOTFOUND", // DNS: host does not resolve
|
||||
"EAI_AGAIN", // DNS: resolver timed out
|
||||
"ECONNREFUSED", // TCP: port closed
|
||||
"EHOSTUNREACH",
|
||||
"ENETUNREACH",
|
||||
"CERT_HAS_EXPIRED", // TLS: handshake fails before the request is written
|
||||
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
||||
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
||||
"ERR_TLS_CERT_ALTNAME_INVALID",
|
||||
]);
|
||||
|
||||
/**
|
||||
* EbirrPay — direct mobile-wallet debit (docs/ebirr/INTEGRATION.md).
|
||||
*
|
||||
* Unlike every other gateway in this package, eBirr has **no hosted page, no redirect and no
|
||||
* webhook**. `API_PURCHASE` pushes a PIN prompt to the payer's handset over USSD and holds the
|
||||
* HTTP connection open until they approve or decline it — the synchronous response *is* the
|
||||
* settlement notification.
|
||||
*
|
||||
* The charge is therefore split in two:
|
||||
*
|
||||
* - `initiate()` performs **no I/O**. It validates the payer account and builds the
|
||||
* `AWAIT_PUSH` client action used as the fallback when the debit outlives its timeout.
|
||||
* - `purchase()` issues the actual blocking debit. payment-api's IntentsService awaits it on
|
||||
* the request path (`settleEBirrPurchase`) so the payer gets the verdict in the initiate
|
||||
* response — there is no webhook, so this response *is* the settlement notification.
|
||||
*
|
||||
* The wait is bounded by `EBIRR_PURCHASE_TIMEOUT_MS` (45s), which has to stay under the chain's
|
||||
* own limits — the passenger API's 60s `PAYMENT_API_HTTP_TIMEOUT_MS` and nginx's 60s default
|
||||
* `proxy_read_timeout`. A payer slower than that yields PROCESSING and the intent falls back to
|
||||
* `AWAIT_PUSH` for the client poll; the reconciliation sweep settles it via `queryStatus`
|
||||
* (`API_GETTRANINFO`), which is the authority vendor doc §10 points at for missing or ambiguous
|
||||
* responses. That path is rare but must not be removed: the money may already have moved.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EBirrProvider implements PaymentProvider {
|
||||
export class EBirrProvider implements PaymentProvider, OnModuleInit {
|
||||
readonly method = ProviderMethod.EBIRR;
|
||||
private readonly logger = new Logger(EBirrProvider.name);
|
||||
private readonly httpsAgent: https.Agent;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {}
|
||||
) {
|
||||
const insecure = this.config.get<boolean>("ebirr.insecureTls");
|
||||
if (insecure) {
|
||||
this.logger.warn(
|
||||
"EBIRR_INSECURE_TLS=true — TLS verification disabled for eBirr calls. DEV ONLY.",
|
||||
);
|
||||
}
|
||||
this.httpsAgent = new https.Agent({ rejectUnauthorized: !insecure });
|
||||
}
|
||||
|
||||
/** Log the effective eBirr config once at startup (secrets masked) so misconfig is visible. */
|
||||
onModuleInit(): void {
|
||||
this.logger.log(
|
||||
`eBirr config resolved: ${JSON.stringify(this.effectiveConfig())}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Snapshot of every resolved eBirr env value; secret fields are masked, not printed raw. */
|
||||
private effectiveConfig(): Record<string, unknown> {
|
||||
return {
|
||||
EBIRR_BASE_URL: this.baseUrl || "(empty)",
|
||||
EBIRR_MERCHANT_UID: this.merchantUid || "(empty)",
|
||||
EBIRR_API_USER_ID: this.apiUserId || "(empty)",
|
||||
EBIRR_API_KEY: this.mask(this.apiKey),
|
||||
EBIRR_PAYMENT_METHOD: this.paymentMethod,
|
||||
EBIRR_CHANNEL_NAME: this.channelName,
|
||||
EBIRR_PURCHASE_TIMEOUT_MS: this.purchaseTimeoutMs,
|
||||
EBIRR_PUSH_TTL_MS: this.pushTtlMs,
|
||||
EBIRR_INSECURE_TLS:
|
||||
this.config.get<boolean>("ebirr.insecureTls") ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mask a secret to `set(len=N,…abcd)` / `(empty)` so presence & length are visible but not the value. */
|
||||
private mask(value: string): string {
|
||||
if (!value) return "(empty)";
|
||||
const tail = value.length > 4 ? value.slice(-4) : "";
|
||||
return `set(len=${value.length},…${tail})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the push debit. Deliberately does no network I/O — the charge itself is `purchase()`.
|
||||
*
|
||||
* `providerOrderId` is our own `merchantOrderId`: this flow mints no separate order id (the
|
||||
* `orderId` the vendor doc §5.4 mentions belongs to the HPP family we don't use), and
|
||||
* `queryStatus` looks the transaction up by `referenceId` anyway.
|
||||
*/
|
||||
async initiate(
|
||||
input: ProviderInitiationInput,
|
||||
): Promise<ProviderInitiationResult> {
|
||||
const amount = input.amountMinor / 100;
|
||||
const timestamp = Date.now();
|
||||
if (!input.payerAccount?.trim()) {
|
||||
throw new Error(
|
||||
"eBirr requires payerAccount (the customer's mobile-wallet number)",
|
||||
);
|
||||
}
|
||||
// Throws on a malformed number rather than pushing a PIN prompt to the wrong handset.
|
||||
const accountNo = normalizeEthiopianMsisdn(input.payerAccount);
|
||||
|
||||
const requestBody: EBirrInitiateRequest = {
|
||||
merchantCode: this.merchantCode,
|
||||
orderNo: input.merchantOrderId,
|
||||
amount,
|
||||
currency: input.currency,
|
||||
subject: `EDR Ticket`,
|
||||
body: `Order ${input.orderRef}`,
|
||||
notifyUrl: this.notifyUrl,
|
||||
// Per-transaction browser return target (each calling app has its own UI); config is fallback.
|
||||
returnUrl: input.returnUrl ?? this.returnUrl,
|
||||
timestamp,
|
||||
sign: this.signRequest({
|
||||
merchantCode: this.merchantCode,
|
||||
orderNo: input.merchantOrderId,
|
||||
amount,
|
||||
timestamp,
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await this.postJson<EBirrInitiateResponse>(
|
||||
`${this.baseUrl}/gateway/api/pay/create`,
|
||||
requestBody,
|
||||
this.logger.log(
|
||||
`eBirr initiate ref=${input.merchantOrderId} account=${maskMsisdn(accountNo)} ` +
|
||||
`currency=${input.currency} amount=${this.toAmount(input.amountMinor)} ` +
|
||||
`(amountMinorIn=${input.amountMinor})`,
|
||||
);
|
||||
|
||||
if (response.code !== "0000" || !response.data?.orderNo) {
|
||||
throw new Error(`eBirr initiate failed: ${response.message}`);
|
||||
}
|
||||
|
||||
const expiresAt = new Date(response.data.expireTime);
|
||||
|
||||
return {
|
||||
providerOrderId: response.data.orderNo,
|
||||
clientAction: { type: "REDIRECT", url: response.data.payUrl },
|
||||
expiresAt,
|
||||
providerOrderId: input.merchantOrderId,
|
||||
clientAction: {
|
||||
type: "AWAIT_PUSH",
|
||||
message:
|
||||
"Check your phone and enter your eBirr PIN to approve the payment.",
|
||||
payerAccountMasked: maskMsisdn(accountNo),
|
||||
},
|
||||
expiresAt: new Date(Date.now() + this.pushTtlMs),
|
||||
rawInitiation: {
|
||||
request: this.sanitize(requestBody),
|
||||
response,
|
||||
request: this.sanitizeRequest(
|
||||
this.buildPurchaseRequest(input, accountNo),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
|
||||
const timestamp = Date.now();
|
||||
const requestBody = {
|
||||
merchantCode: this.merchantCode,
|
||||
orderNo: merchantOrderId,
|
||||
timestamp,
|
||||
sign: this.signRequest({
|
||||
merchantCode: this.merchantCode,
|
||||
orderNo: merchantOrderId,
|
||||
timestamp,
|
||||
}),
|
||||
};
|
||||
/**
|
||||
* Issue the debit. Blocks for as long as the payer takes to enter their PIN, so it must be
|
||||
* called off the request path.
|
||||
*
|
||||
* Never throws: a transport failure or timeout is reported as PROCESSING, not FAILED. The money
|
||||
* may well have moved, and the vendor doc §10 is explicit that a timeout must be resolved by
|
||||
* querying, not by assuming failure and re-charging.
|
||||
*/
|
||||
async purchase(input: ProviderInitiationInput): Promise<ProviderStatus> {
|
||||
const accountNo = normalizeEthiopianMsisdn(input.payerAccount ?? "");
|
||||
const requestBody = this.buildPurchaseRequest(input, accountNo);
|
||||
const url = `${this.baseUrl}/asm`;
|
||||
|
||||
const response = await this.postJson<EBirrQueryResponse>(
|
||||
`${this.baseUrl}/gateway/api/pay/query`,
|
||||
requestBody,
|
||||
this.logger.log(
|
||||
`eBirr API_PURCHASE → ${url} ref=${input.merchantOrderId} ` +
|
||||
`account=${maskMsisdn(accountNo)} amount=${requestBody.serviceParams.transactionInfo.amount} ` +
|
||||
`${input.currency} (awaiting payer PIN, up to ${this.purchaseTimeoutMs}ms)`,
|
||||
);
|
||||
|
||||
if (response.code !== "0000" || !response.data) {
|
||||
throw new Error(`eBirr query failed: ${response.message}`);
|
||||
let response: EbirrPurchaseResponse;
|
||||
try {
|
||||
response = await this.postJson<EbirrPurchaseResponse>(
|
||||
url,
|
||||
requestBody,
|
||||
this.purchaseTimeoutMs,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const code = (err as { code?: string })?.code;
|
||||
|
||||
// Never sent: no debit exists, so there is nothing to reconcile and no reason to make the
|
||||
// payer wait. Fail it now with a cause they can act on.
|
||||
if (this.isUndispatched(err)) {
|
||||
this.logger.error(
|
||||
`eBirr API_PURCHASE ${input.merchantOrderId} was never dispatched (${code ?? "n/a"}: ` +
|
||||
`${message}) — no debit exists, failing the intent instead of awaiting reconciliation`,
|
||||
);
|
||||
return {
|
||||
status: ProviderPaymentStatus.FAILED,
|
||||
failureCode: code ?? "EBIRR_UNREACHABLE",
|
||||
failureMessage:
|
||||
"Could not reach eBirr — no payment was taken. Please try again.",
|
||||
rawResponse: { error: message, code, dispatched: false },
|
||||
};
|
||||
}
|
||||
|
||||
// Sent but unanswered: unresolved, NOT failed. The sweep settles it via API_GETTRANINFO.
|
||||
this.logger.warn(
|
||||
`eBirr API_PURCHASE ${input.merchantOrderId} did not return a verdict ` +
|
||||
`(${message}) — leaving PROCESSING for reconciliation`,
|
||||
);
|
||||
return {
|
||||
status: ProviderPaymentStatus.PROCESSING,
|
||||
rawResponse: { error: message, code, dispatched: true },
|
||||
};
|
||||
}
|
||||
|
||||
const mapped = this.mapStatus(response.data.tradeStatus);
|
||||
const rawResponse = response as unknown as Record<string, unknown>;
|
||||
const state = response.params?.state;
|
||||
const mapped = this.resolveVerdict(response.responseCode, state);
|
||||
|
||||
if (mapped === ProviderPaymentStatus.FAILED) {
|
||||
this.logger.error(
|
||||
`eBirr API_PURCHASE ${input.merchantOrderId} rejected: ` +
|
||||
`${response.responseCode}/${response.errorCode} ${response.responseMsg} ` +
|
||||
`state=${state ?? "n/a"} (txn=${response.params?.transactionId ?? "n/a"})`,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(
|
||||
`eBirr API_PURCHASE ${input.merchantOrderId} state=${state ?? "n/a"} → ${mapped} ` +
|
||||
`(txn=${response.params?.transactionId ?? "n/a"}, order=${response.params?.orderId ?? "n/a"})`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId: response.data.tradeNo,
|
||||
// Present on rejected responses too (eBirr records the failed attempt), so this is
|
||||
// captured regardless of verdict — it is what reconciliation quotes back to eBirr.
|
||||
providerTxnId: response.params?.transactionId,
|
||||
failureCode:
|
||||
mapped === ProviderPaymentStatus.FAILED
|
||||
? response.data.tradeStatus
|
||||
? response.errorCode || response.responseCode
|
||||
: undefined,
|
||||
failureMessage:
|
||||
mapped === ProviderPaymentStatus.FAILED
|
||||
? // `params.description` is the specific cause ("Invalid Credentials"); responseMsg is
|
||||
// the generic wrapper ("Payment Failed (Invalid Credentials)").
|
||||
(response.params?.description ?? response.responseMsg)
|
||||
: undefined,
|
||||
rawResponse,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Did this error happen *before* the request was written to the wire?
|
||||
*
|
||||
* Only errors we can prove were never dispatched may be reported as FAILED — everything else
|
||||
* has to stay PROCESSING, because a request eBirr received may have moved money.
|
||||
*/
|
||||
private isUndispatched(err: unknown): boolean {
|
||||
// An HTTP response came back — whatever its status, eBirr received the request.
|
||||
if (err instanceof AxiosError && err.response) return false;
|
||||
|
||||
const code = (err as { code?: string })?.code;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
// ETIMEDOUT is ambiguous and has to be read from the message. Node reports a TCP connect
|
||||
// timeout as `connect ETIMEDOUT <ip>:<port>` — nothing was sent. Axios reports its own *read*
|
||||
// timeout with the same code when `clarifyTimeoutError` is set, but phrases it
|
||||
// "timeout of Nms exceeded" — that one was sent and is still in flight.
|
||||
if (code === "ETIMEDOUT") return message.startsWith("connect ");
|
||||
|
||||
return !!code && EBIRR_UNDISPATCHED_CODES.has(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the outcome from an eBirr response.
|
||||
*
|
||||
* Observed against the live sandbox: a *rejected* envelope still carries the authoritative
|
||||
* verdict in `params.state` — e.g. `5206/E10205` with `state: DECLINED`, and `5001/4004`
|
||||
* ("User Aborted") with `state: TIMEOUT`. So `state` wins whenever it is present; the envelope
|
||||
* code is only the fallback for responses that carry no `params` at all.
|
||||
*
|
||||
* The one thing `state` may never do is promote a rejected envelope to SUCCEEDED — success
|
||||
* requires both a 2001 envelope and an approving state.
|
||||
*/
|
||||
private resolveVerdict(
|
||||
responseCode: string,
|
||||
state: string | undefined,
|
||||
): ProviderPaymentStatus {
|
||||
const envelopeOk = responseCode === EBIRR_SUCCESS_CODE;
|
||||
if (!state) {
|
||||
return envelopeOk
|
||||
? ProviderPaymentStatus.PROCESSING
|
||||
: ProviderPaymentStatus.FAILED;
|
||||
}
|
||||
const mapped = this.mapStatus(state);
|
||||
if (mapped === ProviderPaymentStatus.SUCCEEDED && !envelopeOk) {
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
|
||||
const response = await this.postJson<EbirrGetTranInfoResponse>(
|
||||
`${this.baseUrl}/asm`,
|
||||
this.buildGetTranInfoRequest(merchantOrderId),
|
||||
EBIRR_QUERY_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
const rawState =
|
||||
response.params?.status ??
|
||||
response.params?.state ??
|
||||
response.params?.tranStatusDesc;
|
||||
|
||||
// A rejected envelope that still carries a state is a real verdict — the purchase endpoint
|
||||
// demonstrably does this (5206/DECLINED, 5001/TIMEOUT), so don't assume the query endpoint
|
||||
// won't. Trust the state; only fall back to the envelope when there is none.
|
||||
if (rawState) {
|
||||
const mapped = this.resolveVerdict(response.responseCode, rawState);
|
||||
this.logger.log(
|
||||
`eBirr API_GETTRANINFO ${merchantOrderId} state=${rawState} → ${mapped} ` +
|
||||
`(txn=${response.params?.transactionId ?? "n/a"}, order=${response.params?.orderId ?? "n/a"})`,
|
||||
);
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId: response.params?.transactionId,
|
||||
failureCode:
|
||||
mapped === ProviderPaymentStatus.FAILED
|
||||
? response.errorCode || rawState
|
||||
: undefined,
|
||||
failureMessage:
|
||||
mapped === ProviderPaymentStatus.FAILED
|
||||
? (response.params?.description ?? response.responseMsg)
|
||||
: undefined,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
// No state at all. Same convention as waafi/cac-bank: eBirr returns a bare error envelope
|
||||
// for a transaction it has no record of, which means the payer hasn't answered the prompt
|
||||
// yet — that is REQUIRES_ACTION (still waiting on the handset), NOT PROCESSING. Persisting a
|
||||
// PROCESSING guess would let the sweep strand a payer on a push they never touched. The
|
||||
// intent still resolves: via purchase()'s verdict, or via expiresAt.
|
||||
if (response.responseCode !== EBIRR_SUCCESS_CODE) {
|
||||
this.logger.warn(
|
||||
`eBirr API_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ` +
|
||||
`${response.responseMsg} — no state returned, treating as still awaiting the payer`,
|
||||
);
|
||||
return {
|
||||
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: ProviderPaymentStatus.PROCESSING,
|
||||
providerTxnId: response.params?.transactionId,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||
const { sign, ...data } = payload;
|
||||
if (!sign || typeof sign !== "string") return false;
|
||||
|
||||
const expectedSign = this.signRequest(data);
|
||||
return crypto.timingSafeEqual(Buffer.from(sign), Buffer.from(expectedSign));
|
||||
}
|
||||
|
||||
mapWebhookStatus(tradeStatus: string): ProviderPaymentStatus {
|
||||
return this.mapStatus(tradeStatus);
|
||||
}
|
||||
|
||||
private mapStatus(tradeStatus: string): ProviderPaymentStatus {
|
||||
switch (tradeStatus?.toUpperCase()) {
|
||||
case "TRADE_SUCCESS":
|
||||
private mapStatus(raw: string | undefined): ProviderPaymentStatus {
|
||||
switch (raw?.toUpperCase()) {
|
||||
case "APPROVED":
|
||||
case "SUCCESS":
|
||||
return ProviderPaymentStatus.SUCCEEDED;
|
||||
case "TRADE_CLOSED":
|
||||
case "TRADE_FAILED":
|
||||
case "CANCELED":
|
||||
case "CANCELLED":
|
||||
return ProviderPaymentStatus.CANCELLED;
|
||||
case "DECLINED":
|
||||
case "REJECTED":
|
||||
case "FAILED":
|
||||
case "EXPIRED":
|
||||
case "TIMEOUT":
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
case "WAIT_BUYER_PAY":
|
||||
case "PENDING":
|
||||
case "INITIATED":
|
||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||
case "PROCESSING":
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
default:
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
private signRequest(data: Record<string, unknown>): string {
|
||||
const sortedKeys = Object.keys(data).sort();
|
||||
const signString =
|
||||
sortedKeys.map((key) => `${key}=${data[key]}`).join("&") +
|
||||
`&key=${this.secretKey}`;
|
||||
|
||||
return crypto
|
||||
.createHash("md5")
|
||||
.update(signString)
|
||||
.digest("hex")
|
||||
.toUpperCase();
|
||||
private buildPurchaseRequest(
|
||||
input: ProviderInitiationInput,
|
||||
accountNo: string,
|
||||
): EbirrPurchaseRequest {
|
||||
return {
|
||||
schemaVersion: "1.0",
|
||||
requestId: crypto.randomUUID(),
|
||||
timestamp: this.timestamp(),
|
||||
channelName: this.channelName,
|
||||
serviceName: "API_PURCHASE",
|
||||
serviceParams: {
|
||||
merchantUid: this.merchantUid,
|
||||
apiKey: this.apiKey,
|
||||
apiUserId: this.apiUserId,
|
||||
paymentMethod: this.paymentMethod,
|
||||
payerInfo: { accountNo },
|
||||
transactionInfo: {
|
||||
referenceId: input.merchantOrderId,
|
||||
invoiceId: input.orderRef,
|
||||
amount: this.toAmount(input.amountMinor),
|
||||
// Charge exactly the currency the caller already converted to. The provider never
|
||||
// relabels the currency.
|
||||
currency: input.currency,
|
||||
description: `${input.orderRef}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
private buildGetTranInfoRequest(
|
||||
merchantOrderId: string,
|
||||
): EbirrGetTranInfoRequest {
|
||||
return {
|
||||
schemaVersion: "1.0",
|
||||
requestId: crypto.randomUUID(),
|
||||
timestamp: this.timestamp(),
|
||||
channelName: this.channelName,
|
||||
serviceName: "API_GETTRANINFO",
|
||||
serviceParams: {
|
||||
merchantUid: this.merchantUid,
|
||||
apiKey: this.apiKey,
|
||||
apiUserId: this.apiUserId,
|
||||
referenceId: merchantOrderId,
|
||||
},
|
||||
timeout: 10_000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `amountMinor` is a misnomer inherited from the shared contract — it carries the *major*
|
||||
* amount (see PaymentIntent.amountMinor: "real/major price; may be fractional"). Pass it
|
||||
* through at 2dp. Never divide by 100 (the pre-rewrite code did, charging 1/100th), and don't
|
||||
* borrow Waafi's `Math.trunc` — that is only correct for 0-decimal DJF and would drop ETB cents.
|
||||
*/
|
||||
private toAmount(amountMinor: number): number {
|
||||
return Number(amountMinor.toFixed(2));
|
||||
}
|
||||
|
||||
/** eBirr expects `YYYY-MM-DD HH:mm:ss` — not the epoch seconds Waafi's `/asm` accepts. */
|
||||
private timestamp(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
|
||||
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single funnel for every `/asm` call, so the full wire request and the full wire response are
|
||||
* both logged for `API_PURCHASE` and `API_GETTRANINFO` alike — at `log`, not `debug`, because
|
||||
* eBirr has no webhook and no dashboard we can see: when a payer disputes a debit these lines
|
||||
* are the only record of what we sent and what came back. Bodies pass through `sanitize*` so the
|
||||
* api key and the payer's full MSISDN never reach the log sink.
|
||||
*/
|
||||
private async postJson<T>(
|
||||
url: string,
|
||||
body: unknown,
|
||||
timeout: number,
|
||||
): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
timeout,
|
||||
httpsAgent: this.httpsAgent,
|
||||
};
|
||||
|
||||
const envelope = body as { serviceName?: string; requestId?: string };
|
||||
const tag = `${envelope.serviceName ?? "UNKNOWN"} requestId=${envelope.requestId ?? "n/a"}`;
|
||||
|
||||
this.logger.log(
|
||||
`eBirr → POST ${url} ${tag} request=${JSON.stringify(this.sanitizeRequest(body))}`,
|
||||
);
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||
this.logger.debug(
|
||||
`eBirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`,
|
||||
this.logger.log(
|
||||
`eBirr ← ${tag} status=${res.status} latency=${Date.now() - started}ms ` +
|
||||
`response=${JSON.stringify(this.sanitizeResponse(res.data))}`,
|
||||
);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
const latency = Date.now() - started;
|
||||
if (err instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`eBirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||
`eBirr ← ${tag} failed after ${latency}ms: status=${err.response?.status} ` +
|
||||
`response=${JSON.stringify(this.sanitizeResponse(err.response?.data))} ` +
|
||||
`code=${err.code} message=${err.message}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`eBirr POST ${url} threw: ${err instanceof Error ? err.message : err}`,
|
||||
`eBirr ← ${tag} threw after ${latency}ms: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(body: EBirrInitiateRequest): Record<string, unknown> {
|
||||
const { sign: _sign, ...rest } = body;
|
||||
return rest;
|
||||
/**
|
||||
* Redact the api key and mask the payer MSISDN in an outbound envelope. Covers both service
|
||||
* shapes: `API_PURCHASE` carries `payerInfo.accountNo`, `API_GETTRANINFO` carries neither.
|
||||
*/
|
||||
private sanitizeRequest(body: unknown): Record<string, unknown> {
|
||||
const envelope = body as EbirrPurchaseRequest;
|
||||
const params = envelope?.serviceParams as
|
||||
| (EbirrPurchaseRequest["serviceParams"] & Record<string, unknown>)
|
||||
| undefined;
|
||||
if (!params) return body as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
...envelope,
|
||||
serviceParams: {
|
||||
...params,
|
||||
apiKey: "***REDACTED***",
|
||||
...(params.payerInfo
|
||||
? { payerInfo: { accountNo: maskMsisdn(params.payerInfo.accountNo) } }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Responses carry no secret, but `params.accountNo` / `params.payerId` echo the payer's wallet
|
||||
* number back — mask those the same way the request side does.
|
||||
*/
|
||||
private sanitizeResponse(data: unknown): unknown {
|
||||
if (!data || typeof data !== "object") return data;
|
||||
|
||||
const response = data as { params?: Record<string, unknown> };
|
||||
if (!response.params) return data;
|
||||
|
||||
const params = { ...response.params };
|
||||
for (const key of ["accountNo", "payerId"]) {
|
||||
const value = params[key];
|
||||
if (typeof value === "string" && value) params[key] = maskMsisdn(value);
|
||||
}
|
||||
return { ...response, params };
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>("ebirr.baseUrl") ?? "";
|
||||
}
|
||||
private get merchantCode(): string {
|
||||
return this.config.get<string>("ebirr.merchantCode") ?? "";
|
||||
private get merchantUid(): string {
|
||||
return this.config.get<string>("ebirr.merchantUid") ?? "";
|
||||
}
|
||||
private get secretKey(): string {
|
||||
return this.config.get<string>("ebirr.secretKey") ?? "";
|
||||
private get apiKey(): string {
|
||||
return this.config.get<string>("ebirr.apiKey") ?? "";
|
||||
}
|
||||
private get notifyUrl(): string {
|
||||
return this.config.get<string>("ebirr.notifyUrl") ?? "";
|
||||
private get apiUserId(): string {
|
||||
return this.config.get<string>("ebirr.apiUserId") ?? "";
|
||||
}
|
||||
private get returnUrl(): string {
|
||||
return this.config.get<string>("ebirr.returnUrl") ?? "";
|
||||
private get paymentMethod(): string {
|
||||
return this.config.get<string>("ebirr.paymentMethod") ?? "MWALLET_ACCOUNT";
|
||||
}
|
||||
private get channelName(): string {
|
||||
return this.config.get<string>("ebirr.channelName") ?? "WEB";
|
||||
}
|
||||
/**
|
||||
* How long `purchase()` waits for the payer's PIN. This is awaited on the request path (see
|
||||
* IntentsService.settleEBirrPurchase), so it MUST stay below the passenger API's
|
||||
* PAYMENT_API_HTTP_TIMEOUT_MS (60s) and any proxy read timeout in front of it.
|
||||
*/
|
||||
private get purchaseTimeoutMs(): number {
|
||||
return this.config.get<number>("ebirr.purchaseTimeoutMs") ?? 45_000;
|
||||
}
|
||||
private get pushTtlMs(): number {
|
||||
return this.config.get<number>("ebirr.pushTtlMs") ?? 180_000;
|
||||
}
|
||||
}
|
||||
|
||||
143
packages/payment-providers/src/providers/ebirr/ebirr.types.ts
Normal file
143
packages/payment-providers/src/providers/ebirr/ebirr.types.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* EbirrPay (API Payment) request/response types — docs/ebirr/EbirrPay for API PAYMENT.md.
|
||||
*
|
||||
* EbirrPay is the same ASM platform as WaafiPay: every operation is multiplexed through a single
|
||||
* `POST /asm`, discriminated by `serviceName`, with the identical envelope and the identical
|
||||
* `responseCode === '2001'` convention. See `../waafi/waafi.types.ts`.
|
||||
*
|
||||
* We use the API family (`API_PURCHASE`, `API_GETTRANINFO`), which is a *direct wallet debit*:
|
||||
* the purchase call pushes a PIN prompt to the payer's handset over USSD and blocks until they
|
||||
* approve it. There is no hosted page, no redirect and no webhook — the vendor doc's §5 "Redirects
|
||||
* the customer to a secure Hosted Payment Page" and §8.3 "EbirrPay returns APIUrl and orderId" are
|
||||
* stale copy-paste from the HPP family, contradicted by §5.3's own response body. See
|
||||
* docs/ebirr/INTEGRATION.md.
|
||||
*/
|
||||
|
||||
/** Terminal/intermediate states reported by EbirrPay (`params.state` / `params.status`). */
|
||||
export type EbirrState =
|
||||
| 'APPROVED'
|
||||
| 'DECLINED'
|
||||
| 'FAILED'
|
||||
| 'CANCELLED'
|
||||
| 'EXPIRED'
|
||||
| 'TIMEOUT'
|
||||
| string;
|
||||
|
||||
/** Common request envelope shared by every `/asm` call (§3). */
|
||||
export interface EbirrRequestEnvelope<TServiceParams> {
|
||||
schemaVersion: '1.0';
|
||||
requestId: string;
|
||||
timestamp: string;
|
||||
channelName: string;
|
||||
serviceName: string;
|
||||
serviceParams: TServiceParams;
|
||||
}
|
||||
|
||||
/** Common response envelope. `responseCode === '2001'` means the request was processed. */
|
||||
export interface EbirrResponseEnvelope<TParams> {
|
||||
schemaVersion: string;
|
||||
timestamp: string;
|
||||
responseId: string;
|
||||
responseCode: string;
|
||||
errorCode: string;
|
||||
responseMsg: string;
|
||||
params?: TParams;
|
||||
}
|
||||
|
||||
// --- API_PURCHASE -----------------------------------------------------------------------------
|
||||
|
||||
export interface EbirrPurchaseServiceParams {
|
||||
merchantUid: string;
|
||||
/** Merchant API key (§4). The vendor doc also calls this `APIKey` in §6.2/§7.2 — same field. */
|
||||
apiKey: string;
|
||||
/** Doc §5.2 types this as a Number, but the live API accepts the string form. */
|
||||
apiUserId: string;
|
||||
paymentMethod: string;
|
||||
/**
|
||||
* The payer's wallet. Doc §5.2 names this `subscriptionId`, but the field the live sandbox
|
||||
* actually accepts is `accountNo` — verified by hand against testpayments.ebirr.com.
|
||||
*/
|
||||
payerInfo: {
|
||||
accountNo: string;
|
||||
};
|
||||
transactionInfo: {
|
||||
referenceId: string;
|
||||
/** Not listed in §5.2 but accepted by the live API and echoed back by API_GETTRANINFO (§7.3). */
|
||||
invoiceId?: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type EbirrPurchaseRequest = EbirrRequestEnvelope<EbirrPurchaseServiceParams>;
|
||||
|
||||
/**
|
||||
* §5.3 plus the fields the live sandbox actually returns.
|
||||
*
|
||||
* `state` is the authoritative outcome. Critically, it is present on **rejected** envelopes too,
|
||||
* so it must be read regardless of `responseCode` — observed live:
|
||||
*
|
||||
* 2001 / errorCode 0 → state APPROVED
|
||||
* 5206 / errorCode E10205 → state DECLINED, description "Invalid Credentials"
|
||||
* 5001 / errorCode 4004 → state TIMEOUT, description "User Aborted"
|
||||
*
|
||||
* `transactionId` and `orderId` are issued even for failed attempts.
|
||||
*/
|
||||
export interface EbirrPurchaseParams {
|
||||
accountNo?: string;
|
||||
accountType?: string;
|
||||
state?: EbirrState;
|
||||
merchantCharges?: string;
|
||||
referenceId?: string;
|
||||
transactionId?: string;
|
||||
/** EbirrPay's own order id (doc §11 "EbirrPay order tracking"). Not in §5.3's example. */
|
||||
orderId?: string;
|
||||
issuerTransactionId?: string;
|
||||
txAmount?: string;
|
||||
/** Specific failure cause, e.g. "Invalid Credentials" / "User Aborted". Not in §5.3. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type EbirrPurchaseResponse = EbirrResponseEnvelope<EbirrPurchaseParams>;
|
||||
|
||||
// --- API_GETTRANINFO --------------------------------------------------------------------------
|
||||
|
||||
export interface EbirrGetTranInfoServiceParams {
|
||||
merchantUid: string;
|
||||
apiKey: string;
|
||||
apiUserId: string;
|
||||
/** Either the merchant referenceId or the EbirrPay transactionId may be supplied. */
|
||||
referenceId?: string;
|
||||
transactionId?: string;
|
||||
}
|
||||
|
||||
export type EbirrGetTranInfoRequest = EbirrRequestEnvelope<EbirrGetTranInfoServiceParams>;
|
||||
|
||||
/**
|
||||
* §7.3 — field-for-field the same set as `WaafiGetTranInfoParams`.
|
||||
*
|
||||
* The doc renders the first key as `tranStatETBesc`, which is `tranStat` + `usD` + `esc`: the
|
||||
* vendor ran a global USD→ETB replace over the Waafi doc and corrupted `tranStatusDesc`. The real
|
||||
* field is `tranStatusDesc`.
|
||||
*/
|
||||
export interface EbirrGetTranInfoParams {
|
||||
tranStatusDesc?: string;
|
||||
amount?: string;
|
||||
payerId?: string;
|
||||
paymentMethod?: string;
|
||||
description?: string;
|
||||
tranDate?: string;
|
||||
currency?: string;
|
||||
invoiceId?: string;
|
||||
referenceId?: string;
|
||||
tranAmount?: string;
|
||||
transactionId?: string;
|
||||
orderId?: string;
|
||||
tranStatusId?: string;
|
||||
status?: EbirrState;
|
||||
/** Purchase responses use `state`; assume the query endpoint may too rather than betting on it. */
|
||||
state?: EbirrState;
|
||||
}
|
||||
|
||||
export type EbirrGetTranInfoResponse = EbirrResponseEnvelope<EbirrGetTranInfoParams>;
|
||||
53
packages/payment-providers/src/utils/msisdn.ts
Normal file
53
packages/payment-providers/src/utils/msisdn.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Ethiopian MSISDN normalisation.
|
||||
*
|
||||
* Passenger phone numbers are stored inconsistently — `+2519…`, `2519…` and local `09…` all
|
||||
* appear (see the variant-building comment in the passenger API's bookings.service). Ethiopian
|
||||
* mobile wallets want the bare international form with no `+` and no leading zero, e.g.
|
||||
* `251923582676`.
|
||||
*
|
||||
* This is deliberately separate from `normalizeCacMobile` (cac-bank.json.ts), which does the
|
||||
* opposite for Djibouti — it *strips* the 253 country code to a national number.
|
||||
*/
|
||||
|
||||
/** Ethiopian mobile subscriber numbers are 9 digits and always start with 9 (or 7 for Safaricom). */
|
||||
const ET_NATIONAL_LENGTH = 9;
|
||||
const ET_COUNTRY_CODE = '251';
|
||||
|
||||
/**
|
||||
* Convert any accepted Ethiopian phone format to the bare `251XXXXXXXXX` form.
|
||||
*
|
||||
* Accepts `+251923582676`, `251923582676`, `0923582676` and `923582676`, plus spaces, dashes and
|
||||
* parentheses anywhere. Throws on anything that isn't a plausible Ethiopian mobile number rather
|
||||
* than silently sending a wrong account — a mistyped number would push a PIN prompt to a
|
||||
* stranger's handset.
|
||||
*/
|
||||
export function normalizeEthiopianMsisdn(input: string): string {
|
||||
const digits = (input ?? '').replace(/[\s()+-]/g, '');
|
||||
if (!/^\d+$/.test(digits)) {
|
||||
throw new Error(`Invalid Ethiopian mobile number: ${input}`);
|
||||
}
|
||||
|
||||
let national: string;
|
||||
if (digits.startsWith('00' + ET_COUNTRY_CODE)) {
|
||||
national = digits.slice(2 + ET_COUNTRY_CODE.length);
|
||||
} else if (digits.startsWith(ET_COUNTRY_CODE)) {
|
||||
national = digits.slice(ET_COUNTRY_CODE.length);
|
||||
} else if (digits.startsWith('0')) {
|
||||
national = digits.slice(1);
|
||||
} else {
|
||||
national = digits;
|
||||
}
|
||||
|
||||
if (national.length !== ET_NATIONAL_LENGTH || !/^[79]/.test(national)) {
|
||||
throw new Error(`Invalid Ethiopian mobile number: ${input}`);
|
||||
}
|
||||
|
||||
return `${ET_COUNTRY_CODE}${national}`;
|
||||
}
|
||||
|
||||
/** Mask an MSISDN for display/logging: `251923582676` → `2519****2676`. */
|
||||
export function maskMsisdn(msisdn: string): string {
|
||||
if (msisdn.length <= 8) return '****';
|
||||
return `${msisdn.slice(0, 4)}****${msisdn.slice(-4)}`;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export interface EBirrWebhookPayload {
|
||||
merchantCode: string;
|
||||
orderNo: string;
|
||||
tradeStatus: string;
|
||||
tradeNo?: string;
|
||||
totalAmount?: number;
|
||||
currency?: string;
|
||||
payTime?: number;
|
||||
timestamp: number;
|
||||
sign: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -58,6 +58,17 @@ export type ClientAction =
|
||||
providerOrderId: string;
|
||||
message?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Wallet push-debit (eBirr): the provider has already prompted the payer on their own
|
||||
* handset (USSD/app PIN). There is nothing to navigate to and nothing to collect —
|
||||
* the client shows `message` and polls the intent until it turns terminal.
|
||||
*/
|
||||
type: "AWAIT_PUSH";
|
||||
message: string;
|
||||
/** Masked MSISDN the prompt was pushed to, so the payer can confirm it's their phone. */
|
||||
payerAccountMasked?: string;
|
||||
}
|
||||
| {
|
||||
/** CBE_BILL: show the bill reference the customer pays at any CBE channel. */
|
||||
type: "SHOW_BILL_REFERENCE";
|
||||
@@ -77,6 +88,8 @@ export interface ProviderInitiationInput {
|
||||
* Payer account identifier (e.g. mobile-wallet MSISDN in full international format).
|
||||
* Optional and provider-specific: some wallet providers (e.g. Waafi HPP with
|
||||
* MWALLET_ACCOUNT) require the payer's phone number up front to pre-fill the hosted page.
|
||||
* For push-debit providers (eBirr, CAC Bank) it is mandatory — it is the account the PIN
|
||||
* prompt is pushed to, so there is no hosted page that could collect it later.
|
||||
*/
|
||||
payerAccount?: string;
|
||||
/** Optional caller-supplied redirect targets for redirect/HPP-style providers. */
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Optional PostCSS configuration for applications that need it
|
||||
export const postcssConfig = {
|
||||
plugins: {
|
||||
|
||||
@@ -13,7 +13,10 @@ import { DataTableFooter } from "./footer";
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
status,
|
||||
// The body only renders under "success", but the footer renders regardless —
|
||||
// omitting status gave a blank table under a populated "Showing 1–N of N"
|
||||
// footer. Having rows to draw is the default case, so default to success.
|
||||
status = "success",
|
||||
onRowClick,
|
||||
rowStyle,
|
||||
rowClassName,
|
||||
|
||||
Reference in New Issue
Block a user