mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
678 lines
25 KiB
TypeScript
678 lines
25 KiB
TypeScript
import {
|
|
Injectable,
|
|
NotFoundException,
|
|
BadRequestException,
|
|
Logger,
|
|
} from '@nestjs/common';
|
|
import { PrismaService } from '../../common/prisma.service';
|
|
import { AuditService } from '../../common/audit.service';
|
|
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
|
|
import { snapshot } from '../../common/audit-snapshot';
|
|
|
|
const ALLOWANCE_AUDIT_FIELDS = [
|
|
'seatClassId',
|
|
'maxWeightKg',
|
|
'maxPiecesCount',
|
|
'excessFeePerKg',
|
|
] as const;
|
|
import { CurrencyService } from '../currency/currency.service';
|
|
import { PaymentClientService } from '../payments/payment-client.service';
|
|
import { NotificationsService } from '../notifications/notifications.service';
|
|
import { SmsClientService } from '../notifications/sms-client.service';
|
|
import { EmailClientService } from '../notifications/email-client.service';
|
|
import {
|
|
LogExcessBaggageDto,
|
|
WaiveChargeDto,
|
|
InitiateExcessPaymentDto,
|
|
} from './excess-baggage.dto';
|
|
import {
|
|
PaymentService as PaymentServiceEnum,
|
|
PaymentReferenceType,
|
|
ProviderMethod,
|
|
ProviderPaymentStatus,
|
|
} from '@edr/types';
|
|
import { PaymentMethodType, PaymentIntentStatus } from '@prisma/client';
|
|
|
|
const CHARGE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
|
|
/**
|
|
* WALLET is an internal balance debit handled entirely inside this app (PaymentsService
|
|
* .initiateWalletPayment) — it is not a provider and the payment microservice rejects it as one.
|
|
* Excess baggage has no wallet path, so it is refused up front with a message a 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, so initiate is rejected without it (mirrors PaymentsService).
|
|
*/
|
|
const METHODS_REQUIRING_PAYER_ACCOUNT = new Set<string>([
|
|
PaymentMethodType.CAC_BANK,
|
|
PaymentMethodType.EBIRR,
|
|
]);
|
|
|
|
/**
|
|
* How long an excess baggage charge stays payable once a CBE bill has been issued for it.
|
|
*
|
|
* The 30-minute link TTL is a browser-session window: it assumes the payer is sitting in front of
|
|
* the page. A CBE bill is the opposite — the payer walks to a branch, or opens CBE Birr later, and
|
|
* the bill reference may already be written on a slip of paper. Handing the payment service a
|
|
* 30-minute `expiresAt` would also make the reconciliation sweep expire the intent and emit
|
|
* payment.failed within the hour (CBE_IMPLEMENTATION_PLAN.md §6.4 calls this the single most
|
|
* important detail of the integration).
|
|
*
|
|
* So issuing a bill EXTENDS the charge's own deadline to this window. `charge.expiresAt` stays the
|
|
* single source of truth for both the pay link and the bill.
|
|
*/
|
|
const CBE_BILL_WINDOW_HOURS = Number(
|
|
process.env.EXCESS_BAGGAGE_CBE_BILL_HOURS ?? 24,
|
|
);
|
|
|
|
@Injectable()
|
|
export class ExcessBaggageService {
|
|
private readonly logger = new Logger(ExcessBaggageService.name);
|
|
|
|
constructor(
|
|
private prisma: PrismaService,
|
|
private auditService: AuditService,
|
|
private currencyService: CurrencyService,
|
|
private paymentClient: PaymentClientService,
|
|
private notifications: NotificationsService,
|
|
private smsClient: SmsClientService,
|
|
private emailClient: EmailClientService,
|
|
) {}
|
|
|
|
async logCharge(dto: LogExcessBaggageDto) {
|
|
const bookingRef = dto.bookingReference?.trim();
|
|
const bookingId = dto.bookingId?.trim();
|
|
|
|
const booking = bookingRef
|
|
? await this.prisma.booking.findFirst({
|
|
where: { bookingRef: { equals: bookingRef, mode: 'insensitive' } },
|
|
include: {
|
|
passenger: { include: { user: true } },
|
|
},
|
|
})
|
|
: bookingId
|
|
? await this.prisma.booking.findUnique({
|
|
where: { id: bookingId },
|
|
include: {
|
|
passenger: { include: { user: true } },
|
|
},
|
|
})
|
|
: null;
|
|
|
|
if (!booking) throw new NotFoundException('Booking not found');
|
|
if (!['CONFIRMED', 'BOARDED'].includes(booking.status)) {
|
|
throw new BadRequestException('Booking must be CONFIRMED or BOARDED to log excess baggage');
|
|
}
|
|
|
|
const allowance = await this.prisma.baggageAllowance.findFirst({ orderBy: { createdAt: 'asc' } });
|
|
if (!allowance) throw new BadRequestException('No excess baggage rate configured. Please set a rate in Tariff Rates.');
|
|
const feePerKgMinor = allowance.excessFeePerKg;
|
|
|
|
const totalMinor = feePerKgMinor * dto.excessWeightKg;
|
|
const expiresAt = new Date(Date.now() + CHARGE_TTL_MS);
|
|
const contactPhone = dto.contactPhone?.trim() || (booking.contactPhone ?? booking.passenger?.user?.phone ?? null);
|
|
const contactEmail = dto.contactEmail?.trim() || (booking.contactEmail ?? booking.passenger?.user?.email ?? null);
|
|
|
|
const status = dto.collectCash ? 'CASH_COLLECTED' : 'PENDING';
|
|
const paidAt = dto.collectCash ? new Date() : null;
|
|
|
|
const charge = await this.prisma.excessBaggageCharge.create({
|
|
data: {
|
|
bookingId: booking.id,
|
|
agentId: dto.agentId ?? '',
|
|
excessWeightKg: dto.excessWeightKg,
|
|
feePerKgMinor,
|
|
totalMinor,
|
|
status,
|
|
expiresAt,
|
|
paidAt,
|
|
contactPhone,
|
|
contactEmail,
|
|
},
|
|
});
|
|
|
|
if (!dto.collectCash) {
|
|
await this.sendPaymentLink(charge, booking, contactPhone, contactEmail);
|
|
}
|
|
|
|
// The charge row carries contactPhone/contactEmail for the payment link; those stay out of
|
|
// the audit payload, which needs only the money and who raised it.
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.CREATE,
|
|
entityType: AUDIT_ENTITIES.ExcessBaggageCharge,
|
|
entityId: charge.id,
|
|
newData: {
|
|
bookingId: booking.id,
|
|
bookingRef: booking.bookingRef,
|
|
excessWeightKg: dto.excessWeightKg,
|
|
feePerKgMinor,
|
|
totalMinor,
|
|
currency: charge.currency,
|
|
status,
|
|
agentId: charge.agentId || null,
|
|
collectCash: dto.collectCash ?? false,
|
|
},
|
|
});
|
|
return charge;
|
|
}
|
|
|
|
private async sendPaymentLink(
|
|
charge: any,
|
|
booking: any,
|
|
phone: string | null,
|
|
email: string | null,
|
|
) {
|
|
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
|
|
const payUrl = `${portalUrl}/excess-baggage/pay/${charge.paymentToken}`;
|
|
const amountStr = (charge.totalMinor / 100).toFixed(2);
|
|
const msg = `EDR: Excess baggage charge of ${amountStr} ETB for booking ${booking.bookingRef}. Pay here: ${payUrl} (valid 30 min)`;
|
|
|
|
if (phone) {
|
|
try {
|
|
await this.smsClient.sendSms({ to: phone, message: msg });
|
|
} catch (err) {
|
|
this.logger.warn(`SMS send failed for excess baggage charge ${charge.id}: ${err}`);
|
|
}
|
|
}
|
|
if (email) {
|
|
try {
|
|
await this.emailClient.sendEmail({
|
|
to: email,
|
|
subject: `EDR — Excess baggage payment required (${booking.bookingRef})`,
|
|
text: msg,
|
|
});
|
|
} catch (err) {
|
|
this.logger.warn(`Email send failed for excess baggage charge ${charge.id}: ${err}`);
|
|
}
|
|
}
|
|
if (!phone && !email) {
|
|
this.logger.warn(`No contact info to send excess baggage payment link for charge ${charge.id}`);
|
|
}
|
|
}
|
|
|
|
async getCharge(id: string) {
|
|
const charge = await this.prisma.excessBaggageCharge.findUnique({
|
|
where: { id },
|
|
include: { booking: { select: { bookingRef: true, status: true } } },
|
|
});
|
|
if (!charge) throw new NotFoundException('Charge not found');
|
|
return charge;
|
|
}
|
|
|
|
async getByToken(token: string) {
|
|
const charge = await this.prisma.excessBaggageCharge.findUnique({
|
|
where: { paymentToken: token },
|
|
include: { booking: { select: { bookingRef: true, scheduleId: true } } },
|
|
});
|
|
if (!charge) throw new NotFoundException('Payment link not found');
|
|
if (charge.status === 'EXPIRED' || new Date() > charge.expiresAt) {
|
|
if (charge.status === 'PENDING') {
|
|
await this.prisma.excessBaggageCharge.update({
|
|
where: { id: charge.id },
|
|
data: { status: 'EXPIRED' },
|
|
});
|
|
}
|
|
throw new BadRequestException('This payment link has expired');
|
|
}
|
|
if (charge.status === 'PAID' || charge.status === 'CASH_COLLECTED') {
|
|
throw new BadRequestException('This charge has already been paid');
|
|
}
|
|
if (charge.status === 'WAIVED') {
|
|
throw new BadRequestException('This charge has been waived');
|
|
}
|
|
return charge;
|
|
}
|
|
|
|
/**
|
|
* What the payer is actually charged when paying this charge with `method`.
|
|
*
|
|
* The charge itself is always booked in ETB (`ExcessBaggageCharge.currency` defaults to ETB and
|
|
* nothing overrides it), but the selected method settles in its own currency — WAAFI/DMONEY 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
|
|
* verbatim, 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: { totalMinor: number; currency: string },
|
|
method: string,
|
|
): Promise<{ amount: number; currency: string }> {
|
|
if (UNSUPPORTED_METHODS.has(method)) {
|
|
throw new BadRequestException(
|
|
`${method} is not available for excess baggage 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. Every other
|
|
// method charges in its configured settlement currency, falling back to the charge's own.
|
|
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.totalMinor,
|
|
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 `initiatePayment` 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 };
|
|
}
|
|
|
|
async initiatePayment(token: string, dto: InitiateExcessPaymentDto) {
|
|
const charge = await this.getByToken(token);
|
|
|
|
if (
|
|
METHODS_REQUIRING_PAYER_ACCOUNT.has(dto.method) &&
|
|
!dto.payerAccount?.trim()
|
|
) {
|
|
throw new BadRequestException(
|
|
`payerAccount (mobile number) is required for ${dto.method}`,
|
|
);
|
|
}
|
|
|
|
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
|
|
const returnUrl = `${portalUrl}/excess-baggage/pay/${token}/result`;
|
|
|
|
const { amount, currency } = await this.resolveChargeAmount(
|
|
charge,
|
|
dto.method,
|
|
);
|
|
|
|
// CBE_BILL is inbound-only: no provider session is opened, the bill simply sits in CBE's
|
|
// system until someone pays it. It therefore needs a real deadline and a payer name (Full_Name
|
|
// is mandatory in CBE's envelope) rather than the redirect flow's session semantics.
|
|
let payerName: string | undefined;
|
|
let expiresAt: string | undefined;
|
|
if (dto.method === PaymentMethodType.CBE_BILL) {
|
|
const deadline = await this.extendForCbeBill(charge);
|
|
expiresAt = deadline.toISOString();
|
|
payerName = (await this.resolvePayerName(charge.bookingId)) ?? undefined;
|
|
}
|
|
|
|
const snapshot = await this.paymentClient.initiate({
|
|
service: PaymentServiceEnum.PASSENGER,
|
|
referenceType: 'EXCESS_BAGGAGE' as PaymentReferenceType,
|
|
referenceId: charge.id,
|
|
orderRef: `EXB-${charge.id.substring(0, 8).toUpperCase()}`,
|
|
// `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: dto.method as unknown as ProviderMethod,
|
|
platform: dto.platform as any,
|
|
payerAccount: dto.payerAccount?.trim() || undefined,
|
|
payerName,
|
|
expiresAt,
|
|
returnUrl,
|
|
failureUrl: returnUrl,
|
|
});
|
|
|
|
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
|
await this.markPaid(charge.id, snapshot.providerTxnId);
|
|
}
|
|
|
|
return {
|
|
chargeId: charge.id,
|
|
status: snapshot.status,
|
|
clientAction: snapshot.clientAction,
|
|
merchantOrderId: snapshot.merchantOrderId,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pushes the charge's deadline out to the CBE bill window and returns it. Only ever extends —
|
|
* a charge that already has longer left (a re-issued bill, an agent's resend) keeps it, so
|
|
* re-initiating a bill can never shorten a window the payer was already given.
|
|
*/
|
|
private async extendForCbeBill(charge: {
|
|
id: string;
|
|
expiresAt: Date;
|
|
}): Promise<Date> {
|
|
const target = new Date(Date.now() + CBE_BILL_WINDOW_HOURS * 60 * 60 * 1000);
|
|
if (charge.expiresAt >= target) return charge.expiresAt;
|
|
|
|
await this.prisma.excessBaggageCharge.update({
|
|
where: { id: charge.id },
|
|
data: { expiresAt: target },
|
|
});
|
|
this.logger.log(
|
|
`charge ${charge.id}: expiry extended to ${target.toISOString()} for CBE bill`,
|
|
);
|
|
return target;
|
|
}
|
|
|
|
/**
|
|
* Full_Name for CBE's confirmation screen — mandatory in its envelope. The passenger the
|
|
* baggage belongs to: lead traveller 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
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Bare status for the pay/result pages to poll. Unlike getByToken this does NOT reject a paid,
|
|
* expired or waived charge — the whole point is to report those states. A CBE bill can settle
|
|
* long after the payer closed the tab, and the 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.excessBaggageCharge.findUnique({
|
|
where: { paymentToken: token },
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
paidAt: true,
|
|
totalMinor: 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' || charge.status === 'CASH_COLLECTED',
|
|
paidAt: charge.paidAt,
|
|
totalMinor: charge.totalMinor,
|
|
currency: charge.currency,
|
|
expiresAt: charge.expiresAt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Submit the one-time password for a COLLECT_OTP provider (CAC Bank). The bank SMSed it to the
|
|
* payerAccount given at initiate; 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 30-minute
|
|
* link TTL lapsed while they were reading the SMS would strand a payment that is mid-flight.
|
|
*/
|
|
async confirmOtp(token: string, otp: string) {
|
|
const charge = await this.prisma.excessBaggageCharge.findUnique({
|
|
where: { paymentToken: token },
|
|
});
|
|
if (!charge) throw new NotFoundException('Payment link not found');
|
|
if (charge.status === 'PAID' || charge.status === 'CASH_COLLECTED') {
|
|
return { chargeId: charge.id, status: 'SUCCEEDED', alreadyPaid: true };
|
|
}
|
|
|
|
const snapshot = await this.paymentClient.getIntentByReference(
|
|
'EXCESS_BAGGAGE' as PaymentReferenceType,
|
|
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 markPaid(chargeId: string, providerTxnId?: string) {
|
|
const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id: chargeId } });
|
|
if (!charge) throw new NotFoundException('Charge not found');
|
|
if (charge.status === 'PAID') return charge;
|
|
|
|
// Conditional claim: PaymentsService.handleExcessBaggageChargeEvent drives the same
|
|
// transition from the webhook. Whichever caller actually flips the row writes the audit
|
|
// event, so the settlement is recorded exactly once regardless of which path won.
|
|
const { count } = await this.prisma.excessBaggageCharge.updateMany({
|
|
where: { id: chargeId, status: { notIn: ['PAID', 'CASH_COLLECTED'] } },
|
|
data: { status: 'PAID', paidAt: new Date() },
|
|
});
|
|
|
|
const updated = await this.prisma.excessBaggageCharge.findUnique({ where: { id: chargeId } });
|
|
|
|
if (count === 1) {
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.PAY,
|
|
entityType: AUDIT_ENTITIES.ExcessBaggageCharge,
|
|
entityId: chargeId,
|
|
oldData: { status: charge.status },
|
|
newData: {
|
|
status: 'PAID',
|
|
bookingId: charge.bookingId,
|
|
totalMinor: charge.totalMinor,
|
|
currency: charge.currency,
|
|
providerTxnId: providerTxnId ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
return updated!;
|
|
}
|
|
|
|
async waiveCharge(id: string, dto: WaiveChargeDto) {
|
|
const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id } });
|
|
if (!charge) throw new NotFoundException('Charge not found');
|
|
if (['PAID', 'CASH_COLLECTED'].includes(charge.status)) {
|
|
throw new BadRequestException('Cannot waive a charge that has already been paid');
|
|
}
|
|
const waived = await this.prisma.excessBaggageCharge.update({
|
|
where: { id },
|
|
data: { status: 'WAIVED', waivedBy: dto.waivedBy, waivedReason: dto.waivedReason },
|
|
});
|
|
// `dto.waivedBy` is a client-supplied label kept for the business column; the audit actor
|
|
// is resolved from the session by AuditService, so the two cannot disagree about who acted.
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.WAIVE,
|
|
entityType: AUDIT_ENTITIES.ExcessBaggageCharge,
|
|
entityId: id,
|
|
oldData: { status: charge.status },
|
|
newData: {
|
|
status: 'WAIVED',
|
|
bookingId: charge.bookingId,
|
|
totalMinor: charge.totalMinor,
|
|
currency: charge.currency,
|
|
waivedBy: dto.waivedBy,
|
|
waivedReason: dto.waivedReason,
|
|
},
|
|
});
|
|
return waived;
|
|
}
|
|
|
|
async resendLink(id: string) {
|
|
const charge = await this.prisma.excessBaggageCharge.findUnique({
|
|
where: { id },
|
|
include: { booking: { select: { bookingRef: true, passengerId: true } } },
|
|
});
|
|
if (!charge) throw new NotFoundException('Charge not found');
|
|
if (charge.status !== 'PENDING') {
|
|
throw new BadRequestException('Can only resend link for PENDING charges');
|
|
}
|
|
// Extend expiry by 30 minutes from now
|
|
const updatedCharge = await this.prisma.excessBaggageCharge.update({
|
|
where: { id },
|
|
data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) },
|
|
});
|
|
await this.sendPaymentLink(updatedCharge, charge.booking, charge.contactPhone, charge.contactEmail);
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.RESEND,
|
|
entityType: AUDIT_ENTITIES.ExcessBaggageCharge,
|
|
entityId: id,
|
|
oldData: { expiresAt: charge.expiresAt?.toISOString() },
|
|
newData: {
|
|
bookingRef: charge.booking.bookingRef,
|
|
expiresAt: updatedCharge.expiresAt?.toISOString(),
|
|
},
|
|
});
|
|
return { sent: true };
|
|
}
|
|
|
|
async getAll(filters: {
|
|
status?: string;
|
|
bookingRef?: string;
|
|
dateFrom?: string;
|
|
dateTo?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
}) {
|
|
const { status, bookingRef, dateFrom, dateTo, 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' } };
|
|
if (dateFrom || dateTo) {
|
|
where.createdAt = {
|
|
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
|
|
...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}),
|
|
};
|
|
}
|
|
|
|
// Mark expired charges before fetching
|
|
await this.prisma.excessBaggageCharge.updateMany({
|
|
where: {
|
|
status: 'PENDING',
|
|
expiresAt: { lt: new Date() },
|
|
},
|
|
data: { status: 'EXPIRED' },
|
|
});
|
|
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.excessBaggageCharge.findMany({
|
|
where,
|
|
include: { booking: { select: { bookingRef: true, status: true } } },
|
|
orderBy: { createdAt: 'desc' },
|
|
skip,
|
|
take: pageSize,
|
|
}),
|
|
this.prisma.excessBaggageCharge.count({ where }),
|
|
]);
|
|
|
|
return { items, total, page, pageSize };
|
|
}
|
|
|
|
async getAllowances() {
|
|
const [allowances, seatClasses] = await Promise.all([
|
|
this.prisma.baggageAllowance.findMany({ orderBy: { createdAt: 'asc' } }),
|
|
this.prisma.seatClass.findMany({ select: { id: true, name: true } }),
|
|
]);
|
|
const scMap = new Map(seatClasses.map(s => [s.id, s]));
|
|
return allowances.map(a => ({ ...a, seatClass: scMap.get(a.seatClassId) ?? null }));
|
|
}
|
|
|
|
async upsertAllowance(dto: { seatClassId: string; maxWeightKg?: number; maxPiecesCount?: number; excessFeePerKg: number }) {
|
|
const existing = await this.prisma.baggageAllowance.findFirst({ where: { seatClassId: dto.seatClassId } });
|
|
if (existing) {
|
|
const updated = await this.prisma.baggageAllowance.update({
|
|
where: { id: existing.id },
|
|
data: { maxWeightKg: dto.maxWeightKg ?? 0, maxPiecesCount: dto.maxPiecesCount ?? 0, excessFeePerKg: dto.excessFeePerKg },
|
|
});
|
|
// An upsert, so report the edit rather than always claiming a create — this is a tariff
|
|
// change and the previous fee is the whole point of the row.
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.UPDATE,
|
|
entityType: AUDIT_ENTITIES.BaggageAllowance,
|
|
entityId: existing.id,
|
|
oldData: snapshot(existing, ALLOWANCE_AUDIT_FIELDS),
|
|
newData: snapshot(updated, ALLOWANCE_AUDIT_FIELDS),
|
|
});
|
|
return updated;
|
|
}
|
|
const created = await this.prisma.baggageAllowance.create({
|
|
data: { seatClassId: dto.seatClassId, maxWeightKg: dto.maxWeightKg ?? 0, maxPiecesCount: dto.maxPiecesCount ?? 0, excessFeePerKg: dto.excessFeePerKg },
|
|
});
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.CREATE,
|
|
entityType: AUDIT_ENTITIES.BaggageAllowance,
|
|
entityId: created.id,
|
|
newData: snapshot(created, ALLOWANCE_AUDIT_FIELDS),
|
|
});
|
|
return created;
|
|
}
|
|
|
|
async updateAllowance(id: string, dto: Partial<{ maxWeightKg: number; maxPiecesCount: number; excessFeePerKg: number }>) {
|
|
const existing = await this.prisma.baggageAllowance.findUnique({ where: { id } });
|
|
if (!existing) throw new NotFoundException('Baggage allowance not found');
|
|
const updated = await this.prisma.baggageAllowance.update({ where: { id }, data: dto });
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.UPDATE,
|
|
entityType: AUDIT_ENTITIES.BaggageAllowance,
|
|
entityId: id,
|
|
oldData: snapshot(existing, ALLOWANCE_AUDIT_FIELDS),
|
|
newData: snapshot(updated, ALLOWANCE_AUDIT_FIELDS),
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
async deleteAllowance(id: string) {
|
|
const existing = await this.prisma.baggageAllowance.findUnique({ where: { id } });
|
|
await this.prisma.baggageAllowance.deleteMany({ where: { id } });
|
|
if (existing) {
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.DELETE,
|
|
entityType: AUDIT_ENTITIES.BaggageAllowance,
|
|
entityId: id,
|
|
oldData: snapshot(existing, ALLOWANCE_AUDIT_FIELDS),
|
|
});
|
|
}
|
|
return { deleted: true };
|
|
}
|
|
|
|
async deleteCharge(id: string) {
|
|
const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id } });
|
|
if (!charge) throw new NotFoundException('Charge not found');
|
|
|
|
await this.prisma.excessBaggageCharge.delete({ where: { id } });
|
|
// Hard delete of a money record — previously silent.
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.DELETE,
|
|
entityType: AUDIT_ENTITIES.ExcessBaggageCharge,
|
|
entityId: id,
|
|
oldData: {
|
|
bookingId: charge.bookingId,
|
|
excessWeightKg: charge.excessWeightKg,
|
|
totalMinor: charge.totalMinor,
|
|
currency: charge.currency,
|
|
status: charge.status,
|
|
},
|
|
});
|
|
return { deleted: true };
|
|
}
|
|
}
|