mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
Merge pull request #293 from Tria-plc/alpha
Add payment and booking status cron job and update seatmap
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- Migration already applied directly to the database.
|
||||||
|
-- This file exists only to satisfy Prisma's migration directory check (P3015).
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE passenger."Booking" ADD COLUMN IF NOT EXISTS "paymentReminderSentAt" TIMESTAMP(3);
|
||||||
@@ -534,6 +534,7 @@ model Booking {
|
|||||||
source String @default("WEB")
|
source String @default("WEB")
|
||||||
promoCode String?
|
promoCode String?
|
||||||
paidAt DateTime?
|
paidAt DateTime?
|
||||||
|
paymentReminderSentAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import { SystemConfigModule } from './modules/system-config/system-config.module
|
|||||||
import { PackagesModule } from './modules/packages/packages.module';
|
import { PackagesModule } from './modules/packages/packages.module';
|
||||||
import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module';
|
import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module';
|
||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
|
import { TasksModule } from './modules/tasks/tasks.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -131,6 +132,7 @@ import { HealthModule } from './modules/health/health.module';
|
|||||||
PackagesModule,
|
PackagesModule,
|
||||||
ExcessBaggageModule,
|
ExcessBaggageModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
TasksModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
this.logger.warn(`${request.method} ${request.url} -> ${status} ${message}`);
|
this.logger.warn(`${request.method} ${request.url} -> ${status} ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the thrown body is already a structured object (e.g. { status, message, code }),
|
||||||
|
// merge it into the envelope so callers receive all custom fields.
|
||||||
|
const customFields =
|
||||||
|
typeof messageRaw === 'object' && messageRaw !== null ? messageRaw : {};
|
||||||
|
|
||||||
response.status(status).json({
|
response.status(status).json({
|
||||||
success: false,
|
success: false,
|
||||||
statusCode: status,
|
statusCode: status,
|
||||||
@@ -54,6 +59,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
error: exception instanceof Error ? exception.name : 'Error',
|
error: exception instanceof Error ? exception.name : 'Error',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
path: request.url,
|
path: request.url,
|
||||||
|
...customFields,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException, SetMetadata } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, SetMetadata, BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger';
|
||||||
|
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||||
import { Throttle } from '@nestjs/throttler';
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { GuestBookingService } from './guest-booking.service';
|
import { GuestBookingService } from './guest-booking.service';
|
||||||
@@ -35,13 +36,13 @@ export class BookingsController {
|
|||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
) {
|
) {
|
||||||
const passengerId = req.user?.passengerId;
|
const iamUserId = req.user?.id;
|
||||||
if (!passengerId) throw new Error('Passenger ID not found in token');
|
if (!iamUserId) throw new UnauthorizedException();
|
||||||
return this.service.findByPassengerId(passengerId, {
|
return this.service.findByIamUserId(iamUserId, {
|
||||||
search,
|
search,
|
||||||
status,
|
status,
|
||||||
page: page ? parseInt(page) : 1,
|
page: page ? parseInt(page) : 1,
|
||||||
pageSize: pageSize ? parseInt(pageSize) : 20
|
pageSize: pageSize ? parseInt(pageSize) : 20
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ export class BookingsService {
|
|||||||
private readonly fareEngine: FareEngineService,
|
private readonly fareEngine: FareEngineService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
|
||||||
|
const passenger = await this.prisma.passenger.findUniqueOrThrow({ where: { iamUserId }, select: { id: true } });
|
||||||
|
return this.findByPassengerId(passenger.id, filters);
|
||||||
|
}
|
||||||
|
|
||||||
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
|
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
|
||||||
const { search, status, page = 1, pageSize = 20 } = filters;
|
const { search, status, page = 1, pageSize = 20 } = filters;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const DEFAULT_BEDS_PER_ROOM: Record<'ECONOMY_BED' | 'VIP_BED', number> = {
|
|||||||
// Name-based fallback: checks if 'vip' is present for any bed/sleeper coach type
|
// Name-based fallback: checks if 'vip' is present for any bed/sleeper coach type
|
||||||
function detectBedCategory(coachTypeName: string): BedCategory {
|
function detectBedCategory(coachTypeName: string): BedCategory {
|
||||||
const name = coachTypeName.toLowerCase();
|
const name = coachTypeName.toLowerCase();
|
||||||
const isBed = name.includes('bed') || name.includes('sleeper') || name.includes('couchette');
|
const isBed = name.includes('bed') || name.includes('berth') || name.includes('sleeper') || name.includes('couchette');
|
||||||
if (!isBed) return null;
|
if (!isBed) return null;
|
||||||
if (name.includes('vip')) return 'VIP_BED';
|
if (name.includes('vip')) return 'VIP_BED';
|
||||||
return 'ECONOMY_BED';
|
return 'ECONOMY_BED';
|
||||||
|
|||||||
@@ -51,27 +51,30 @@ export class SeatsService {
|
|||||||
: 0;
|
: 0;
|
||||||
const bedCategory = isBedCoach ? this.getBedCategory(coachTypeName, bedsPerRoom) : null;
|
const bedCategory = isBedCoach ? this.getBedCategory(coachTypeName, bedsPerRoom) : null;
|
||||||
|
|
||||||
const mappedSeats = allSeats.map((s: any) => ({
|
const mappedSeats = allSeats.map((s: any) => {
|
||||||
id: s.id,
|
const resolvedBedPosition = isBedCoach
|
||||||
seatNumber: s.seatNumber,
|
? this.resolveBedPosition(s.col, s.bedPosition)
|
||||||
label: s.seatNumber,
|
: s.bedPosition;
|
||||||
status: effectiveStatuses.get(s.id) ?? s.status,
|
return {
|
||||||
kind: s.kind,
|
id: s.id,
|
||||||
row: s.row,
|
seatNumber: s.seatNumber,
|
||||||
col: s.col,
|
label: s.seatNumber,
|
||||||
isWindow: s.isWindow,
|
status: effectiveStatuses.get(s.id) ?? s.status,
|
||||||
isAisle: s.isAisle,
|
kind: s.kind,
|
||||||
// Bed-specific fields
|
row: s.row,
|
||||||
...(isBedCoach ? {
|
col: s.col,
|
||||||
room_id: `${a.coach.id}-R${s.row}`,
|
isWindow: s.isWindow,
|
||||||
category: bedCategory,
|
isAisle: s.isAisle,
|
||||||
position: this.colToPosition(s.col),
|
bedPosition: resolvedBedPosition,
|
||||||
bed_type: this.bedPositionToType(s.bedPosition),
|
// Bed-specific fields (only when coach is a bed coach)
|
||||||
bedPosition: s.bedPosition,
|
...(isBedCoach ? {
|
||||||
} : {
|
room_id: `${a.coach.id}-R${s.row}`,
|
||||||
bedPosition: s.bedPosition,
|
category: bedCategory,
|
||||||
}),
|
position: this.colToPosition(s.col, a.coach.arrangement),
|
||||||
}));
|
bed_type: this.bedPositionToType(resolvedBedPosition),
|
||||||
|
} : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
id: a.coach.id,
|
id: a.coach.id,
|
||||||
@@ -116,7 +119,7 @@ export class SeatsService {
|
|||||||
|
|
||||||
private isBedCoach(coachTypeName: string): boolean {
|
private isBedCoach(coachTypeName: string): boolean {
|
||||||
const n = coachTypeName.toLowerCase();
|
const n = coachTypeName.toLowerCase();
|
||||||
return n.includes('bed') || n.includes('sleeper') || n.includes('couchette');
|
return n.includes('bed') || n.includes('berth') || n.includes('sleeper') || n.includes('couchette');
|
||||||
}
|
}
|
||||||
|
|
||||||
private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' {
|
private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' {
|
||||||
@@ -128,9 +131,23 @@ export class SeatsService {
|
|||||||
return 'ECONOMY_BED';
|
return 'ECONOMY_BED';
|
||||||
}
|
}
|
||||||
|
|
||||||
// col format: L1, L2, L3, R1, R2, R3
|
// col format: L1, L2, L3, R1, R2, R3 (new) or A, B, C, D (legacy)
|
||||||
private colToPosition(col: string): 'LEFT' | 'RIGHT' {
|
// arrangement e.g. "2+2", "3+3", "2+0" → "leftCount+rightCount"
|
||||||
return col?.startsWith('R') ? 'RIGHT' : 'LEFT';
|
private colToPosition(col: string, arrangement?: string): 'LEFT' | 'RIGHT' | null {
|
||||||
|
if (!col) return null;
|
||||||
|
// New named-col format: L1, L2, R1, R2 …
|
||||||
|
if (/^L\d+$/.test(col)) return 'LEFT';
|
||||||
|
if (/^R\d+$/.test(col)) return 'RIGHT';
|
||||||
|
// Legacy single-letter cols (A, B, C, D …): derive from arrangement
|
||||||
|
const colIndex = col.toUpperCase().charCodeAt(0) - 65; // A=0, B=1, C=2 …
|
||||||
|
if (arrangement) {
|
||||||
|
const [leftStr, rightStr] = arrangement.split('+');
|
||||||
|
const rightCount = parseInt(rightStr ?? '0', 10);
|
||||||
|
if (rightCount === 0) return 'LEFT'; // single-side berth coach — all LEFT
|
||||||
|
const leftCount = parseInt(leftStr, 10) || 0;
|
||||||
|
return colIndex < leftCount ? 'LEFT' : 'RIGHT';
|
||||||
|
}
|
||||||
|
return 'LEFT'; // safe default when no arrangement info
|
||||||
}
|
}
|
||||||
|
|
||||||
private bedPositionToType(bedPosition: string | null): 'LOWER' | 'MIDDLE' | 'UPPER' | null {
|
private bedPositionToType(bedPosition: string | null): 'LOWER' | 'MIDDLE' | 'UPPER' | null {
|
||||||
@@ -141,6 +158,22 @@ export class SeatsService {
|
|||||||
return map[bedPosition.toLowerCase()] ?? null;
|
return map[bedPosition.toLowerCase()] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derives bedPosition from col when the seat was created with legacy A/B/C columns
|
||||||
|
// (new coaches use L1/L2/L3/R1/R2/R3 and store bedPosition explicitly).
|
||||||
|
// Col-to-tier mapping: A → lower, B → middle, C → upper, D → upper (4-tier).
|
||||||
|
private resolveBedPosition(col: string, storedBedPosition: string | null): string | null {
|
||||||
|
if (storedBedPosition) return storedBedPosition;
|
||||||
|
const legacyMap: Record<string, string> = { A: 'lower', B: 'middle', C: 'upper', D: 'upper' };
|
||||||
|
// Also handle numeric suffix in L/R cols: L1→lower, L2→middle, L3→upper
|
||||||
|
if (/^[LR]\d+$/.test(col)) {
|
||||||
|
const tier = parseInt(col.slice(1), 10);
|
||||||
|
if (tier === 1) return 'lower';
|
||||||
|
if (tier === 2) return 'middle';
|
||||||
|
return 'upper';
|
||||||
|
}
|
||||||
|
return legacyMap[col?.toUpperCase()] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
async resolveEffectiveStatuses(
|
async resolveEffectiveStatuses(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
seatIds: string[],
|
seatIds: string[],
|
||||||
|
|||||||
10
apps/edr-passenger-api/src/modules/tasks/tasks.module.ts
Normal file
10
apps/edr-passenger-api/src/modules/tasks/tasks.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from '../../common/prisma.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { TasksService } from './tasks.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, NotificationsModule],
|
||||||
|
providers: [TasksService],
|
||||||
|
})
|
||||||
|
export class TasksModule {}
|
||||||
205
apps/edr-passenger-api/src/modules/tasks/tasks.service.ts
Normal file
205
apps/edr-passenger-api/src/modules/tasks/tasks.service.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
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
|
||||||
|
|
||||||
|
/** Half-width of the reminder detection window (cron runs every 2 min). */
|
||||||
|
const REMINDER_WINDOW_MINUTES = 2;
|
||||||
|
|
||||||
|
function fmtTime(d: Date): string {
|
||||||
|
return d.toLocaleTimeString('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: 'Africa/Addis_Ababa',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TasksService {
|
||||||
|
private readonly logger = new Logger(TasksService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly sms: SmsClientService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Every 2 min: advance TrainSchedule statuses (departure / arrival).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
@Cron('*/2 * * * *')
|
||||||
|
async syncScheduleStatuses() {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const [departed, arrived] = await Promise.all([
|
||||||
|
this.prisma.trainSchedule.updateMany({
|
||||||
|
where: { status: 'SCHEDULED', departureAt: { lte: now } },
|
||||||
|
data: { status: 'EN_ROUTE' },
|
||||||
|
}),
|
||||||
|
this.prisma.trainSchedule.updateMany({
|
||||||
|
where: { status: { in: ['EN_ROUTE', 'BOARDING'] }, arrivalAt: { lte: now } },
|
||||||
|
data: { status: 'ARRIVED' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (departed.count > 0 || arrived.count > 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`Schedule sync: ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Every 2 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.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
@Cron('*/2 * * * *')
|
||||||
|
async enforcePaymentDeadlines() {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
this.sendPaymentReminders(now),
|
||||||
|
this.cancelExpiredPendingBookings(now),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3-hour reminder ───────────────────────────────────────────────────────
|
||||||
|
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);
|
||||||
|
|
||||||
|
const bookings = await this.prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
paymentReminderSentAt: null,
|
||||||
|
schedule: { departureAt: { gte: windowStart, lte: windowEnd } },
|
||||||
|
} as any,
|
||||||
|
include: {
|
||||||
|
schedule: {
|
||||||
|
include: {
|
||||||
|
originStation: { select: { name: true } },
|
||||||
|
destinationStation: { select: { name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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 message =
|
||||||
|
`EDR: Your booking ${booking.bookingRef} ` +
|
||||||
|
`(${origin} → ${dest}) departs at ${fmtTime(dep)}. ` +
|
||||||
|
`Complete payment by ${fmtTime(deadline)} or your booking will be cancelled.`;
|
||||||
|
|
||||||
|
if (booking.contactPhone) {
|
||||||
|
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.booking.update({
|
||||||
|
where: { id: booking.id },
|
||||||
|
data: { paymentReminderSentAt: now } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Payment reminder sent: ${booking.bookingRef} (departs ${fmtTime(dep)}, deadline ${fmtTime(deadline)})`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Reminder failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2-hour auto-cancel ────────────────────────────────────────────────────
|
||||||
|
private async cancelExpiredPendingBookings(now: Date) {
|
||||||
|
const cutoff = new Date(now.getTime() + DEADLINE_MINUTES * 60 * 1000); // now + 2 h
|
||||||
|
|
||||||
|
const expiredBookings = await this.prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
schedule: { departureAt: { lte: cutoff } },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
schedule: {
|
||||||
|
include: {
|
||||||
|
originStation: { select: { name: true } },
|
||||||
|
destinationStation: { select: { name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paymentIntent: { select: { method: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const booking of expiredBookings) {
|
||||||
|
try {
|
||||||
|
// 1. Release held seats (Journey rows are the occupancy source of truth)
|
||||||
|
await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });
|
||||||
|
|
||||||
|
// 2. Audit record (no refund — payment was never completed)
|
||||||
|
await this.prisma.bookingCancellation.create({
|
||||||
|
data: {
|
||||||
|
bookingId: booking.id,
|
||||||
|
cancelledBy: 'SYSTEM',
|
||||||
|
reason: 'Payment not completed before departure deadline',
|
||||||
|
refundAmount: 0,
|
||||||
|
refundMethod: booking.paymentIntent?.method ?? 'NONE',
|
||||||
|
refundStatus: 'NOT_APPLICABLE',
|
||||||
|
},
|
||||||
|
}).catch(() => null); // booking may already have a cancellation record
|
||||||
|
|
||||||
|
// 3. Mark cancelled
|
||||||
|
await this.prisma.booking.update({
|
||||||
|
where: { id: booking.id },
|
||||||
|
data: { status: 'CANCELLED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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.`;
|
||||||
|
|
||||||
|
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)})`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Auto-cancel failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiredBookings.length > 0) {
|
||||||
|
this.logger.log(`Auto-cancelled ${expiredBookings.length} expired pending booking(s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
@@ -127,12 +127,37 @@ export class TicketsService {
|
|||||||
});
|
});
|
||||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
|
|
||||||
|
// No payment intent record at all
|
||||||
|
if (!booking.paymentIntent) {
|
||||||
|
throw new HttpException(
|
||||||
|
{ status: 'error', message: 'Payment not completed', code: 400 },
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payment intent exists but not yet succeeded
|
||||||
|
if (booking.paymentIntent.status !== 'SUCCEEDED') {
|
||||||
|
throw new HttpException(
|
||||||
|
{
|
||||||
|
status: 'error',
|
||||||
|
message: 'Payment not completed',
|
||||||
|
code: 400,
|
||||||
|
detail: `Payment status: ${booking.paymentIntent.status}`,
|
||||||
|
},
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Booking not in CONFIRMED state (safety net — should align with SUCCEEDED)
|
||||||
if (booking.status !== 'CONFIRMED') {
|
if (booking.status !== 'CONFIRMED') {
|
||||||
const paymentStatus = booking.paymentIntent?.status ?? null;
|
throw new HttpException(
|
||||||
throw new BadRequestException(
|
{
|
||||||
`Payment not completed. Please complete your payment before accessing the ticket. ` +
|
status: 'error',
|
||||||
`Booking status: ${booking.status}` +
|
message: 'Payment not completed',
|
||||||
(paymentStatus ? `. Payment status: ${paymentStatus}` : ''),
|
code: 400,
|
||||||
|
detail: `Booking status: ${booking.status}`,
|
||||||
|
},
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"tsBuildInfoFile": "./.tsbuildinfo",
|
"tsBuildInfoFile": "./.tsbuildinfo",
|
||||||
"paths": { "@/*": ["./src/*"] },
|
"paths": {
|
||||||
"module": "node16",
|
"@/*": ["./src/*"],
|
||||||
"moduleResolution": "node16",
|
"@tria-plc/iamapi-common": ["./node_modules/@tria-plc/iamapi-common/dist/index"],
|
||||||
|
"@tria-plc/iamapi-common/*": ["./node_modules/@tria-plc/iamapi-common/dist/*"]
|
||||||
|
},
|
||||||
"strictPropertyInitialization": false,
|
"strictPropertyInitialization": false,
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false
|
"noUnusedParameters": false
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export default function PaymentPage() {
|
|||||||
usePaymentStore();
|
usePaymentStore();
|
||||||
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [paymentError, setPaymentError] = useState<string | null>(null);
|
||||||
|
|
||||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||||
|
|
||||||
@@ -60,57 +61,37 @@ export default function PaymentPage() {
|
|||||||
|
|
||||||
const paymentMutation = useMutation({
|
const paymentMutation = useMutation({
|
||||||
mutationFn: async (data: any) => {
|
mutationFn: async (data: any) => {
|
||||||
// For all payment methods, use the initiate endpoint
|
return await apiClient.post("/payments/initiate", {
|
||||||
try {
|
bookingId: data.bookingId,
|
||||||
return await apiClient.post("/payments/initiate", {
|
method: data.method,
|
||||||
bookingId: data.bookingId,
|
paymentMethodId: data.paymentMethodId,
|
||||||
method: data.method,
|
platform: 'web',
|
||||||
paymentMethodId: data.paymentMethodId,
|
});
|
||||||
platform: 'web',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.log("Payment API not available, using mock payment");
|
|
||||||
// Mock payment response
|
|
||||||
return {
|
|
||||||
paymentIntentId: `mock-payment-${Date.now()}`,
|
|
||||||
status: "PENDING",
|
|
||||||
amountMinor: data.amountMinor,
|
|
||||||
currency: data.currency,
|
|
||||||
method: data.method,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onSuccess: async (data: any) => {
|
onSuccess: async (data: any) => {
|
||||||
// Handle TELEBIRR/WAAFI redirect response
|
setPaymentError(null);
|
||||||
|
|
||||||
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI') && data?.clientAction?.type === 'REDIRECT') {
|
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI') && data?.clientAction?.type === 'REDIRECT') {
|
||||||
const redirectUrl = data.clientAction.url;
|
|
||||||
|
|
||||||
// Store the intent ID for later verification
|
|
||||||
setPaymentIntent(data.intentId);
|
setPaymentIntent(data.intentId);
|
||||||
updateStatus("REQUIRES_ACTION");
|
updateStatus("REQUIRES_ACTION");
|
||||||
|
window.location.href = data.clientAction.url;
|
||||||
// Redirect to payment gateway
|
|
||||||
window.location.href = redirectUrl;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPaymentIntent(data.paymentIntentId || data.intentId);
|
setPaymentIntent(data.paymentIntentId || data.intentId);
|
||||||
updateStatus("PROCESSING");
|
updateStatus("PROCESSING");
|
||||||
|
|
||||||
// Simulate payment processing
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
updateStatus("SUCCEEDED");
|
updateStatus("SUCCEEDED");
|
||||||
router.push("/booking/confirmation");
|
router.push("/booking/confirmation");
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
console.error("Payment failed:", error);
|
console.error("Payment failed:", error);
|
||||||
updateStatus("FAILED");
|
updateStatus("FAILED");
|
||||||
const errorMessage =
|
setPaymentError(
|
||||||
error?.response?.data?.message ||
|
error?.response?.data?.message ||
|
||||||
error?.message ||
|
error?.message ||
|
||||||
"Payment failed. Please try again.";
|
"Payment failed. Please try again.",
|
||||||
alert(errorMessage);
|
);
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -124,6 +105,7 @@ export default function PaymentPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
setPaymentError(null);
|
||||||
|
|
||||||
// Find the selected payment method to get its ID
|
// Find the selected payment method to get its ID
|
||||||
const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod);
|
const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod);
|
||||||
@@ -575,11 +557,10 @@ export default function PaymentPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{paymentMutation.isError && (
|
{paymentError && (
|
||||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-4">
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-4">
|
||||||
<p className="text-red-800 dark:text-red-200 text-sm font-medium">
|
<p className="text-red-800 dark:text-red-200 text-sm font-medium">
|
||||||
⚠️ Payment failed. Please try again or contact support if the
|
⚠️ {paymentError}
|
||||||
problem persists.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default registerAs("dmoney", () => ({
|
|||||||
returnUrl: process.env.DMONEY_RETURN_URL ?? "",
|
returnUrl: process.env.DMONEY_RETURN_URL ?? "",
|
||||||
timeoutExpress: process.env.DMONEY_TIMEOUT_EXPRESS ?? "120m",
|
timeoutExpress: process.env.DMONEY_TIMEOUT_EXPRESS ?? "120m",
|
||||||
language: process.env.DMONEY_LANGUAGE ?? "en",
|
language: process.env.DMONEY_LANGUAGE ?? "en",
|
||||||
currency: process.env.DMONEY_CURRENCY ?? "FDJ",
|
currency: process.env.DMONEY_CURRENCY ?? "DJF",
|
||||||
privateKey: process.env.DMONEY_PRIVATE_KEY ?? "",
|
privateKey: process.env.DMONEY_PRIVATE_KEY ?? "",
|
||||||
publicKey: process.env.DMONEY_PUBLIC_KEY ?? "",
|
publicKey: process.env.DMONEY_PUBLIC_KEY ?? "",
|
||||||
insecureTls: process.env.DMONEY_INSECURE_TLS === "true",
|
insecureTls: process.env.DMONEY_INSECURE_TLS === "true",
|
||||||
|
|||||||
@@ -206,18 +206,16 @@ export class DMoneyProvider implements PaymentProvider {
|
|||||||
appid: this.merchantAppId,
|
appid: this.merchantAppId,
|
||||||
merch_code: this.merchantCode,
|
merch_code: this.merchantCode,
|
||||||
merch_order_id: input.merchantOrderId,
|
merch_order_id: input.merchantOrderId,
|
||||||
trade_type: "Checkout" as const,
|
trade_type: "WebCheckout" as const,
|
||||||
|
business_type: "OnlineMerchant" as const,
|
||||||
title: `${input.orderRef}`,
|
title: `${input.orderRef}`,
|
||||||
total_amount: totalAmount,
|
total_amount: totalAmount,
|
||||||
trans_currency: 1 == 1 ? "DJF": this.currency,
|
trans_currency: this.currency,
|
||||||
timeout_express: this.timeoutExpress,
|
timeout_express: this.timeoutExpress,
|
||||||
...(redirectUrl ? { redirect_url: redirectUrl } : {}),
|
...(redirectUrl ? { redirect_url: redirectUrl } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("\n\n\n")
|
|
||||||
console.log(req)
|
|
||||||
console.log("\n\n\n")
|
|
||||||
const sign = signRequestObject(
|
const sign = signRequestObject(
|
||||||
req as unknown as Record<string, unknown>,
|
req as unknown as Record<string, unknown>,
|
||||||
this.privateKey,
|
this.privateKey,
|
||||||
@@ -265,7 +263,7 @@ export class DMoneyProvider implements PaymentProvider {
|
|||||||
`sign=${sign}`,
|
`sign=${sign}`,
|
||||||
"sign_type=SHA256WithRSA",
|
"sign_type=SHA256WithRSA",
|
||||||
"version=1.0",
|
"version=1.0",
|
||||||
"trade_type=Checkout",
|
"trade_type=WebCheckout",
|
||||||
`language=${this.language}`,
|
`language=${this.language}`,
|
||||||
].join("&");
|
].join("&");
|
||||||
return `${this.webBaseUrl}/payment/web/paygate?${query}`;
|
return `${this.webBaseUrl}/payment/web/paygate?${query}`;
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ export interface DMoneyPreOrderBizContent {
|
|||||||
appid: string;
|
appid: string;
|
||||||
merch_code: string;
|
merch_code: string;
|
||||||
merch_order_id: string;
|
merch_order_id: string;
|
||||||
trade_type: 'Checkout';
|
trade_type: 'WebCheckout';
|
||||||
|
business_type: 'OnlineMerchant';
|
||||||
title: string;
|
title: string;
|
||||||
total_amount: string;
|
total_amount: string;
|
||||||
trans_currency: string;
|
trans_currency: string;
|
||||||
timeout_express: string;
|
timeout_express: string;
|
||||||
business_type?: string;
|
|
||||||
redirect_url?: string;
|
redirect_url?: string;
|
||||||
callback_info?: string;
|
callback_info?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user