This commit is contained in:
Roba Boru
2026-06-17 16:50:56 +03:00
3 changed files with 99 additions and 1 deletions

View File

@@ -79,6 +79,28 @@ export class PaymentsController {
return this.service.getIntentByBookingId(bookingId);
}
@Get("waafi/return")
@ApiOperation({
summary:
"DEMO ONLY — confirm a Waafi payment from the browser-return params and return JSON for the " +
"UI to display. The frontend success page forwards the Waafi query params here. Gated by " +
"WAAFI_DEMO_TRUST_RETURN (INSECURE; real confirmation is the webhook/HPP_GETTRANINFO).",
})
@ApiQuery({ name: "referenceId", required: true })
@ApiQuery({ name: "state", required: true })
@ApiQuery({ name: "transactionId", required: false })
waafiReturn(
@Query("referenceId") referenceId: string,
@Query("state") state: string,
@Query("transactionId") transactionId: string,
) {
return this.service.confirmWaafiReturnDemo({
referenceId,
state,
transactionId,
});
}
@Post("refund")
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT)

View File

@@ -44,6 +44,8 @@ export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
private readonly walletDemoAutoSucceed = true;
private readonly waafiDemoTrustReturn = true;
constructor(
private prisma: PrismaService,
private seatsService: SeatsService,
@@ -192,6 +194,41 @@ export class PaymentsService {
return { returnUrl, failureUrl };
}
async confirmWaafiReturnDemo(params: {
referenceId?: string;
state?: string;
transactionId?: string;
}): Promise<{ confirmed: boolean; bookingId?: string; reason?: string }> {
if (!this.waafiDemoTrustReturn) {
return { confirmed: false, reason: "demo-disabled" };
}
if ((params.state ?? "").toUpperCase() !== "APPROVED") {
return { confirmed: false, reason: `not-approved (${params.state})` };
}
if (!params.referenceId) {
return { confirmed: false, reason: "missing-referenceId" };
}
const intent = await this.prisma.paymentIntent.findFirst({
where: { merchantOrderId: params.referenceId },
});
if (!intent) {
this.logger.warn(
`waafi demo return: no local intent for referenceId ${params.referenceId}`,
);
return { confirmed: false, reason: "intent-not-found" };
}
this.logger.warn(
`WAAFI_DEMO_TRUST_RETURN enabled — confirming booking ${intent.bookingId} from browser return (INSECURE, demo only)`,
);
await this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: params.transactionId,
});
return { confirmed: true, bookingId: intent.bookingId };
}
private async syncIntentProjection(
bookingId: string,
snapshot: PaymentIntentSnapshot,
@@ -453,6 +490,30 @@ export class PaymentsService {
});
}
/**
* Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as
* ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the
* whole confirmation. Falls back to "now" for missing/invalid/far-future/ancient values so the
* booking still confirms.
*/
private sanitizePaidAt(value?: Date): Date {
const now = new Date();
if (!value) return now;
const t = value.getTime();
const oneDayMs = 86_400_000;
if (
Number.isNaN(t) ||
t > now.getTime() + oneDayMs ||
t < Date.UTC(2000, 0, 1)
) {
this.logger.warn(
`finalizePaymentSuccess: implausible paidAt (epoch=${t}); using current time instead`,
);
return now;
}
return value;
}
async finalizePaymentSuccess(input: {
intentId: string;
providerTxnId?: string;
@@ -477,7 +538,7 @@ export class PaymentsService {
});
if (!booking) throw new NotFoundException("Booking not found");
const paidAt = input.paidAt ?? new Date();
const paidAt = this.sanitizePaidAt(input.paidAt);
await this.prisma.$transaction(async (tx) => {
await tx.paymentIntent.update({
where: { id: intent.id },

View File

@@ -86,6 +86,21 @@ export class WaafiProvider implements PaymentProvider {
requestBody,
);
// Waafi returns transaction info (params.status) ONLY when responseCode is 2001. For an
// unpaid or not-yet-existing transaction it returns an error envelope (e.g. 5001 / E10206
// "Failed to get transaction info") with no status. Treat that as still-pending (PROCESSING),
// never terminal — so the intent keeps waiting for the webhook / its expiry rather than being
// wrongly resolved off a "no info" response.
if (response.responseCode !== WAAFI_SUCCESS_CODE) {
this.logger.debug(
`Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as pending`,
);
return {
status: ProviderPaymentStatus.PROCESSING,
rawResponse: response as unknown as Record<string, unknown>,
};
}
const rawState = response.params?.status ?? response.params?.tranStatusDesc;
const transactionId = response.params?.transactionId;
const mapped = this.mapStatus(rawState);