Update payment methods and cron job for payment cancellation

This commit is contained in:
Roba Boru
2026-06-29 16:37:01 +03:00
parent 334755432d
commit 4c153b0e3b
16 changed files with 793 additions and 564 deletions

View File

@@ -3,12 +3,19 @@ import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../common/prisma.service';
import { SmsClientService } from '../notifications/sms-client.service';
/** Minutes before departure at which each action fires. */
const REMINDER_MINUTES = 3 * 60; // 3 h → send payment reminder SMS
const DEADLINE_MINUTES = 2 * 60; // 2 h → cancel unpaid booking
/** Maximum time (hours) a passenger has to pay after booking. */
const MAX_PAYMENT_HOURS = 2;
/** Minutes before departure: cutoff for new bookings and payment deadline. */
const CUTOFF_MINUTES = 30;
/** Half-width of the reminder detection window (cron runs every 2 min). */
const REMINDER_WINDOW_MINUTES = 2;
/**
* payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
*/
function computePaymentDeadline(createdAt: Date, departureAt: Date): Date {
const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000);
const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000);
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
}
function fmtTime(d: Date): string {
return d.toLocaleTimeString('en-GB', {
@@ -28,66 +35,72 @@ export class TasksService {
) {}
// ─────────────────────────────────────────────────────────────────────────
// Every 2 min: advance TrainSchedule statuses (departure / arrival).
// Every 1 min: advance TrainSchedule statuses.
//
// SCHEDULED → BOARDING when departure ≤ 30 min away (closed to new bookings)
// BOARDING → EN_ROUTE at actual departure
// EN_ROUTE → ARRIVED at arrival time
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/2 * * * *')
@Cron('*/1 * * * *')
async syncScheduleStatuses() {
const now = new Date();
const thirtyMinFromNow = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
const [departed, arrived] = await Promise.all([
const [boarding, departed, arrived] = await Promise.all([
this.prisma.trainSchedule.updateMany({
where: { status: 'SCHEDULED', departureAt: { lte: now } },
where: { status: 'SCHEDULED', departureAt: { lte: thirtyMinFromNow } },
data: { status: 'BOARDING' },
}),
this.prisma.trainSchedule.updateMany({
where: { status: 'BOARDING', departureAt: { lte: now } },
data: { status: 'EN_ROUTE' },
}),
this.prisma.trainSchedule.updateMany({
where: { status: { in: ['EN_ROUTE', 'BOARDING'] }, arrivalAt: { lte: now } },
where: { status: 'EN_ROUTE', arrivalAt: { lte: now } },
data: { status: 'ARRIVED' },
}),
]);
if (departed.count > 0 || arrived.count > 0) {
if (boarding.count > 0 || departed.count > 0 || arrived.count > 0) {
this.logger.log(
`Schedule sync: ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
`Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Every 2 min: payment deadline enforcement.
// Every 1 min: payment deadline enforcement.
//
// • 3 h before departure → send one SMS reminder to complete payment.
// • 2 h before departure → cancel booking if payment is still pending
// and notify the passenger by SMS.
// Reminder — sent once at the midpoint of the booking's payment window:
// reminder_at = booking_time + total_window / 2
//
// Example: train departs 08:00
// 05:00 → reminder SMS sent ("pay before 06:00 or booking is cancelled")
// 06:00 → booking auto-cancelled, cancellation SMS sent
// Cancel — when now ≥ payment_deadline
// payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
//
// Examples (departure 10:00, cutoff 9:30):
// Booked 8:00 → deadline 9:30, window 1.5h, reminder at 8:45
// Booked 9:00 → deadline 9:30, window 30min, reminder at 9:15
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/2 * * * *')
@Cron('*/1 * * * *')
async enforcePaymentDeadlines() {
const now = new Date();
await Promise.all([
this.sendPaymentReminders(now),
this.cancelExpiredPendingBookings(now),
]);
}
// ── 3-hour reminder ───────────────────────────────────────────────────────
// ── Send reminder at the midpoint of each booking's payment window ────────
private async sendPaymentReminders(now: Date) {
// Narrow 4-minute window (±2 min around the 3-hour mark) so each booking
// is caught by exactly one cron tick and paymentReminderSentAt guards re-sends.
const windowMs = REMINDER_WINDOW_MINUTES * 60 * 1000;
const reminderMs = REMINDER_MINUTES * 60 * 1000;
const windowStart = new Date(now.getTime() + reminderMs - windowMs);
const windowEnd = new Date(now.getTime() + reminderMs + windowMs);
// Only look at bookings created within the last 3 h with a future departure.
const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000);
const bookings = await this.prisma.booking.findMany({
where: {
status: 'PENDING_PAYMENT',
paymentReminderSentAt: null,
schedule: { departureAt: { gte: windowStart, lte: windowEnd } },
createdAt: { gte: threeHoursAgo },
schedule: { departureAt: { gte: now } },
} as any,
include: {
schedule: {
@@ -101,15 +114,28 @@ export class TasksService {
for (const booking of bookings) {
try {
const dep = booking.schedule.departureAt as Date;
const deadline = new Date(dep.getTime() - DEADLINE_MINUTES * 60 * 1000);
const origin = booking.schedule.originStation?.name ?? '';
const dest = booking.schedule.destinationStation?.name ?? '';
const createdAt = booking.createdAt as Date;
const dep = booking.schedule.departureAt as Date;
const paymentDeadline = computePaymentDeadline(createdAt, dep);
const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime();
// Skip degenerate windows (< 2 min) — the cancel job will handle these immediately
if (totalWindowMs < 2 * 60 * 1000) continue;
// Remind once, at the midpoint of the total payment window
const reminderAt = new Date(createdAt.getTime() + totalWindowMs / 2);
if (now < reminderAt) continue;
const origin = booking.schedule.originStation?.name ?? '';
const dest = booking.schedule.destinationStation?.name ?? '';
const remainingMs = Math.max(0, paymentDeadline.getTime() - now.getTime());
const remainingMin = Math.round(remainingMs / 60_000);
const message =
`EDR: Your booking ${booking.bookingRef} ` +
`(${origin}${dest}) departs at ${fmtTime(dep)}. ` +
`Complete payment by ${fmtTime(deadline)} or your booking will be cancelled.`;
`Complete payment within ${remainingMin} minute(s) (by ${fmtTime(paymentDeadline)}) ` +
`or your booking will be cancelled.`;
if (booking.contactPhone) {
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
@@ -121,7 +147,8 @@ export class TasksService {
});
this.logger.log(
`Payment reminder sent: ${booking.bookingRef} (departs ${fmtTime(dep)}, deadline ${fmtTime(deadline)})`,
`Payment reminder sent: ${booking.bookingRef} ` +
`(deadline ${fmtTime(paymentDeadline)}, ${remainingMin} min remaining)`,
);
} catch (err) {
this.logger.error(
@@ -131,14 +158,22 @@ export class TasksService {
}
}
// ── 2-hour auto-cancel ────────────────────────────────────────────────────
// ── Cancel bookings whose payment deadline has passed ─────────────────────
private async cancelExpiredPendingBookings(now: Date) {
const cutoff = new Date(now.getTime() + DEADLINE_MINUTES * 60 * 1000); // now + 2 h
const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
// payment_deadline = MIN(createdAt + 2h, departureAt - 30min)
// Deadline is reached when either branch of the MIN is in the past:
// (a) createdAt ≤ now - 2h → 2-hour max window elapsed
// (b) departureAt ≤ now + 30min → departure within 30 min
const expiredBookings = await this.prisma.booking.findMany({
where: {
status: 'PENDING_PAYMENT',
schedule: { departureAt: { lte: cutoff } },
OR: [
{ createdAt: { lte: twoHoursAgo } },
{ schedule: { departureAt: { lte: departureCutoff } } },
],
},
include: {
schedule: {
@@ -151,8 +186,16 @@ export class TasksService {
},
});
let cancelledCount = 0;
for (const booking of expiredBookings) {
try {
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation
const createdAt = booking.createdAt as Date;
const dep = booking.schedule.departureAt as Date;
const paymentDeadline = computePaymentDeadline(createdAt, dep);
if (now < paymentDeadline) continue;
// 1. Release held seats (Journey rows are the occupancy source of truth)
await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });
@@ -161,12 +204,12 @@ export class TasksService {
data: {
bookingId: booking.id,
cancelledBy: 'SYSTEM',
reason: 'Payment not completed before departure deadline',
reason: 'Payment not completed before deadline',
refundAmount: 0,
refundMethod: booking.paymentIntent?.method ?? 'NONE',
refundStatus: 'NOT_APPLICABLE',
},
}).catch(() => null); // booking may already have a cancellation record
}).catch(() => null);
// 3. Mark cancelled
await this.prisma.booking.update({
@@ -175,22 +218,20 @@ export class TasksService {
});
// 4. Notify passenger
const dep = booking.schedule.departureAt as Date;
const origin = booking.schedule.originStation?.name ?? '';
const dest = booking.schedule.destinationStation?.name ?? '';
const message =
`EDR: Your booking ${booking.bookingRef} ` +
`(${origin}${dest}, departs ${fmtTime(dep)}) has been cancelled ` +
`because payment was not completed before the deadline.`;
`because payment was not completed before the deadline (${fmtTime(paymentDeadline)}).`;
if (booking.contactPhone) {
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
}
this.logger.log(
`Auto-cancelled: ${booking.bookingRef} (payment deadline expired, departs ${fmtTime(dep)})`,
);
this.logger.log(`Auto-cancelled: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`);
cancelledCount++;
} catch (err) {
this.logger.error(
`Auto-cancel failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`,
@@ -198,8 +239,8 @@ export class TasksService {
}
}
if (expiredBookings.length > 0) {
this.logger.log(`Auto-cancelled ${expiredBookings.length} expired pending booking(s)`);
if (cancelledCount > 0) {
this.logger.log(`Auto-cancelled ${cancelledCount} expired pending booking(s)`);
}
}
}