mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
feat: (payment) add eBirr as synchronous API_PURCHASE wallet debit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
@@ -632,7 +684,8 @@ export class PaymentsService {
|
||||
failureCode: snapshot.failureCode ?? null,
|
||||
failureMessage: snapshot.failureMessage ?? null,
|
||||
rawInitiation: (snapshot as any).providerResponse
|
||||
? ((snapshot as any).providerResponse as unknown as Prisma.InputJsonValue)
|
||||
? ((snapshot as any)
|
||||
.providerResponse as unknown as Prisma.InputJsonValue)
|
||||
: Prisma.DbNull,
|
||||
};
|
||||
return this.prisma.paymentIntent.upsert({
|
||||
@@ -730,6 +783,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 +841,6 @@ export class PaymentsService {
|
||||
where: { bookingId },
|
||||
});
|
||||
|
||||
|
||||
if (local?.status === PaymentIntentStatus.SUCCEEDED) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
@@ -875,7 +932,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 +959,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 +1014,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 +1047,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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1100,7 +1182,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 +1194,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)}`,
|
||||
@@ -1206,7 +1292,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 +1314,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 +1409,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 +1504,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 +1539,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 +1558,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 +1639,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 +1748,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 +1763,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user