mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
194 lines
7.9 KiB
TypeScript
194 lines
7.9 KiB
TypeScript
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}`);
|
|
}
|
|
}
|
|
}
|