Merge pull request #223 from Tria-plc/alpha

Alpha
This commit is contained in:
Abubeker Yasin
2026-06-19 15:50:37 +03:00
committed by GitHub
10 changed files with 288 additions and 308 deletions

View File

@@ -34,7 +34,6 @@
"@nestjs/schedule": "^6.1.3", "@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^7.4.0", "@nestjs/swagger": "^7.4.0",
"@prisma/client": "^6.19.3", "@prisma/client": "^6.19.3",
"@sendgrid/mail": "^8.1.0",
"axios": "^1.7.7", "axios": "^1.7.7",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",

View File

@@ -555,6 +555,8 @@ async function seedNotificationTemplates() {
const templates = [ const templates = [
{ id: uuidv4(), code: 'booking.created', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed. Total: {{amount}} {{currency}}.' }, { id: uuidv4(), code: 'booking.created', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed. Total: {{amount}} {{currency}}.' },
{ id: uuidv4(), code: 'payment.succeeded', channel: 'SMS', subject: 'Payment Received', bodyTemplate: 'Payment of {{amount}} {{currency}} received for booking {{bookingRef}}.' }, { id: uuidv4(), code: 'payment.succeeded', channel: 'SMS', subject: 'Payment Received', bodyTemplate: 'Payment of {{amount}} {{currency}} received for booking {{bookingRef}}.' },
{ id: uuidv4(), code: 'payment.failed', channel: 'SMS', subject: 'Payment Failed', bodyTemplate: 'Payment for booking {{bookingRef}} could not be completed. Please try again.' },
{ id: uuidv4(), code: 'booking.cancelled', channel: 'EMAIL', subject: 'Booking Cancelled', bodyTemplate: 'Your booking {{bookingRef}} has been cancelled. Refund: {{refundAmount}} {{currency}}.' },
// Templates below are not wired to handlers yet (Phase 2 — full event coverage). // Templates below are not wired to handlers yet (Phase 2 — full event coverage).
{ id: uuidv4(), code: 'trip.departure', channel: 'PUSH', subject: 'Trip Departing Soon', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' }, { id: uuidv4(), code: 'trip.departure', channel: 'PUSH', subject: 'Trip Departing Soon', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' },
{ id: uuidv4(), code: 'trip.delay', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' }, { id: uuidv4(), code: 'trip.delay', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' },

View File

@@ -1007,6 +1007,7 @@ export class BookingsService {
await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } }); await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } });
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId)); await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } }); await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
this.eventEmitter.emit('booking.cancelled', { booking, refundAmount });
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' }; return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
} }

View File

@@ -28,18 +28,23 @@ export class EmailClientService implements OnApplicationBootstrap {
); );
} }
async sendEmail(dto: SendEmail) { async sendEmail(dto: SendEmail): Promise<{ queued: boolean }> {
if (!this.enabled) { if (!this.enabled) {
this.logger.warn(`RABBITMQ disabled — skipped EMAIL to ${dto.to}`); this.logger.warn(`RABBITMQ disabled — skipped EMAIL`);
return {}; return { queued: false };
} }
this.emailServiceClient.emit("send-email", { this.emailServiceClient.emit("send-email", {
...dto, ...dto,
appKey: "IFHCRS-LICENSE-MANAGEMENT", appKey: "IFHCRS-LICENSE-MANAGEMENT",
}); });
// Fire-and-forget enqueue: this confirms the message was handed to RabbitMQ, NOT delivered.
this.logger.log( this.logger.log(
`EMAIL emitted to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email' to=${dto.to} subject="${dto.subject ?? ""}" body="${dto.text ?? dto.body ?? dto.html ?? ""}"`, `EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`,
); );
return {}; // Recipient + content are PII — keep them at debug level only.
this.logger.debug(
`EMAIL payload to=${dto.to} subject="${dto.subject ?? ""}" body="${dto.text ?? dto.body ?? dto.html ?? ""}"`,
);
return { queued: true };
} }
} }

