Files
edr-platform/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts

400 lines
16 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 { CurrencyService } from '../currency/currency.service';
import {
PaymentReferenceType,
PaymentService as PaymentServiceEnum,
ProviderMethod,
ProviderPaymentStatus,
} from '@edr/types';
import { PaymentPlatformDto } from './payments.dto';
import { PaymentMethodType } from '@prisma/client';
const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours
/**
* WALLET is an internal balance debit handled inside this app, not a provider — the payment
* microservice rejects it as one. Supplementary charges have no wallet path, so it is refused up
* front with a message the payer can act on rather than a 502 from the gateway layer.
*/
const UNSUPPORTED_METHODS = new Set<string>([PaymentMethodType.WALLET]);
/**
* Push-debit methods charge an account we must be told up front — CAC Bank SMSes a one-time
* password to it, eBirr pushes a USSD PIN prompt to it. Neither opens a hosted page that could
* collect the number later (mirrors PaymentsService and ExcessBaggageService).
*/
const METHODS_REQUIRING_PAYER_ACCOUNT = new Set<string>([
PaymentMethodType.CAC_BANK,
PaymentMethodType.EBIRR,
]);
@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,
private currencyService: CurrencyService,
) {}
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;
}
/**
* What the payer is actually charged when settling this charge with `method`.
*
* The charge is raised in ETB, but the selected method settles in its own currency — WAAFI and
* D-Money in DJF, CARD in USD, the Ethiopian wallets in ETB — recorded on the PaymentMethod row.
* The payment microservice is currency-agnostic and hands whatever it is given straight to the
* gateway, so the ETB->settlement conversion has to happen here or the provider is asked to
* debit an ETB number labelled as its own currency.
*
* Both the quote shown to the payer and the amount sent to the provider come through this one
* method, so the price on the button and the price debited cannot drift apart.
*/
private async resolveChargeAmount(
charge: { amountMinor: number; currency: string },
method: string,
): Promise<{ amount: number; currency: string }> {
if (UNSUPPORTED_METHODS.has(method)) {
throw new BadRequestException(
`${method} is not available for balance payments`,
);
}
const paymentMethod = await this.prisma.paymentMethod.findUnique({
where: { type: method as PaymentMethodType },
});
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8) — never converted.
const chargeCurrency =
method === PaymentMethodType.CBE_BILL
? 'ETB'
: (paymentMethod?.currency ?? charge.currency).toUpperCase();
// Applies the target currency's own precision — DJF rounds to whole francs, ETB/USD to cents.
const amount = await this.currencyService.convertMinorToChargeMajor(
charge.amountMinor,
charge.currency,
chargeCurrency,
);
return { amount, currency: chargeCurrency };
}
/**
* Price quote for the pay page: what `method` would debit, in that method's settlement
* currency. The payer sees this before committing, and pay() recomputes it the same way.
*/
async quoteAmount(token: string, method: string) {
const charge = await this.getByToken(token);
const { amount, currency } = await this.resolveChargeAmount(charge, method);
return { chargeId: charge.id, method, currency, amount };
}
/**
* Bare status for the pay/result pages to poll. Unlike getByToken this does NOT reject a paid,
* expired or waived charge — reporting those states is the entire point. A CBE bill can settle
* long after the payer closed the tab, and redirect methods only converge when the settlement
* event lands, so the page needs something it can watch.
*/
async getStatus(token: string) {
const charge = await this.prisma.supplementaryCharge.findUnique({
where: { paymentToken: token },
select: {
id: true,
status: true,
paidAt: true,
amountMinor: true,
currency: true,
expiresAt: true,
},
});
if (!charge) throw new NotFoundException('Payment link not found');
return {
chargeId: charge.id,
status: charge.status,
paid: charge.status === 'PAID',
paidAt: charge.paidAt,
amountMinor: charge.amountMinor,
currency: charge.currency,
expiresAt: charge.expiresAt,
};
}
/**
* Full_Name for CBE's confirmation screen — mandatory in its envelope. The traveller the balance
* is owed against: lead passenger on the booking, falling back to the account holder.
*/
private async resolvePayerName(bookingId: string): Promise<string | null> {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: { seats: true, passenger: { include: { user: true } } },
});
if (!booking) return null;
return (
booking.seats?.find((s: any) => s.leg === 1)?.passengerName ??
booking.seats?.[0]?.passengerName ??
booking.passenger?.user?.fullName ??
null
);
}
/**
* Submit the one-time password for a COLLECT_OTP provider (CAC Bank). The bank SMSed it to the
* payerAccount given at pay(); this forwards it to the payment service and marks the charge paid
* when the debit settles. A wrong or expired OTP bubbles up as a 400 and the intent stays open,
* so the payer can simply re-enter it.
*
* Deliberately reads the charge directly rather than through getByToken: the bank is already
* holding a debit against this payer, and refusing to submit their OTP because the link TTL
* lapsed while they read the SMS would strand a payment that is mid-flight.
*/
async confirmOtp(token: string, otp: string) {
const charge = await this.prisma.supplementaryCharge.findUnique({
where: { paymentToken: token },
});
if (!charge) throw new NotFoundException('Payment link not found');
if (charge.status === 'PAID') {
return { chargeId: charge.id, status: 'SUCCEEDED', alreadyPaid: true };
}
const snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.SUPPLEMENTARY_CHARGE,
charge.id,
);
if (!snapshot) {
throw new NotFoundException('No active payment to confirm for this charge');
}
const confirmed = await this.paymentClient.confirmOtp(
snapshot.intentId,
otp,
);
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
await this.markPaid(charge.id, confirmed.providerTxnId);
}
return {
chargeId: charge.id,
status: confirmed.status,
alreadyPaid: false,
};
}
async pay(
token: string,
method: string,
platform?: PaymentPlatformDto,
requestOrigin?: string | null,
payerAccount?: string,
) {
const charge = await this.getByToken(token); // validates status/expiry
if (METHODS_REQUIRING_PAYER_ACCOUNT.has(method) && !payerAccount?.trim()) {
throw new BadRequestException(
`payerAccount (mobile number) is required for ${method}`,
);
}
const paymentMethod = method as ProviderMethod;
// Self-pay links are opened on whichever portal domain the recipient used
// (bookingedr.et vs passenger.edrsc.com), so the return pages must live on
// that same domain. `requestOrigin` is already allowlist-validated by the
// controller; PORTAL_URL is the fallback for non-browser callers.
const portalUrl =
requestOrigin ?? process.env.PORTAL_URL ?? 'http://localhost:5174';
const returnUrl = `${portalUrl}/pay-balance/${token}/success`;
const failureUrl = `${portalUrl}/pay-balance/${token}/failed`;
const { amount, currency } = await this.resolveChargeAmount(charge, method);
// CBE_BILL is inbound-only: no provider session is opened, the bill simply sits in CBE's
// system until someone pays it. It needs a real deadline and a payer name (Full_Name is
// mandatory in CBE's envelope) rather than the redirect flow's session semantics.
//
// Unlike an excess baggage charge (30-minute link TTL), this charge already carries a 72-hour
// deadline of its own, which is a sane bill lifetime — so it is passed straight through with
// no extension. That deadline is what stops the reconciliation sweep from expiring the intent
// early (CBE_IMPLEMENTATION_PLAN.md §6.4). A charge with no expiry at all yields no intent
// expiry either, which is correct: an open-ended debt backs an open-ended bill.
let payerName: string | undefined;
let expiresAt: string | undefined;
if (method === PaymentMethodType.CBE_BILL) {
expiresAt = charge.expiresAt?.toISOString();
payerName = (await this.resolvePayerName(charge.bookingId)) ?? undefined;
}
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.PASSENGER,
referenceType: PaymentReferenceType.SUPPLEMENTARY_CHARGE,
referenceId: charge.id,
orderRef: `SC-${charge.id.substring(0, 8)}`,
// `amountMinor` is the contract's name but its value is MAJOR units — the provider layer
// charges it verbatim at the currency's own precision (see PaymentIntentSnapshot).
amountMinor: amount,
currency,
provider: paymentMethod,
platform,
payerAccount: payerAccount?.trim() || undefined,
payerName,
expiresAt,
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}`);
}
}
}