feat: (payment) add eBirr as synchronous API_PURCHASE wallet debit

This commit is contained in:
Abubeker Yasin
2026-08-10 11:36:05 +03:00
parent 78d69eb02b
commit 6471c3511a
19 changed files with 1591 additions and 380 deletions

View File

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

View File

@@ -86,8 +86,10 @@ export class PaymentsService {
) {}
async deletePayment(id: string) {
const intent = await this.prisma.paymentIntent.findUnique({ where: { id } });
if (!intent) throw new NotFoundException('Payment intent not found');
const intent = await this.prisma.paymentIntent.findUnique({
where: { id },
});
if (!intent) throw new NotFoundException("Payment intent not found");
await this.prisma.paymentIntent.delete({ where: { id } });
return { deleted: true, id };
}
@@ -147,20 +149,29 @@ export class PaymentsService {
// For package round-trip bookings the stored amountMinor may be the single-leg
// amount. Recompute from the tier price when applicable.
let amountMinor = item.amountMinor;
if (b?.packageId && b?.bookingType === 'ROUND_TRIP' && b?.priceTier?.priceMinor) {
if (
b?.packageId &&
b?.bookingType === "ROUND_TRIP" &&
b?.priceTier?.priceMinor
) {
const adultFare = b.priceTier.priceMinor * 2;
const childFare = Math.round(adultFare * 0.1);
const correctMinor = (b.adultCount || 1) * adultFare + (b.childCount || 0) * childFare;
const correctMinor =
(b.adultCount || 1) * adultFare + (b.childCount || 0) * childFare;
// Convert to the charge currency ratio: stored amountMinor is in charge currency
// (may be DJF/USD), but correctMinor is in ETB minor. Only override when the
// currency is ETB (most common case); for foreign currencies keep stored value.
if (item.currency === 'ETB') amountMinor = correctMinor;
if (item.currency === "ETB") amountMinor = correctMinor;
}
return {
id: item.id,
reference: item.id.substring(0, 8),
bookingId: item.bookingId,
booking: { bookingRef: b?.bookingRef, totalMinor: b?.totalMinor, currency: b?.currency },
booking: {
bookingRef: b?.bookingRef,
totalMinor: b?.totalMinor,
currency: b?.currency,
},
amountMinor,
currency: item.currency,
method: item.method,
@@ -187,7 +198,11 @@ export class PaymentsService {
priceTierId?: string | null;
displayTotalMinor?: number | null;
}): Promise<number> {
if (!booking.packageId || !booking.priceTierId || booking.bookingType !== 'ROUND_TRIP') {
if (
!booking.packageId ||
!booking.priceTierId ||
booking.bookingType !== "ROUND_TRIP"
) {
return booking.totalMinor;
}
// New bookings store displayTotalMinor from the frontend's reviewedTotalMinor; their
@@ -196,17 +211,30 @@ export class PaymentsService {
return booking.totalMinor;
}
// Legacy path: old bookings may have stored a single-leg totalMinor — recompute from tier.
const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: booking.priceTierId } });
const tier = await this.prisma.packagePriceTier.findUnique({
where: { id: booking.priceTierId },
});
if (!tier) return booking.totalMinor;
const seats = await this.prisma.bookingSeat.findMany({ where: { bookingId: booking.id, leg: 1 }, select: { passengerCategory: true } });
const adultCount = seats.filter(s => s.passengerCategory === 'ADULT').length || 1;
const childCount = seats.filter(s => s.passengerCategory === 'CHILD').length;
const seats = await this.prisma.bookingSeat.findMany({
where: { bookingId: booking.id, leg: 1 },
select: { passengerCategory: true },
});
const adultCount =
seats.filter((s) => s.passengerCategory === "ADULT").length || 1;
const childCount = seats.filter(
(s) => s.passengerCategory === "CHILD",
).length;
// tier.priceMinor may be in a non-ETB currency — convert to ETB so the result is
// always in the same units as totalMinor (which is always the ETB canonical).
const rawFare = tier.priceMinor * 2;
const adultFareMinor = tier.currency && (tier.currency as string) !== 'ETB'
? await this.currencyService.convertAmount(rawFare, tier.currency as any, 'ETB' as any)
: rawFare;
const adultFareMinor =
tier.currency && (tier.currency as string) !== "ETB"
? await this.currencyService.convertAmount(
rawFare,
tier.currency as any,
"ETB" as any,
)
: rawFare;
const childFareMinor = Math.round(adultFareMinor * 0.1);
return adultCount * adultFareMinor + childCount * childFareMinor;
}
@@ -233,6 +261,14 @@ export class PaymentsService {
);
}
// eBirr is a direct wallet debit — the PIN prompt is pushed to this number over USSD. There
// is no hosted page that could collect it later, so it must be supplied up front.
if (method === PaymentMethodType.EBIRR && !dto.payerAccount?.trim()) {
throw new BadRequestException(
"payerAccount (mobile wallet number) is required for eBirr",
);
}
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8). payerAccount is NOT
// required — CBE identifies the payer at its own channel.
if (
@@ -272,7 +308,9 @@ export class PaymentsService {
// Opening a session with less than MIN_PAYMENT_WINDOW_MINUTES left produces the worst possible
// outcome — the provider captures the money and the booking is already CANCELLED when the
// capture lands. WALLET is exempt (returned above): it is an instant internal balance debit.
const paymentDeadline = await this.computeBookingPaymentDeadline(booking.id);
const paymentDeadline = await this.computeBookingPaymentDeadline(
booking.id,
);
const sessionExpiresAt = paymentDeadline
? computePaymentSessionExpiry(paymentDeadline)
: undefined;
@@ -282,8 +320,8 @@ export class PaymentsService {
remainingMs <= 0
? "The payment window for this booking has expired. Please make a new booking."
: `Too little time is left to start a payment (${Math.ceil(remainingMs / 60000)} minute(s) ` +
`until this booking expires; at least ${MIN_PAYMENT_WINDOW_MINUTES} are required). ` +
`Please make a new booking.`,
`until this booking expires; at least ${MIN_PAYMENT_WINDOW_MINUTES} are required). ` +
`Please make a new booking.`,
);
}
@@ -307,8 +345,12 @@ export class PaymentsService {
? "ETB"
: (paymentMethod?.currency ?? booking.currency).toUpperCase();
const bookingDisplayCurrency = ((booking as any).displayCurrency ?? 'ETB').toUpperCase();
const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as number | null;
const bookingDisplayCurrency = (
(booking as any).displayCurrency ?? "ETB"
).toUpperCase();
const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as
| number
| null;
let chargeAmount: number;
if (method === PaymentMethodType.CBE_BILL) {
@@ -319,18 +361,24 @@ export class PaymentsService {
);
} else if (
chargeCurrency === bookingDisplayCurrency &&
chargeCurrency !== 'ETB' &&
chargeCurrency !== "ETB" &&
bookingDisplayTotalMinor != null
) {
// Display currency matches charge currency — use the pre-converted amount directly.
chargeAmount = this.currencyService.displayMinorToChargeMajor(bookingDisplayTotalMinor, chargeCurrency);
} else if (chargeCurrency === 'ETB') {
chargeAmount = this.currencyService.displayMinorToChargeMajor(booking.totalMinor, 'ETB');
chargeAmount = this.currencyService.displayMinorToChargeMajor(
bookingDisplayTotalMinor,
chargeCurrency,
);
} else if (chargeCurrency === "ETB") {
chargeAmount = this.currencyService.displayMinorToChargeMajor(
booking.totalMinor,
"ETB",
);
} else {
// Booking is in ETB — convert to the provider's settlement currency.
chargeAmount = await this.currencyService.convertMinorToChargeMajor(
booking.totalMinor,
booking.currency,
booking.currency,
chargeCurrency,
);
}
@@ -398,10 +446,15 @@ export class PaymentsService {
bookingId,
);
if (!snapshot) {
throw new NotFoundException("No active payment to confirm for this booking");
throw new NotFoundException(
"No active payment to confirm for this booking",
);
}
const confirmed = await this.paymentClient.confirmOtp(snapshot.intentId, otp);
const confirmed = await this.paymentClient.confirmOtp(
snapshot.intentId,
otp,
);
let intent = await this.syncIntentProjection(bookingId, confirmed);
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
@@ -553,9 +606,8 @@ export class PaymentsService {
[PaymentMethodType.CBE_BIRR]: {
returnUrl: process.env.CBE_RETURN_URL,
},
[PaymentMethodType.EBIRR]: {
returnUrl: process.env.EBIRR_RETURN_URL,
},
// No EBIRR entry: the payer never leaves the page — eBirr pushes a PIN prompt to their
// handset — so there is no browser bounce-back to configure.
[PaymentMethodType.CARD]: {
returnUrl: process.env.CARD_RETURN_URL,
},
@@ -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,
});
}
}