mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "SupplementaryCharge" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bookingId" TEXT NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"amountMinor" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"paymentToken" TEXT NOT NULL,
|
||||
"providerTxnId" TEXT,
|
||||
"notes" TEXT,
|
||||
"createdBy" TEXT NOT NULL,
|
||||
"paidAt" TIMESTAMP(3),
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SupplementaryCharge_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SupplementaryCharge_paymentToken_key" ON "SupplementaryCharge"("paymentToken");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SupplementaryCharge_bookingId_idx" ON "SupplementaryCharge"("bookingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SupplementaryCharge_paymentToken_idx" ON "SupplementaryCharge"("paymentToken");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SupplementaryCharge_status_idx" ON "SupplementaryCharge"("status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SupplementaryCharge" ADD CONSTRAINT "SupplementaryCharge_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -567,6 +567,7 @@ model Booking {
|
||||
cancellation BookingCancellation?
|
||||
baggage BaggageBooking[]
|
||||
excessBaggageCharges ExcessBaggageCharge[]
|
||||
supplementaryCharges SupplementaryCharge[]
|
||||
journey Journey?
|
||||
|
||||
@@index([passengerId, status])
|
||||
@@ -1234,6 +1235,28 @@ model BaggageBooking {
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model SupplementaryCharge {
|
||||
id String @id @default(uuid())
|
||||
bookingId String
|
||||
reason String // e.g. "UNDERPAYMENT", "FARE_CORRECTION"
|
||||
amountMinor Int
|
||||
currency String @default("ETB")
|
||||
status String @default("PENDING") // PENDING | PAID | WAIVED | EXPIRED
|
||||
paymentToken String @unique @default(uuid())
|
||||
providerTxnId String?
|
||||
notes String?
|
||||
createdBy String
|
||||
paidAt DateTime?
|
||||
expiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
|
||||
@@index([bookingId])
|
||||
@@index([paymentToken])
|
||||
@@index([status])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model ExcessBaggageCharge {
|
||||
id String @id @default(uuid())
|
||||
bookingId String
|
||||
|
||||
@@ -39,12 +39,34 @@ import {
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util";
|
||||
import { SupplementaryChargesService } from "./supplementary-charges.service";
|
||||
import { IsString, IsInt, IsOptional, Min, IsEnum, IsIn } from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
class CreateSupplementaryChargeDto {
|
||||
@ApiProperty({ example: 'EDR-20240001', description: 'Booking reference number' }) @IsString() bookingRef: string;
|
||||
@ApiProperty({ description: 'Amount owed in minor units (e.g. 5000 = 50 ETB)' }) @IsInt() @Min(1) amountMinor: number;
|
||||
@ApiProperty({ example: 'UNDERPAYMENT' }) @IsString() reason: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
|
||||
class WaiveSupplementaryChargeDto {
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
|
||||
class PaySupplementaryChargeDto {
|
||||
@ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
|
||||
@ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile']) platform?: 'web' | 'mobile';
|
||||
}
|
||||
|
||||
@ApiTags("Payment")
|
||||
@Controller("payments")
|
||||
// @Throttle({ strict: { limit: 20, ttl: 60_000 } })
|
||||
export class PaymentsController {
|
||||
constructor(private service: PaymentsService) {}
|
||||
constructor(
|
||||
private service: PaymentsService,
|
||||
private supplementaryService: SupplementaryChargesService,
|
||||
) {}
|
||||
|
||||
@Delete(":id")
|
||||
@PassengerStaff([PASSENGER_PERMS.admin])
|
||||
@@ -315,6 +337,92 @@ export class PaymentsController {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Supplementary Charges ──────────────────────────────────────────────────
|
||||
|
||||
@Post('supplementary')
|
||||
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' })
|
||||
createSupplementaryCharge(
|
||||
@Body() dto: CreateSupplementaryChargeDto,
|
||||
@Headers('x-iam-user-id') iamUserId?: string,
|
||||
) {
|
||||
return this.supplementaryService.create({
|
||||
...dto,
|
||||
createdBy: iamUserId ?? 'staff',
|
||||
});
|
||||
}
|
||||
|
||||
@Get('supplementary')
|
||||
@PassengerStaff([PASSENGER_PERMS.payments.view, PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'List supplementary charges (staff only)' })
|
||||
@ApiQuery({ name: 'bookingRef', required: false })
|
||||
@ApiQuery({ name: 'status', required: false })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
listSupplementaryCharges(
|
||||
@Query('bookingRef') bookingRef?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.supplementaryService.getAll({
|
||||
bookingRef,
|
||||
status,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('supplementary/by-token/:token')
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({ summary: 'Get supplementary charge by payment token (public — for self-pay page)' })
|
||||
getSupplementaryByToken(@Param('token') token: string) {
|
||||
return this.supplementaryService.getByToken(token);
|
||||
}
|
||||
|
||||
@Post('supplementary/by-token/:token/pay')
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({ summary: 'Initiate payment for a supplementary charge (public — self-pay)' })
|
||||
paySupplementaryCharge(
|
||||
@Param('token') token: string,
|
||||
@Body() dto: PaySupplementaryChargeDto,
|
||||
) {
|
||||
return this.supplementaryService.pay(token, dto.method, dto.platform);
|
||||
}
|
||||
|
||||
@Post('supplementary/:id/mark-paid')
|
||||
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Manually mark a supplementary charge as paid (staff only)' })
|
||||
markSupplementaryPaid(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { providerTxnId?: string },
|
||||
) {
|
||||
return this.supplementaryService.markPaid(id, body.providerTxnId);
|
||||
}
|
||||
|
||||
@Post('supplementary/:id/waive')
|
||||
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Waive a supplementary charge (staff only)' })
|
||||
waiveSupplementaryCharge(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: WaiveSupplementaryChargeDto,
|
||||
@Headers('x-iam-user-id') iamUserId?: string,
|
||||
) {
|
||||
return this.supplementaryService.waive(id, dto.notes ?? '', iamUserId ?? 'staff');
|
||||
}
|
||||
|
||||
@Post('supplementary/:id/resend')
|
||||
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Resend payment link for a supplementary charge (staff only)' })
|
||||
resendSupplementaryLink(@Param('id') id: string) {
|
||||
return this.supplementaryService.resendLink(id);
|
||||
}
|
||||
|
||||
private buildRedirectHtml(url: string): string {
|
||||
const escaped = url.replace(/\"/g, """);
|
||||
return `<!DOCTYPE html>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@edr/types";
|
||||
import { PaymentsController } from "./payments.controller";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
import { SupplementaryChargesService } from "./supplementary-charges.service";
|
||||
import { InternalPaymentsController } from "./internal-payments.controller";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { PaymentEventsConsumer } from "./payment-events.consumer";
|
||||
@@ -21,6 +22,8 @@ import { TicketsModule } from "../tickets/tickets.module";
|
||||
import { CurrencyModule } from "../currency/currency.module";
|
||||
import { AuditModule } from "../../common/audit.module";
|
||||
|
||||
import { NotificationsModule } from "../notifications/notifications.module";
|
||||
|
||||
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
|
||||
|
||||
function rabbitMQImport(): DynamicModule[] {
|
||||
@@ -55,8 +58,7 @@ function rabbitMQImport(): DynamicModule[] {
|
||||
TicketsModule,
|
||||
CurrencyModule,
|
||||
AuditModule,
|
||||
// The payment service proxies slow provider calls (e.g. CAC Bank initiate, which SMSes an
|
||||
// OTP and can take tens of seconds). Keep this hop generous; overridable via env.
|
||||
NotificationsModule,
|
||||
HttpModule.register({
|
||||
timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000,
|
||||
}),
|
||||
@@ -65,6 +67,7 @@ function rabbitMQImport(): DynamicModule[] {
|
||||
controllers: [PaymentsController, InternalPaymentsController],
|
||||
providers: [
|
||||
PaymentsService,
|
||||
SupplementaryChargesService,
|
||||
PaymentClientService,
|
||||
PaymentEventsConsumer,
|
||||
ServiceAuthGuard,
|
||||
|
||||
@@ -840,19 +840,46 @@ 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`);
|
||||
return { processed: true };
|
||||
}
|
||||
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' };
|
||||
}
|
||||
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 },
|
||||
});
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: charge.id, newData: { status: 'PAID', providerTxnId: event.providerTxnId } });
|
||||
return { processed: true };
|
||||
}
|
||||
|
||||
async handlePaymentEvent(
|
||||
event: PaymentEventDto,
|
||||
): Promise<MarkPaidResponseDto> {
|
||||
if (
|
||||
event.service !== PaymentServiceEnum.PASSENGER ||
|
||||
event.referenceType !== PaymentReferenceType.BOOKING
|
||||
) {
|
||||
if (event.service !== PaymentServiceEnum.PASSENGER) {
|
||||
this.logger.warn(
|
||||
`mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`,
|
||||
);
|
||||
return { processed: false, reason: "foreign-reference" };
|
||||
}
|
||||
|
||||
if (event.referenceType === PaymentReferenceType.SUPPLEMENTARY_CHARGE) {
|
||||
return this.handleSupplementaryChargeEvent(event);
|
||||
}
|
||||
|
||||
if (event.referenceType !== PaymentReferenceType.BOOKING) {
|
||||
this.logger.warn(
|
||||
`mark-paid: ignoring unknown referenceType ${event.referenceType}`,
|
||||
);
|
||||
return { processed: false, reason: "foreign-reference" };
|
||||
}
|
||||
|
||||
if (event.eventType === "payment.failed") {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId: event.referenceId },
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { EmailClientService } from '../notifications/email-client.service';
|
||||
import { PaymentClientService } from './payment-client.service';
|
||||
import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod } from '@edr/types';
|
||||
|
||||
const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours
|
||||
|
||||
@Injectable()
|
||||
export class SupplementaryChargesService {
|
||||
private readonly logger = new Logger(SupplementaryChargesService.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private auditService: AuditService,
|
||||
private smsClient: SmsClientService,
|
||||
private emailClient: EmailClientService,
|
||||
private paymentClient: PaymentClientService,
|
||||
) {}
|
||||
|
||||
async create(dto: {
|
||||
bookingRef: string;
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
notes?: string;
|
||||
createdBy: string;
|
||||
}) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef: dto.bookingRef },
|
||||
include: { passenger: { include: { user: true } } },
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
if (!['CONFIRMED', 'BOARDED'].includes(booking.status)) {
|
||||
throw new BadRequestException('Booking must be CONFIRMED or BOARDED to raise a supplementary charge');
|
||||
}
|
||||
if (dto.amountMinor <= 0) throw new BadRequestException('Amount must be positive');
|
||||
|
||||
const expiresAt = new Date(Date.now() + CHARGE_TTL_MS);
|
||||
const charge = await this.prisma.supplementaryCharge.create({
|
||||
data: {
|
||||
bookingId: booking.id,
|
||||
reason: dto.reason,
|
||||
amountMinor: dto.amountMinor,
|
||||
notes: dto.notes ?? null,
|
||||
createdBy: dto.createdBy,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
const phone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null;
|
||||
const email = booking.contactEmail ?? booking.passenger?.user?.email ?? null;
|
||||
await this.sendLink(charge, booking.bookingRef, phone, email);
|
||||
|
||||
await this.auditService.log({
|
||||
action: 'CREATE',
|
||||
entityType: 'SupplementaryCharge',
|
||||
entityId: charge.id,
|
||||
newData: { bookingRef: dto.bookingRef, amountMinor: dto.amountMinor, reason: dto.reason },
|
||||
});
|
||||
return charge;
|
||||
}
|
||||
|
||||
async getAll(filters: { bookingRef?: string; status?: string; page?: number; pageSize?: number }) {
|
||||
const { bookingRef, status, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where: any = {};
|
||||
if (status) where.status = status;
|
||||
if (bookingRef) where.booking = { bookingRef: { contains: bookingRef, mode: 'insensitive' } };
|
||||
|
||||
await this.prisma.supplementaryCharge.updateMany({
|
||||
where: { status: 'PENDING', expiresAt: { lt: new Date() } },
|
||||
data: { status: 'EXPIRED' },
|
||||
});
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.supplementaryCharge.findMany({
|
||||
where,
|
||||
include: { booking: { select: { bookingRef: true, status: true, contactPhone: true, contactEmail: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.supplementaryCharge.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getByToken(token: string) {
|
||||
const charge = await this.prisma.supplementaryCharge.findUnique({
|
||||
where: { paymentToken: token },
|
||||
include: { booking: { select: { bookingRef: true } } },
|
||||
});
|
||||
if (!charge) throw new NotFoundException('Payment link not found');
|
||||
if (charge.status === 'PAID') throw new BadRequestException('This charge has already been paid');
|
||||
if (charge.status === 'WAIVED') throw new BadRequestException('This charge has been waived');
|
||||
if (charge.status === 'EXPIRED' || (charge.expiresAt && new Date() > charge.expiresAt)) {
|
||||
if (charge.status === 'PENDING') {
|
||||
await this.prisma.supplementaryCharge.update({ where: { id: charge.id }, data: { status: 'EXPIRED' } });
|
||||
}
|
||||
throw new BadRequestException('This payment link has expired');
|
||||
}
|
||||
return charge;
|
||||
}
|
||||
|
||||
async markPaid(id: string, providerTxnId?: string) {
|
||||
const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id } });
|
||||
if (!charge) throw new NotFoundException('Charge not found');
|
||||
if (charge.status === 'PAID') return charge;
|
||||
const updated = await this.prisma.supplementaryCharge.update({
|
||||
where: { id },
|
||||
data: { status: 'PAID', paidAt: new Date(), providerTxnId: providerTxnId ?? null },
|
||||
});
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'PAID' } });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async pay(token: string, method: string, platform?: 'web' | 'mobile') {
|
||||
const charge = await this.getByToken(token); // validates status/expiry
|
||||
|
||||
const paymentMethod = method as ProviderMethod;
|
||||
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
|
||||
const returnUrl = `${portalUrl}/pay-balance/${token}/success`;
|
||||
const failureUrl = `${portalUrl}/pay-balance/${token}/failed`;
|
||||
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.PASSENGER,
|
||||
referenceType: PaymentReferenceType.SUPPLEMENTARY_CHARGE,
|
||||
referenceId: charge.id,
|
||||
orderRef: `SC-${charge.id.substring(0, 8)}`,
|
||||
amountMinor: charge.amountMinor,
|
||||
currency: charge.currency,
|
||||
provider: paymentMethod,
|
||||
platform,
|
||||
returnUrl,
|
||||
failureUrl,
|
||||
});
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async waive(id: string, notes: string, waivedBy: string) {
|
||||
const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id } });
|
||||
if (!charge) throw new NotFoundException('Charge not found');
|
||||
if (charge.status === 'PAID') throw new BadRequestException('Cannot waive a paid charge');
|
||||
const updated = await this.prisma.supplementaryCharge.update({
|
||||
where: { id },
|
||||
data: { status: 'WAIVED', notes },
|
||||
});
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'WAIVED', waivedBy, notes } });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async resendLink(id: string) {
|
||||
const charge = await this.prisma.supplementaryCharge.findUnique({
|
||||
where: { id },
|
||||
include: { booking: { select: { bookingRef: true, contactPhone: true, contactEmail: true } } },
|
||||
});
|
||||
if (!charge) throw new NotFoundException('Charge not found');
|
||||
if (charge.status !== 'PENDING') throw new BadRequestException('Can only resend link for PENDING charges');
|
||||
const updated = await this.prisma.supplementaryCharge.update({
|
||||
where: { id },
|
||||
data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) },
|
||||
});
|
||||
await this.sendLink(updated, charge.booking.bookingRef, charge.booking.contactPhone, charge.booking.contactEmail);
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
private async sendLink(charge: any, bookingRef: string, phone: string | null, email: string | null) {
|
||||
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
|
||||
const payUrl = `${portalUrl}/pay-balance/${charge.paymentToken}`;
|
||||
const amount = (charge.amountMinor / 100).toFixed(2);
|
||||
const msg = `EDR: A balance of ${amount} ETB is outstanding for booking ${bookingRef}. Pay here: ${payUrl}`;
|
||||
|
||||
if (phone) {
|
||||
try { await this.smsClient.sendSms({ to: phone, message: msg }); }
|
||||
catch (err) { this.logger.warn(`SMS failed for supplementary charge ${charge.id}: ${err}`); }
|
||||
}
|
||||
if (email) {
|
||||
try {
|
||||
await this.emailClient.sendEmail({
|
||||
to: email,
|
||||
subject: `EDR — Outstanding balance for booking ${bookingRef}`,
|
||||
text: msg,
|
||||
});
|
||||
} catch (err) { this.logger.warn(`Email failed for supplementary charge ${charge.id}: ${err}`); }
|
||||
}
|
||||
if (!phone && !email) {
|
||||
this.logger.warn(`No contact info for supplementary charge ${charge.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user