View File

@@ -1,199 +1,10 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as sgMail from '@sendgrid/mail';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
export interface NotificationChannel { export interface NotificationChannel {
send(recipient: string, subject: string, body: string, context?: Record<string, unknown>): Promise<boolean>; send(recipient: string, subject: string, body: string, context?: Record<string, unknown>): Promise<boolean>;
} }
@Injectable()
export class EmailAdapter implements NotificationChannel {
private readonly logger = new Logger(EmailAdapter.name);
constructor(private readonly config: ConfigService) {
const apiKey = this.config.get<string>('SENDGRID_API_KEY');
if (apiKey) {
sgMail.setApiKey(apiKey);
this.logger.log('SendGrid Email adapter initialized');
} else {
this.logger.warn('SENDGRID_API_KEY not configured - emails will be logged only');
}
}
async send(
recipient: string,
subject: string,
body: string,
context?: Record<string, unknown>,
): Promise<boolean> {
const apiKey = this.config.get<string>('SENDGRID_API_KEY');
const fromEmail = this.config.get<string>('SENDGRID_FROM_EMAIL') || 'noreply@edr-platform.com';
if (!apiKey) {
this.logger.log(`[EMAIL MOCK] To: ${recipient} | Subject: ${subject} | Body: ${body.substring(0, 100)}`);
return true;
}
try {
const msg: sgMail.MailDataRequired = {
to: recipient,
from: fromEmail,
subject,
text: body,
html: this.formatHtml(body, context),
};
await sgMail.send(msg);
this.logger.log(`Email sent successfully to ${recipient}`);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Failed to send email to ${recipient}: ${message}`);
return false;
}
}
private formatHtml(body: string, context?: Record<string, unknown>): string {
const contextHtml = context
? `<div style="margin-top: 20px; padding: 10px; background: #f5f5f5; border-radius: 4px;">
<small>${JSON.stringify(context, null, 2)}</small>
</div>`
: '';
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #0066cc; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: white; }
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>Ethio-Djibouti Railway</h2>
</div>
<div class="content">
${body.replace(/\n/g, '<br>')}
${contextHtml}
</div>
<div class="footer">
<p>© 2024 Ethio-Djibouti Railway. All rights reserved.</p>
</div>
</div>
</body>
</html>
`;
}
}
@Injectable()
export class SmsAdapter implements NotificationChannel {
private readonly logger = new Logger(SmsAdapter.name);
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {
const provider = this.config.get<string>('SMS_PROVIDER');
this.logger.log(`SMS adapter initialized with provider: ${provider || 'MOCK'}`);
}
async send(
recipient: string,
subject: string,
body: string,
_context?: Record<string, unknown>,
): Promise<boolean> {
const provider = this.config.get<string>('SMS_PROVIDER');
const apiKey = this.config.get<string>('SMS_API_KEY');
if (!provider || !apiKey) {
this.logger.log(`[SMS MOCK] To: ${recipient} | Message: ${body.substring(0, 100)}`);
return true;
}
try {
switch (provider.toLowerCase()) {
case 'twilio':
return await this.sendViaTwilio(recipient, body);
case 'africastalking':
return await this.sendViaAfricasTalking(recipient, body);
default:
this.logger.warn(`Unknown SMS provider: ${provider}`);
return false;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Failed to send SMS to ${recipient}: ${message}`);
return false;
}
}
private async sendViaTwilio(to: string, body: string): Promise<boolean> {
const accountSid = this.config.get<string>('TWILIO_ACCOUNT_SID');
const authToken = this.config.get<string>('TWILIO_AUTH_TOKEN');
const fromNumber = this.config.get<string>('TWILIO_FROM_NUMBER');
const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`;
const auth = Buffer.from(`${accountSid}:${authToken}`).toString('base64');
const response = await firstValueFrom(
this.http.post(
url,
new URLSearchParams({
To: to,
From: fromNumber || '',
Body: body,
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${auth}`,
},
},
),
);
return response.status === 201;
}
private async sendViaAfricasTalking(to: string, body: string): Promise<boolean> {
const apiKey = this.config.get<string>('SMS_API_KEY');
const username = this.config.get<string>('AFRICASTALKING_USERNAME');
const from = this.config.get<string>('AFRICASTALKING_FROM');
const url = 'https://api.africastalking.com/version1/messaging';
const response = await firstValueFrom(
this.http.post(
url,
new URLSearchParams({
username: username || '',
to,
message: body,
from: from || '',
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'apiKey': apiKey || '',
},
},
),
);
return response.status === 201;
}
}
@Injectable() @Injectable()
export class PushAdapter implements NotificationChannel { export class PushAdapter implements NotificationChannel {
private readonly logger = new Logger(PushAdapter.name); private readonly logger = new Logger(PushAdapter.name);

View File

@@ -3,12 +3,13 @@ import { HttpModule } from '@nestjs/axios';
import { ClientsModule, Transport } from '@nestjs/microservices'; import { ClientsModule, Transport } from '@nestjs/microservices';
import { NotificationsController } from './notifications.controller'; import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service'; import { NotificationsService } from './notifications.service';
import { EmailAdapter, SmsAdapter, PushAdapter } from './notification.adapters'; import { PushAdapter } from './notification.adapters';
import { EmailClientService } from './email-client.service'; import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service'; import { SmsClientService } from './sms-client.service';
@Module({ @Module({
imports: [ imports: [
// Required by IamGuard (injects HttpService) used in NotificationsController.
HttpModule.register({ timeout: 10_000 }), HttpModule.register({ timeout: 10_000 }),
ClientsModule.register([ ClientsModule.register([
{ {
@@ -34,8 +35,6 @@ import { SmsClientService } from './sms-client.service';
controllers: [NotificationsController], controllers: [NotificationsController],
providers: [ providers: [
NotificationsService, NotificationsService,
EmailAdapter,
SmsAdapter,
PushAdapter, PushAdapter,
EmailClientService, EmailClientService,
SmsClientService, SmsClientService,

View File

@@ -1,7 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto';
import { PushAdapter, NotificationChannel } from './notification.adapters'; import { PushAdapter, NotificationChannel } from './notification.adapters';
import { EmailClientService } from './email-client.service'; import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service'; import { SmsClientService } from './sms-client.service';
@@ -20,8 +19,8 @@ export class NotificationsService {
private pushAdapter: PushAdapter, private pushAdapter: PushAdapter,
) { ) {
this.channels = new Map<NotificationChannelType, NotificationChannel>([ this.channels = new Map<NotificationChannelType, NotificationChannel>([
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then(() => true) }], ['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then((r) => r.queued) }],
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }], ['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then((r) => r.queued) }],
['PUSH', this.pushAdapter as NotificationChannel], ['PUSH', this.pushAdapter as NotificationChannel],
]); ]);
} }
@@ -38,27 +37,38 @@ export class NotificationsService {
recipient: string, recipient: string,
context: Record<string, unknown>, context: Record<string, unknown>,
channels?: NotificationChannelType[], channels?: NotificationChannelType[],
): Promise<{ sent: boolean; channels: string[] }> { ): Promise<{ queued: boolean; channels: string[] }> {
const template = await this.prisma.notificationTemplate.findUnique({ const template = await this.prisma.notificationTemplate.findUnique({
where: { code: templateKey }, where: { code: templateKey },
}); });
if (!template || !template.active) { if (!template || !template.active) {
this.logger.warn(`Template ${templateKey} not found or inactive`); this.logger.warn(`Template ${templateKey} not found or inactive`);
return { sent: false, channels: [] }; return { queued: false, channels: [] };
} }
const { subject, body } = this.interpolate(template, context); const { subject, body } = this.interpolate(template, context);
const targetChannels = channels || await this.getUserPreferredChannels(recipient);
const sentChannels: string[] = [];
// Always create in-app notification // Channel resolution: explicit argument wins; otherwise honor the template's declared
if (targetChannels.includes('IN_APP')) { // channel(s); otherwise fall back to the recipient's preferences.
await this.createInAppNotification(recipient, subject, body, context); let targetChannels: NotificationChannelType[];
sentChannels.push('IN_APP'); if (channels) {
targetChannels = channels;
} else if (template.channel) {
targetChannels = this.parseTemplateChannels(template.channel);
} else {
targetChannels = await this.getUserPreferredChannels(recipient);
}
// Channels successfully handed off (in-app persisted / email+SMS enqueued to RabbitMQ).
// NOTE: enqueue is fire-and-forget — this is NOT a delivery confirmation.
const queuedChannels: string[] = [];
if (targetChannels.includes('IN_APP')) {
await this.createInAppNotification(recipient, subject, body, context);
queuedChannels.push('IN_APP');
} }
// Send via other channels
for (const channelType of targetChannels) { for (const channelType of targetChannels) {
if (channelType === 'IN_APP') continue; if (channelType === 'IN_APP') continue;
@@ -74,44 +84,26 @@ export class NotificationsService {
continue; continue;
} }
const success = await adapter.send(recipientAddress, subject, body, context); const queued = await adapter.send(recipientAddress, subject, body, context);
if (success) { if (queued) {
sentChannels.push(channelType); queuedChannels.push(channelType);
} }
} }
return { sent: sentChannels.length > 0, channels: sentChannels }; return { queued: queuedChannels.length > 0, channels: queuedChannels };
} }
/** /**
* Legacy method for backward compatibility * Parses a template's `channel` column (e.g. "EMAIL" or "EMAIL,SMS") into valid channel
* types, always including IN_APP so an in-app record is created.
*/ */
async sendDirect(dto: SendNotificationDto) { private parseTemplateChannels(channel: string): NotificationChannelType[] {
const notification = await this.prisma.notification.create({ const valid: NotificationChannelType[] = ['EMAIL', 'SMS', 'PUSH', 'IN_APP'];
data: { const parsed = channel
passengerId: dto.passengerId, .split(',')
title: dto.title, .map((c) => c.trim().toUpperCase())
body: dto.body, .filter((c): c is NotificationChannelType => valid.includes(c as NotificationChannelType));
category: dto.category as any, return Array.from(new Set<NotificationChannelType>(['IN_APP', ...parsed]));
deepLink: dto.deepLink,
metadata: dto.metadata,
},
});
const passenger = await this.prisma.passenger.findUnique({
where: { id: dto.passengerId },
include: { user: true },
});
if (passenger?.user) {
await this.emailClient.sendEmail({
to: passenger.user.email,
subject: this.sanitize(dto.title),
text: this.sanitize(dto.body),
});
}
return notification;
} }
private async createInAppNotification( private async createInAppNotification(
@@ -154,16 +146,20 @@ export class NotificationsService {
template: { subject?: string | null; bodyTemplate: string }, template: { subject?: string | null; bodyTemplate: string },
context: Record<string, unknown>, context: Record<string, unknown>,
): { subject: string; body: string } { ): { subject: string; body: string } {
const subject = template.subject || 'Notification'; return {
let body = template.bodyTemplate; subject: this.applyVars(template.subject || 'Notification', context),
body: this.applyVars(template.bodyTemplate, context),
};
}
// Simple template interpolation: {{variable}} /** Replaces {{variable}} placeholders in a string with values from the context. */
private applyVars(text: string, context: Record<string, unknown>): string {
let out = text;
for (const [key, value] of Object.entries(context)) { for (const [key, value] of Object.entries(context)) {
const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g'); const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
body = body.replace(regex, String(value)); out = out.replace(regex, String(value));
} }
return out;
return { subject, body };
} }
private async getUserPreferredChannels(recipient: string): Promise<NotificationChannelType[]> { private async getUserPreferredChannels(recipient: string): Promise<NotificationChannelType[]> {
@@ -221,12 +217,6 @@ export class NotificationsService {
} }
} }
private sanitize(value: string): string {
return value
.replace(/[\r\n]/g, ' ')
.replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#x27;' }[c] ?? c));
}
getForPassenger(passengerId: string) { getForPassenger(passengerId: string) {
return this.prisma.notification.findMany({ return this.prisma.notification.findMany({
where: { passengerId }, where: { passengerId },
@@ -265,18 +255,210 @@ export class NotificationsService {
); );
} }
/**
* Payment succeeded → one combined "payment successful, here is your ticket" notification.
* Email carries the full ticket (HTML + QR); SMS is a short pointer to view it. The shallow
* event payload is re-fetched with the relations needed to render the ticket.
*/
@OnEvent('payment.succeeded') @OnEvent('payment.succeeded')
async onPaymentSucceeded(payload: any) { async onPaymentSucceeded(payload: any) {
const passengerId = payload.booking.passengerId;
const bookingId = payload.booking.id;
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
},
});
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId } });
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
const amount = this.formatAmount(booking ?? payload.booking);
const currency = (booking ?? payload.booking).displayCurrency ?? 'ETB';
const ticketUrl = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/confirmation?ref=${ref}`;
// IN_APP — always created.
await this.createInAppNotification(
passengerId,
'Payment successful',
`Your payment of ${amount} ${currency} for booking ${ref} was successful. Your ticket is ready.`,
{ category: 'PAYMENT', deepLink: `edr://tickets/${ref}` },
);
// Ticket not ready (generation failed/raced) — fall back to a payment-only confirmation.
if (!ticket || !booking) {
this.logger.warn(`payment.succeeded: ticket not ready for booking ${ref}; sending payment-only confirmation`);
const text = `EDR: Payment of ${amount} ${currency} received for booking ${ref}. Your ticket is being prepared.`;
await this.deliverEmail(passengerId, `Payment received — ${ref}`, text);
await this.deliverSms(passengerId, text);
return;
}
// SMS — short pointer (no HTML/QR over SMS).
await this.deliverSms(
passengerId,
`EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`,
);
// EMAIL — rich HTML ticket with plain-text fallback.
await this.deliverEmail(
passengerId,
`Your EDR ticket — ${ref}`,
this.buildTicketEmailText(booking, amount, currency, ticketUrl),
this.buildTicketEmailHtml(booking, ticket, amount, currency, ticketUrl),
);
}
private async deliverEmail(recipient: string, subject: string, text: string, html?: string): Promise<void> {
const to = await this.getRecipientAddress(recipient, 'EMAIL');
if (!to) {
this.logger.warn(`No EMAIL address for recipient: ${recipient}`);
return;
}
await this.emailClient.sendEmail({ to, subject, text, html });
}
private async deliverSms(recipient: string, message: string): Promise<void> {
const to = await this.getRecipientAddress(recipient, 'SMS');
if (!to) {
this.logger.warn(`No SMS address for recipient: ${recipient}`);
return;
}
await this.smsClient.sendSms({ to, message });
}
private buildTicketEmailText(booking: any, amount: string, currency: string, url: string): string {
const s = booking.schedule ?? {};
const dep = s.departureAt ? new Date(s.departureAt).toLocaleString('en-GB') : 'TBD';
const passengers = (booking.seats ?? []).map((bs: any) => bs.passengerName).filter(Boolean).join(', ');
return [
`Booking ${booking.bookingRef} confirmed.`,
`${s.originStation?.name ?? ''} -> ${s.destinationStation?.name ?? ''}`,
`Train: ${s.train?.name ?? s.train?.number ?? ''}`,
`Departs: ${dep}`,
passengers ? `Passengers: ${passengers}` : '',
`Total paid: ${amount} ${currency}`,
`View your ticket: ${url}`,
].filter(Boolean).join('\n');
}
private buildTicketEmailHtml(booking: any, ticket: any, amount: string, currency: string, url: string): string {
const s = booking.schedule ?? {};
const fmt = (d: any) =>
d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD';
const seatRows = (booking.seats ?? [])
.map((bs: any) => {
const coach = bs.seat?.coach?.number ?? '-';
const seatNo = bs.seat?.seatNumber ?? '-';
const cls = bs.seat?.coach?.coachType?.name ?? '-';
return `<tr>
<td style="padding:8px;border-bottom:1px solid #eee;">${bs.passengerName ?? ''}</td>
<td style="padding:8px;border-bottom:1px solid #eee;">${coach}</td>
<td style="padding:8px;border-bottom:1px solid #eee;">${seatNo}</td>
<td style="padding:8px;border-bottom:1px solid #eee;">${cls}</td>
</tr>`;
})
.join('');
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin:0;font-family:Arial,Helvetica,sans-serif;color:#333;background:#f4f4f4;">
<div style="max-width:600px;margin:0 auto;background:#fff;">
<div style="background:#0066cc;color:#fff;padding:24px;text-align:center;">
<h2 style="margin:0;">Ethio-Djibouti Railway</h2>
<p style="margin:8px 0 0;">Payment successful — your ticket is ready</p>
</div>
<div style="padding:24px;">
<p>Booking reference: <strong>${booking.bookingRef}</strong></p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr>
<td style="padding:8px 0;color:#666;">From</td>
<td style="padding:8px 0;text-align:right;"><strong>${s.originStation?.name ?? ''}</strong> (${s.originStation?.code ?? ''})</td>
</tr>
<tr>
<td style="padding:8px 0;color:#666;">To</td>
<td style="padding:8px 0;text-align:right;"><strong>${s.destinationStation?.name ?? ''}</strong> (${s.destinationStation?.code ?? ''})</td>
</tr>
<tr>
<td style="padding:8px 0;color:#666;">Train</td>
<td style="padding:8px 0;text-align:right;">${s.train?.name ?? s.train?.number ?? ''}</td>
</tr>
<tr>
<td style="padding:8px 0;color:#666;">Departs</td>
<td style="padding:8px 0;text-align:right;">${fmt(s.departureAt)}</td>
</tr>
<tr>
<td style="padding:8px 0;color:#666;">Arrives</td>
<td style="padding:8px 0;text-align:right;">${fmt(s.arrivalAt)}</td>
</tr>
</table>
<h3 style="margin:16px 0 8px;">Passengers</h3>
<table style="width:100%;border-collapse:collapse;">
<tr style="text-align:left;color:#666;">
<th style="padding:8px;border-bottom:2px solid #eee;">Name</th>
<th style="padding:8px;border-bottom:2px solid #eee;">Coach</th>
<th style="padding:8px;border-bottom:2px solid #eee;">Seat</th>
<th style="padding:8px;border-bottom:2px solid #eee;">Class</th>
</tr>
${seatRows}
</table>
<div style="text-align:center;margin:24px 0;">
<p style="color:#666;margin:0 0 8px;">Show this QR code at the gate</p>
<img src="${ticket.qrPayload}" alt="Ticket QR code" width="180" height="180" style="border:1px solid #eee;padding:8px;background:#fff;" />
</div>
<table style="width:100%;border-collapse:collapse;border-top:2px solid #eee;margin-top:16px;">
<tr>
<td style="padding:12px 0;font-size:16px;"><strong>Total paid</strong></td>
<td style="padding:12px 0;font-size:16px;text-align:right;"><strong>${amount} ${currency}</strong></td>
</tr>
</table>
<div style="text-align:center;margin:24px 0;">
<a href="${url}" style="background:#0066cc;color:#fff;text-decoration:none;padding:12px 28px;border-radius:4px;display:inline-block;">View ticket</a>
</div>
</div>
<div style="text-align:center;padding:20px;color:#999;font-size:12px;">
<p style="margin:0;">© Ethio-Djibouti Railway. All rights reserved.</p>
</div>
</div>
</body>
</html>`;
}
@OnEvent('payment.failed')
async onPaymentFailed(payload: any) {
const booking = payload.booking; const booking = payload.booking;
await this.send( await this.send(
'payment.succeeded', 'payment.failed',
booking.passengerId, booking.passengerId,
{ {
bookingRef: booking.bookingRef, bookingRef: booking.bookingRef,
amount: this.formatAmount(booking),
currency: booking.displayCurrency ?? 'ETB',
category: 'PAYMENT', category: 'PAYMENT',
deepLink: `edr://tickets/${booking.bookingRef}`, deepLink: `edr://bookings/${booking.bookingRef}`,
},
['IN_APP', 'EMAIL', 'SMS'],
);
}
@OnEvent('booking.cancelled')
async onBookingCancelled(payload: any) {
const booking = payload.booking;
await this.send(
'booking.cancelled',
booking.passengerId,
{
bookingRef: booking.bookingRef,
// refundAmount is computed in ETB minor units in BookingsService.cancel().
refundAmount: ((payload.refundAmount ?? 0) / 100).toFixed(2),
currency: 'ETB',
category: 'BOOKING',
deepLink: `edr://bookings/${booking.bookingRef}`,
}, },
['IN_APP', 'EMAIL', 'SMS'], ['IN_APP', 'EMAIL', 'SMS'],
); );

View File

@@ -30,35 +30,39 @@ export class SmsClientService implements OnApplicationBootstrap {
}); });
} }
async sendSms(dto: SingleMessageDto) { async sendSms(dto: SingleMessageDto): Promise<{ queued: boolean }> {
if (!this.enabled) { if (!this.enabled) {
this.logger.warn(`RABBITMQ disabled — skipped SMS to ${dto.to}`); this.logger.warn(`RABBITMQ disabled — skipped SMS`);
return {}; return { queued: false };
} }
this.smsClient.emit("send-sms", { this.smsClient.emit("send-sms", {
...dto, to: dto.to,
text: dto.message,
appKey: "IFHCRS-LICENSE-MANAGEMENT", appKey: "IFHCRS-LICENSE-MANAGEMENT",
}); });
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
this.logger.log( this.logger.log(
`SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' to=${dto.to} message="${dto.message}"`, `SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms'`,
); );
return {}; // Recipient + content are PII — debug only.
this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`);
return { queued: true };
} }
async sendBulkMessages(dto: BulkMessagesDto) { async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> {
if (!this.enabled) { if (!this.enabled) {
this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`); this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`);
return {}; return { queued: false };
} }
const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from }));
this.smsClient.emit("ozeking-bulk-sms", { this.smsClient.emit("ozeking-bulk-sms", {
...dto, messages,
appKey: "IFHCRS-LICENSE-MANAGEMENT", appKey: "IFHCRS-LICENSE-MANAGEMENT",
}); });
this.logger.log( this.logger.log(
`BULK SMS emitted to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${dto.messages?.length ?? 0} messages=${JSON.stringify( `BULK SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length}`,
(dto.messages ?? []).map((m) => ({ to: m.to, message: m.message })),
)}`,
); );
return {}; this.logger.debug(`BULK SMS payload messages=${JSON.stringify(messages)}`);
return { queued: true };
} }
} }

View File

@@ -137,7 +137,9 @@ export class PaymentsService {
referenceType: PaymentReferenceType.BOOKING, referenceType: PaymentReferenceType.BOOKING,
referenceId: booking.id, referenceId: booking.id,
orderRef: booking.bookingRef, orderRef: booking.bookingRef,
amountMinor: booking.totalMinor, // Send the REAL (major) price, not minor units. The payment API no longer divides by 100
// (freight already passes the real price), so the providers charge this value as-is.
amountMinor: booking.totalMinor / 100,
currency: booking.currency, currency: booking.currency,
provider: method as unknown as ProviderMethod, provider: method as unknown as ProviderMethod,
platform: dto.platform, platform: dto.platform,
@@ -620,6 +622,12 @@ export class PaymentsService {
failureMessage: event.failureMessage, failureMessage: event.failureMessage,
}); });
} }
const failedBooking = await this.prisma.booking.findUnique({
where: { id: event.referenceId },
});
if (failedBooking) {
this.eventEmitter.emit("payment.failed", { booking: failedBooking });
}
return { processed: true }; return { processed: true };
} }
@@ -634,11 +642,15 @@ export class PaymentsService {
return { processed: false, reason: "booking-not-found" }; return { processed: false, reason: "booking-not-found" };
} }
if (booking.totalMinor !== event.amountMinor) { // The event carries the REAL (major) price the provider charged (passenger now sends
// booking.totalMinor/100 on initiate), so convert it back to minor units before comparing
// with booking.totalMinor (which is in minor units).
const eventAmountMinor = Math.round(event.amountMinor * 100);
if (booking.totalMinor !== eventAmountMinor) {
// Refuse to confirm: a 4xx makes the relay retry and eventually flag the row FAILED, // Refuse to confirm: a 4xx makes the relay retry and eventually flag the row FAILED,
// which is the alertable signal for an asserted-vs-paid amount divergence. // which is the alertable signal for an asserted-vs-paid amount divergence.
this.logger.error( this.logger.error(
`mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor}`, `mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor} (=${eventAmountMinor} minor)`,
); );
throw new BadRequestException( throw new BadRequestException(
"Event amount does not match booking total", "Event amount does not match booking total",

35
pnpm-lock.yaml generated
View File

@@ -452,9 +452,6 @@ importers:
'@prisma/client': '@prisma/client':
specifier: ^6.19.3 specifier: ^6.19.3
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
'@sendgrid/mail':
specifier: ^8.1.0
version: 8.1.6
axios: axios:
specifier: ^1.7.7 specifier: ^1.7.7
version: 1.17.0 version: 1.17.0
@@ -3756,18 +3753,6 @@ packages:
'@sec-ant/readable-stream@0.4.1': '@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@sendgrid/client@8.1.6':
resolution: {integrity: sha512-/BHu0hqwXNHr2aLhcXU7RmmlVqrdfrbY9KpaNj00KZHlVOVoRxRVrpOCabIB+91ISXJ6+mLM9vpaVUhK6TwBWA==}
engines: {node: '>=12.*'}
'@sendgrid/helpers@8.0.0':
resolution: {integrity: sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==}
engines: {node: '>= 12.0.0'}
'@sendgrid/mail@8.1.6':
resolution: {integrity: sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg==}
engines: {node: '>=12.*'}
'@sinclair/typebox@0.27.10': '@sinclair/typebox@0.27.10':
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
@@ -16642,26 +16627,6 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {} '@sec-ant/readable-stream@0.4.1': {}
'@sendgrid/client@8.1.6':
dependencies:
'@sendgrid/helpers': 8.0.0
axios: 1.17.0
transitivePeerDependencies:
- debug
- supports-color
'@sendgrid/helpers@8.0.0':
dependencies:
deepmerge: 4.3.1
'@sendgrid/mail@8.1.6':
dependencies:
'@sendgrid/client': 8.1.6
'@sendgrid/helpers': 8.0.0
transitivePeerDependencies:
- debug
- supports-color
'@sinclair/typebox@0.27.10': {} '@sinclair/typebox@0.27.10': {}
'@sindresorhus/merge-streams@4.0.0': {} '@sindresorhus/merge-streams@4.0.0': {}