mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
feat: ( audit ) resolve the actor from the session and audit all backoffice mutations
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
|
||||
|
||||
@ApiTags('Audit')
|
||||
@Controller('audit')
|
||||
@@ -16,24 +17,52 @@ export class AuditController {
|
||||
summary: 'Get audit logs',
|
||||
description: 'Retrieve system audit logs with optional filtering',
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by user email or entity ID' })
|
||||
@ApiQuery({ name: 'action', required: false, description: 'Filter by action (CREATE, UPDATE, DELETE, etc.)' })
|
||||
@ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (Booking, Station, etc.)' })
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Match entity ID, actor ID, actor name, or actor phone' })
|
||||
@ApiQuery({ name: 'action', required: false, description: 'Filter by action (CREATE, UPDATE, DELETE, BOARD, WAIVE, ...)' })
|
||||
@ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (Booking, Station, Ticket, ...)' })
|
||||
@ApiQuery({ name: 'iamUserId', required: false, description: 'Exact IAM user id — everything one staff member did' })
|
||||
@ApiQuery({ name: 'from', required: false, description: 'Earliest createdAt (ISO 8601), inclusive' })
|
||||
@ApiQuery({ name: 'to', required: false, description: 'Latest createdAt (ISO 8601), inclusive' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Page size (default 50, max 200)' })
|
||||
@ApiQuery({ name: 'offset', required: false, type: Number, description: 'Rows to skip (default 0)' })
|
||||
async getLogs(
|
||||
@Query('search') search?: string,
|
||||
@Query('action') action?: string,
|
||||
@Query('entityType') entityType?: string,
|
||||
@Query('iamUserId') iamUserId?: string,
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
// The service has always implemented paging; the controller simply never forwarded it, which
|
||||
// pinned the backoffice page and its CSV export to the 50 newest rows.
|
||||
@Query('limit', new ParseIntPipe({ optional: true })) limit?: number,
|
||||
@Query('offset', new ParseIntPipe({ optional: true })) offset?: number,
|
||||
) {
|
||||
const filters = {
|
||||
const result = await this.auditService.getLogs({
|
||||
search: search || undefined,
|
||||
action: action || undefined,
|
||||
entityType: entityType || undefined,
|
||||
};
|
||||
|
||||
const result = await this.auditService.getLogs(filters);
|
||||
iamUserId: iamUserId || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
return { items: result.data, total: result.total, limit: result.limit, offset: result.offset };
|
||||
}
|
||||
|
||||
/**
|
||||
* The vocabularies the writers use, so the backoffice filters stay in step with what the API
|
||||
* actually records instead of drifting behind a hand-maintained list.
|
||||
*/
|
||||
@Get('vocabulary')
|
||||
@ApiOperation({ summary: 'Audit action and entity-type vocabularies' })
|
||||
getVocabulary() {
|
||||
return {
|
||||
actions: Object.values(AUDIT_ACTIONS),
|
||||
entityTypes: Object.values(AUDIT_ENTITIES),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('logs/:id')
|
||||
@ApiOperation({ summary: 'Get audit log by ID' })
|
||||
async getLog(@Param('id') id: string) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
|
||||
|
||||
@Injectable()
|
||||
export class CurrenciesService {
|
||||
@@ -59,7 +60,17 @@ export class CurrenciesService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.auditService.log({ action: 'CREATE', entityType: 'Currency', entityId: rate.id, newData: { code, exchangeRate } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.CREATE,
|
||||
entityType: AUDIT_ENTITIES.Currency,
|
||||
entityId: rate.id,
|
||||
newData: {
|
||||
fromCurrency: rate.fromCurrency,
|
||||
toCurrency: rate.toCurrency,
|
||||
rate: Number(rate.rate),
|
||||
source: rate.source,
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: rate.id,
|
||||
code: rate.toCurrency,
|
||||
@@ -95,7 +106,22 @@ export class CurrenciesService {
|
||||
'MANUAL',
|
||||
);
|
||||
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Currency', entityId: updated.id, newData: { exchangeRate: Number(updated.rate) } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UPDATE,
|
||||
entityType: AUDIT_ENTITIES.Currency,
|
||||
entityId: updated.id,
|
||||
oldData: {
|
||||
fromCurrency: existing.fromCurrency,
|
||||
toCurrency: existing.toCurrency,
|
||||
rate: Number(existing.rate),
|
||||
},
|
||||
newData: {
|
||||
fromCurrency: updated.fromCurrency,
|
||||
toCurrency: updated.toCurrency,
|
||||
rate: Number(updated.rate),
|
||||
source: updated.source,
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: updated.id,
|
||||
code: updated.toCurrency,
|
||||
@@ -112,6 +138,8 @@ export class CurrenciesService {
|
||||
async syncExchangeRates() {
|
||||
// Placeholder: in production this would fetch from an external FX API.
|
||||
// For now, return the current rates as-is.
|
||||
// Deliberately unaudited: this writes nothing today. Instrument it in the same commit that
|
||||
// gives it a real external fetch, otherwise the trail claims a change that never happened.
|
||||
const currencies = await this.getAllCurrencies();
|
||||
return { synced: true, rates: currencies };
|
||||
}
|
||||
@@ -129,7 +157,16 @@ export class CurrenciesService {
|
||||
await this.prisma.currencyExchangeRate.deleteMany({
|
||||
where: { fromCurrency: existing.fromCurrency, toCurrency: existing.toCurrency },
|
||||
});
|
||||
await this.auditService.log({ action: 'DELETE', entityType: 'Currency', entityId: id, oldData: { toCurrency: existing.toCurrency } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.DELETE,
|
||||
entityType: AUDIT_ENTITIES.Currency,
|
||||
entityId: id,
|
||||
oldData: {
|
||||
fromCurrency: existing.fromCurrency,
|
||||
toCurrency: existing.toCurrency,
|
||||
rate: Number(existing.rate),
|
||||
},
|
||||
});
|
||||
return { message: 'Currency deleted successfully' };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { ExcessBaggageService } from './excess-baggage.service';
|
||||
|
||||
/**
|
||||
* Excess baggage settles through two paths — the in-app one (`markPaid`, reached from
|
||||
* `initiatePayment`/`confirmOtp`) and the payment webhook. The in-app path used to write no
|
||||
* audit row at all, so whether a settlement was recorded depended on which route reached it
|
||||
* first. Both now claim the transition conditionally, so it lands exactly once either way.
|
||||
*/
|
||||
describe('ExcessBaggageService — audit', () => {
|
||||
const CHARGE_ID = 'ebc-1';
|
||||
const BOOKING_ID = 'booking-1';
|
||||
|
||||
let prisma: Record<string, any>;
|
||||
let audit: { log: jest.Mock };
|
||||
let service: ExcessBaggageService;
|
||||
|
||||
const build = (charge: Record<string, any> = {}) => {
|
||||
const row = {
|
||||
id: CHARGE_ID,
|
||||
bookingId: BOOKING_ID,
|
||||
agentId: 'iam-agent-1',
|
||||
excessWeightKg: 8,
|
||||
feePerKgMinor: 5000,
|
||||
totalMinor: 40000,
|
||||
currency: 'ETB',
|
||||
status: 'PENDING',
|
||||
paymentToken: 'tok-live-secret',
|
||||
contactPhone: '+251911223344',
|
||||
contactEmail: 'passenger@example.com',
|
||||
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
|
||||
booking: { bookingRef: 'EDR-0001', passengerId: 'p-1' },
|
||||
...charge,
|
||||
};
|
||||
|
||||
prisma = {
|
||||
excessBaggageCharge: {
|
||||
findUnique: jest.fn().mockResolvedValue(row),
|
||||
// Default: this caller wins the race and flips the row.
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
update: jest.fn().mockResolvedValue({ ...row, status: 'WAIVED' }),
|
||||
delete: jest.fn().mockResolvedValue(row),
|
||||
create: jest.fn().mockResolvedValue(row),
|
||||
},
|
||||
baggageAllowance: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
};
|
||||
audit = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
// Constructor order: prisma, audit, currency, paymentClient, notifications, sms, email.
|
||||
service = new ExcessBaggageService(
|
||||
prisma as any,
|
||||
audit as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{ sendSms: jest.fn() } as any,
|
||||
{ sendEmail: jest.fn() } as any,
|
||||
);
|
||||
return row;
|
||||
};
|
||||
|
||||
const rows = () => audit.log.mock.calls.map((c) => c[0]);
|
||||
const byAction = (action: string) => rows().filter((r) => r.action === action);
|
||||
|
||||
describe('markPaid', () => {
|
||||
it('writes one PAY row when it actually flips the charge', async () => {
|
||||
build();
|
||||
await service.markPaid(CHARGE_ID, 'TXN-77');
|
||||
|
||||
expect(byAction('PAY')).toHaveLength(1);
|
||||
expect(byAction('PAY')[0]).toMatchObject({
|
||||
entityType: 'ExcessBaggageCharge',
|
||||
entityId: CHARGE_ID,
|
||||
oldData: { status: 'PENDING' },
|
||||
});
|
||||
expect(byAction('PAY')[0].newData).toMatchObject({
|
||||
status: 'PAID',
|
||||
bookingId: BOOKING_ID,
|
||||
totalMinor: 40000,
|
||||
providerTxnId: 'TXN-77',
|
||||
});
|
||||
});
|
||||
|
||||
it('writes nothing when the webhook already claimed the transition', async () => {
|
||||
build();
|
||||
// count: 0 means another caller flipped the row first and already logged it.
|
||||
prisma.excessBaggageCharge.updateMany.mockResolvedValue({ count: 0 });
|
||||
await service.markPaid(CHARGE_ID, 'TXN-77');
|
||||
|
||||
expect(byAction('PAY')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is a no-op on a charge already read as PAID', async () => {
|
||||
build({ status: 'PAID' });
|
||||
await service.markPaid(CHARGE_ID);
|
||||
|
||||
expect(audit.log).not.toHaveBeenCalled();
|
||||
expect(prisma.excessBaggageCharge.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never puts the payment token on the row', async () => {
|
||||
build();
|
||||
await service.markPaid(CHARGE_ID, 'TXN-77');
|
||||
expect(JSON.stringify(rows())).not.toContain('tok-live-secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('waiveCharge', () => {
|
||||
it('records WAIVE with the status it moved from', async () => {
|
||||
build();
|
||||
await service.waiveCharge(CHARGE_ID, { waivedBy: 'Supervisor Bob', waivedReason: 'goodwill' });
|
||||
|
||||
expect(byAction('WAIVE')).toHaveLength(1);
|
||||
expect(byAction('WAIVE')[0]).toMatchObject({
|
||||
entityType: 'ExcessBaggageCharge',
|
||||
entityId: CHARGE_ID,
|
||||
oldData: { status: 'PENDING' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let the request body become the audit actor', async () => {
|
||||
build();
|
||||
await service.waiveCharge(CHARGE_ID, { waivedBy: 'somebody-else', waivedReason: 'x' });
|
||||
|
||||
// `waivedBy` is descriptive context only; the actor comes from the session inside
|
||||
// AuditService, so no call site sets `userId`.
|
||||
const row = byAction('WAIVE')[0];
|
||||
expect(row.userId).toBeUndefined();
|
||||
expect(row.newData.waivedBy).toBe('somebody-else');
|
||||
});
|
||||
|
||||
it('records nothing when the waiver is refused', async () => {
|
||||
build({ status: 'PAID' });
|
||||
await expect(
|
||||
service.waiveCharge(CHARGE_ID, { waivedBy: 'Bob' }),
|
||||
).rejects.toThrow();
|
||||
expect(audit.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCharge', () => {
|
||||
it('records the hard delete of a money record', async () => {
|
||||
build();
|
||||
await service.deleteCharge(CHARGE_ID);
|
||||
|
||||
expect(byAction('DELETE')).toHaveLength(1);
|
||||
expect(byAction('DELETE')[0].oldData).toMatchObject({
|
||||
bookingId: BOOKING_ID,
|
||||
totalMinor: 40000,
|
||||
status: 'PENDING',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('baggage allowances (Tariff Rates)', () => {
|
||||
it('records a CREATE when no allowance existed', async () => {
|
||||
build();
|
||||
prisma.baggageAllowance.create.mockResolvedValue({
|
||||
id: 'ba-1',
|
||||
seatClassId: 'sc-1',
|
||||
maxWeightKg: 20,
|
||||
maxPiecesCount: 2,
|
||||
excessFeePerKg: 5000,
|
||||
});
|
||||
|
||||
await service.upsertAllowance({ seatClassId: 'sc-1', maxWeightKg: 20, excessFeePerKg: 5000 });
|
||||
|
||||
expect(byAction('CREATE')).toHaveLength(1);
|
||||
expect(byAction('CREATE')[0]).toMatchObject({ entityType: 'BaggageAllowance', entityId: 'ba-1' });
|
||||
});
|
||||
|
||||
it('records an UPDATE with the previous fee when one already existed', async () => {
|
||||
build();
|
||||
prisma.baggageAllowance.findFirst.mockResolvedValue({
|
||||
id: 'ba-1',
|
||||
seatClassId: 'sc-1',
|
||||
maxWeightKg: 20,
|
||||
maxPiecesCount: 2,
|
||||
excessFeePerKg: 5000,
|
||||
});
|
||||
prisma.baggageAllowance.update.mockResolvedValue({
|
||||
id: 'ba-1',
|
||||
seatClassId: 'sc-1',
|
||||
maxWeightKg: 25,
|
||||
maxPiecesCount: 2,
|
||||
excessFeePerKg: 7500,
|
||||
});
|
||||
|
||||
await service.upsertAllowance({ seatClassId: 'sc-1', maxWeightKg: 25, excessFeePerKg: 7500 });
|
||||
|
||||
const row = byAction('UPDATE')[0];
|
||||
expect(row).toMatchObject({ entityType: 'BaggageAllowance', entityId: 'ba-1' });
|
||||
expect(row.oldData).toMatchObject({ excessFeePerKg: 5000 });
|
||||
expect(row.newData).toMatchObject({ excessFeePerKg: 7500 });
|
||||
});
|
||||
|
||||
it('records the deletion of an allowance', async () => {
|
||||
build();
|
||||
prisma.baggageAllowance.findUnique.mockResolvedValue({
|
||||
id: 'ba-1',
|
||||
seatClassId: 'sc-1',
|
||||
maxWeightKg: 20,
|
||||
maxPiecesCount: 2,
|
||||
excessFeePerKg: 5000,
|
||||
});
|
||||
|
||||
await service.deleteAllowance('ba-1');
|
||||
|
||||
expect(byAction('DELETE')).toHaveLength(1);
|
||||
expect(byAction('DELETE')[0].oldData).toMatchObject({ excessFeePerKg: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('reads', () => {
|
||||
it('writes nothing when listing allowances', async () => {
|
||||
build();
|
||||
prisma.baggageAllowance.findMany = jest.fn().mockResolvedValue([]);
|
||||
prisma.seatClass = { findMany: jest.fn().mockResolvedValue([]) };
|
||||
|
||||
await service.getAllowances();
|
||||
expect(audit.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ describe('ExcessBaggageService — charge currency', () => {
|
||||
excessBaggageCharge: {
|
||||
findUnique: jest.fn().mockResolvedValue(charge),
|
||||
update: jest.fn().mockResolvedValue({ ...charge, status: 'PAID' }),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
paymentMethod: { findUnique: jest.fn() },
|
||||
currencyExchangeRate: {
|
||||
@@ -188,6 +189,7 @@ describe('ExcessBaggageService — CAC Bank OTP debit', () => {
|
||||
excessBaggageCharge: {
|
||||
findUnique: jest.fn().mockResolvedValue(charge),
|
||||
update: jest.fn().mockResolvedValue({ ...charge, status: 'PAID' }),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
paymentMethod: {
|
||||
findUnique: jest
|
||||
@@ -263,9 +265,9 @@ describe('ExcessBaggageService — CAC Bank OTP debit', () => {
|
||||
CHARGE_ID,
|
||||
);
|
||||
expect(paymentClient.confirmOtp).toHaveBeenCalledWith('intent-1', '4530');
|
||||
expect(prisma.excessBaggageCharge.update).toHaveBeenCalledWith(
|
||||
expect(prisma.excessBaggageCharge.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: CHARGE_ID },
|
||||
where: expect.objectContaining({ id: CHARGE_ID }),
|
||||
data: expect.objectContaining({ status: 'PAID' }),
|
||||
}),
|
||||
);
|
||||
@@ -281,6 +283,7 @@ describe('ExcessBaggageService — CAC Bank OTP debit', () => {
|
||||
const result = await service.confirmOtp(TOKEN, '0000');
|
||||
|
||||
expect(prisma.excessBaggageCharge.update).not.toHaveBeenCalled();
|
||||
expect(prisma.excessBaggageCharge.updateMany).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ status: 'REQUIRES_ACTION' });
|
||||
});
|
||||
|
||||
@@ -339,6 +342,7 @@ describe('ExcessBaggageService — CBE bill', () => {
|
||||
excessBaggageCharge: {
|
||||
findUnique: jest.fn().mockResolvedValue(charge),
|
||||
update: jest.fn().mockResolvedValue(charge),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
booking: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
|
||||
@@ -29,7 +29,7 @@ export class ExcessBaggageAgentController {
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' })
|
||||
logCharge(@Request() req: any, @Body() dto: LogExcessBaggageDto) {
|
||||
dto.agentId = req.user?.id ?? req.user?.sub ?? dto.agentId;
|
||||
dto.agentId = req.user?.id ?? req.user?.sub ?? '';
|
||||
return this.service.logCharge(dto);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,15 @@ import {
|
||||
} 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';
|
||||
@@ -131,7 +140,24 @@ export class ExcessBaggageService {
|
||||
await this.sendPaymentLink(charge, booking, contactPhone, contactEmail);
|
||||
}
|
||||
|
||||
await this.auditService.log({ action: 'CREATE', entityType: 'ExcessBaggageCharge', entityId: charge.id, newData: { bookingId: booking.id, excessWeightKg: dto.excessWeightKg, totalMinor, status } });
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -431,10 +457,34 @@ export class ExcessBaggageService {
|
||||
const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id: chargeId } });
|
||||
if (!charge) throw new NotFoundException('Charge not found');
|
||||
if (charge.status === 'PAID') return charge;
|
||||
return this.prisma.excessBaggageCharge.update({
|
||||
where: { id: chargeId },
|
||||
|
||||
// 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) {
|
||||
@@ -447,7 +497,22 @@ export class ExcessBaggageService {
|
||||
where: { id },
|
||||
data: { status: 'WAIVED', waivedBy: dto.waivedBy, waivedReason: dto.waivedReason },
|
||||
});
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'ExcessBaggageCharge', entityId: id, newData: { 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;
|
||||
}
|
||||
|
||||
@@ -466,6 +531,16 @@ export class ExcessBaggageService {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -524,30 +599,79 @@ export class ExcessBaggageService {
|
||||
async upsertAllowance(dto: { seatClassId: string; maxWeightKg?: number; maxPiecesCount?: number; excessFeePerKg: number }) {
|
||||
const existing = await this.prisma.baggageAllowance.findFirst({ where: { seatClassId: dto.seatClassId } });
|
||||
if (existing) {
|
||||
return this.prisma.baggageAllowance.update({
|
||||
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;
|
||||
}
|
||||
return this.prisma.baggageAllowance.create({
|
||||
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 }>) {
|
||||
return this.prisma.baggageAllowance.update({ where: { id }, data: dto });
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,37 @@ import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoa
|
||||
import { SeatKind } from '@prisma/client';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
|
||||
import { snapshot } from '../../common/audit-snapshot';
|
||||
|
||||
/** Fields carried into audit rows, per entity. Deliberately narrow — see audit-snapshot.ts. */
|
||||
const COACH_TYPE_AUDIT_FIELDS = ['code', 'name', 'type'] as const;
|
||||
const SEAT_CLASS_AUDIT_FIELDS = [
|
||||
'coachTypeId',
|
||||
'name',
|
||||
'description',
|
||||
'baseFareMinor',
|
||||
'premiumMinor',
|
||||
'insuranceFeeMinor',
|
||||
'isActive',
|
||||
] as const;
|
||||
const TRAIN_AUDIT_FIELDS = [
|
||||
'number',
|
||||
'name',
|
||||
'operatorId',
|
||||
'operatorName',
|
||||
'description',
|
||||
'isActive',
|
||||
] as const;
|
||||
const COACH_AUDIT_FIELDS = [
|
||||
'number',
|
||||
'coachTypeId',
|
||||
'arrangement',
|
||||
'capacity',
|
||||
'status',
|
||||
'sequence',
|
||||
] as const;
|
||||
const COACH_ASSIGNMENT_AUDIT_FIELDS = ['scheduleId', 'coachId', 'positionNumber', 'isOperational'] as const;
|
||||
|
||||
// Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2]
|
||||
function parseArrangement(arrangement: string): number[] {
|
||||
@@ -151,7 +182,7 @@ export class FleetService {
|
||||
constructor(private prisma: PrismaService, private auditService: AuditService) {}
|
||||
|
||||
async createCoachType(dto: CreateCoachTypeDto) {
|
||||
return this.prisma.coachType.create({
|
||||
const coachType = await this.prisma.coachType.create({
|
||||
data: {
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
@@ -162,6 +193,13 @@ export class FleetService {
|
||||
coaches: true,
|
||||
},
|
||||
});
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.CREATE,
|
||||
entityType: AUDIT_ENTITIES.CoachType,
|
||||
entityId: coachType.id,
|
||||
newData: snapshot(coachType, COACH_TYPE_AUDIT_FIELDS),
|
||||
});
|
||||
return coachType;
|
||||
}
|
||||
|
||||
async getCoachTypes() {
|
||||
@@ -183,7 +221,7 @@ export class FleetService {
|
||||
if (dto.name !== undefined) data.name = dto.name;
|
||||
if (dto.type !== undefined) data.type = dto.type;
|
||||
|
||||
return this.prisma.coachType.update({
|
||||
const updated = await this.prisma.coachType.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
@@ -191,6 +229,14 @@ export class FleetService {
|
||||
coaches: true,
|
||||
},
|
||||
});
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UPDATE,
|
||||
entityType: AUDIT_ENTITIES.CoachType,
|
||||
entityId: id,
|
||||
oldData: snapshot(coachType, COACH_TYPE_AUDIT_FIELDS),
|
||||
newData: snapshot(updated, COACH_TYPE_AUDIT_FIELDS),
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteCoachType(id: string) {
|
||||
@@ -224,11 +270,18 @@ export class FleetService {
|
||||
throw new DeleteOperationException('Coach Type', coachType.name, constraints);
|
||||
}
|
||||
|
||||
return this.prisma.coachType.delete({ where: { id } });
|
||||
const deleted = await this.prisma.coachType.delete({ where: { id } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.DELETE,
|
||||
entityType: AUDIT_ENTITIES.CoachType,
|
||||
entityId: id,
|
||||
oldData: snapshot(coachType, COACH_TYPE_AUDIT_FIELDS),
|
||||
});
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async createClass(dto: CreateClassDto) {
|
||||
return this.prisma.seatClass.create({
|
||||
const seatClass = await this.prisma.seatClass.create({
|
||||
data: {
|
||||
coachTypeId: dto.coachTypeId,
|
||||
name: dto.name,
|
||||
@@ -239,6 +292,13 @@ export class FleetService {
|
||||
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
||||
},
|
||||
});
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.CREATE,
|
||||
entityType: AUDIT_ENTITIES.SeatClass,
|
||||
entityId: seatClass.id,
|
||||
newData: snapshot(seatClass, SEAT_CLASS_AUDIT_FIELDS),
|
||||
});
|
||||
return seatClass;
|
||||
}
|
||||
|
||||
async getClasses(coachTypeId?: string) {
|
||||
@@ -267,11 +327,19 @@ export class FleetService {
|
||||
updateData.isActive = dto.isActive;
|
||||
}
|
||||
|
||||
return this.prisma.seatClass.update({
|
||||
const updated = await this.prisma.seatClass.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: { coachType: true },
|
||||
});
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UPDATE,
|
||||
entityType: AUDIT_ENTITIES.SeatClass,
|
||||
entityId: id,
|
||||
oldData: snapshot(seatClass, SEAT_CLASS_AUDIT_FIELDS),
|
||||
newData: snapshot(updated, SEAT_CLASS_AUDIT_FIELDS),
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteClass(id: string, cascade = false) {
|
||||
@@ -308,7 +376,15 @@ export class FleetService {
|
||||
await this.prisma.segmentFareRule.deleteMany({ where: { seatClassId: id } });
|
||||
}
|
||||
|
||||
return this.prisma.seatClass.delete({ where: { id } });
|
||||
const deleted = await this.prisma.seatClass.delete({ where: { id } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.DELETE,
|
||||
entityType: AUDIT_ENTITIES.SeatClass,
|
||||
entityId: id,
|
||||
oldData: snapshot(seatClass, SEAT_CLASS_AUDIT_FIELDS),
|
||||
newData: { cascade },
|
||||
});
|
||||
return deleted;
|
||||
}
|
||||
|
||||
createSeatClass(dto: CreateClassDto) {
|
||||
@@ -345,7 +421,12 @@ export class FleetService {
|
||||
isActive: dto.isActive ?? true,
|
||||
},
|
||||
});
|
||||
await this.auditService.log({ action: 'CREATE', entityType: 'Train', entityId: train.id, newData: { number: train.number, name: train.name } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.CREATE,
|
||||
entityType: AUDIT_ENTITIES.Train,
|
||||
entityId: train.id,
|
||||
newData: snapshot(train, TRAIN_AUDIT_FIELDS),
|
||||
});
|
||||
return train;
|
||||
}
|
||||
|
||||
@@ -363,7 +444,13 @@ export class FleetService {
|
||||
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
||||
},
|
||||
});
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Train', entityId: id, newData: { number: dto.number, name: dto.name } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UPDATE,
|
||||
entityType: AUDIT_ENTITIES.Train,
|
||||
entityId: id,
|
||||
oldData: snapshot(train, TRAIN_AUDIT_FIELDS),
|
||||
newData: snapshot(updated, TRAIN_AUDIT_FIELDS),
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -442,14 +529,28 @@ export class FleetService {
|
||||
}
|
||||
|
||||
const deleted = await this.prisma.train.delete({ where: { id } });
|
||||
await this.auditService.log({ action: 'DELETE', entityType: 'Train', entityId: id, oldData: { number: train.number, name: train.name } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.DELETE,
|
||||
entityType: AUDIT_ENTITIES.Train,
|
||||
entityId: id,
|
||||
oldData: snapshot(train, TRAIN_AUDIT_FIELDS),
|
||||
newData: { cascade },
|
||||
});
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async restoreTrain(id: string) {
|
||||
const train = await this.prisma.train.findUnique({ where: { id } });
|
||||
if (!train) throw new NotFoundException('Train not found');
|
||||
return this.prisma.train.update({ where: { id }, data: { isActive: true } });
|
||||
const restored = await this.prisma.train.update({ where: { id }, data: { isActive: true } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.RESTORE,
|
||||
entityType: AUDIT_ENTITIES.Train,
|
||||
entityId: id,
|
||||
oldData: { isActive: train.isActive },
|
||||
newData: { isActive: true },
|
||||
});
|
||||
return restored;
|
||||
}
|
||||
|
||||
async getCoach(id: string) {
|
||||
@@ -526,7 +627,12 @@ export class FleetService {
|
||||
await this.prisma.seat.createMany({ data: seats });
|
||||
}
|
||||
|
||||
await this.auditService.log({ action: 'CREATE', entityType: 'Coach', entityId: coach.id, newData: { number: coach.number, capacity: coach.capacity } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.CREATE,
|
||||
entityType: AUDIT_ENTITIES.Coach,
|
||||
entityId: coach.id,
|
||||
newData: snapshot(coach, COACH_AUDIT_FIELDS),
|
||||
});
|
||||
return coach;
|
||||
}
|
||||
|
||||
@@ -545,7 +651,13 @@ export class FleetService {
|
||||
},
|
||||
include: { coachType: true },
|
||||
});
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Coach', entityId: id, newData: { number: dto.number, status: dto.status } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UPDATE,
|
||||
entityType: AUDIT_ENTITIES.Coach,
|
||||
entityId: id,
|
||||
oldData: snapshot(coach, COACH_AUDIT_FIELDS),
|
||||
newData: snapshot(updated, COACH_AUDIT_FIELDS),
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -622,7 +734,13 @@ export class FleetService {
|
||||
await this.prisma.seat.deleteMany({ where: { coachId: id } });
|
||||
|
||||
const deleted = await this.prisma.coach.delete({ where: { id } });
|
||||
await this.auditService.log({ action: 'DELETE', entityType: 'Coach', entityId: id, oldData: { number: coach.number } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.DELETE,
|
||||
entityType: AUDIT_ENTITIES.Coach,
|
||||
entityId: id,
|
||||
oldData: snapshot(coach, COACH_AUDIT_FIELDS),
|
||||
newData: { cascade },
|
||||
});
|
||||
return deleted;
|
||||
}
|
||||
|
||||
@@ -634,13 +752,30 @@ export class FleetService {
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
if (!coach) throw new NotFoundException('Coach not found');
|
||||
if (coach.status !== 'ACTIVE') throw new BadRequestException('Coach is not active');
|
||||
return this.prisma.coachAssignment.create({ data: dto });
|
||||
const assignment = await this.prisma.coachAssignment.create({ data: dto });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.ASSIGN,
|
||||
entityType: AUDIT_ENTITIES.CoachAssignment,
|
||||
entityId: assignment.id,
|
||||
newData: {
|
||||
...snapshot(assignment, COACH_ASSIGNMENT_AUDIT_FIELDS),
|
||||
coachNumber: coach.number,
|
||||
},
|
||||
});
|
||||
return assignment;
|
||||
}
|
||||
|
||||
async removeAssignment(id: string) {
|
||||
const assignment = await this.prisma.coachAssignment.findUnique({ where: { id } });
|
||||
if (!assignment) throw new NotFoundException('Assignment not found');
|
||||
return this.prisma.coachAssignment.delete({ where: { id } });
|
||||
const deleted = await this.prisma.coachAssignment.delete({ where: { id } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UNASSIGN,
|
||||
entityType: AUDIT_ENTITIES.CoachAssignment,
|
||||
entityId: id,
|
||||
oldData: snapshot(assignment, COACH_ASSIGNMENT_AUDIT_FIELDS),
|
||||
});
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async generateSeatMapPreview(dto: GenerateSeatMapDto) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
SetMetadata,
|
||||
UseGuards,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
} from "./payments.dto";
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
import { resolveActingUser } from "../../common/acting-user";
|
||||
import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util";
|
||||
import { SupplementaryChargesService } from "./supplementary-charges.service";
|
||||
import { IsString, IsInt, IsOptional, Min, IsEnum, IsIn } from "class-validator";
|
||||
@@ -386,13 +388,13 @@ export class PaymentsController {
|
||||
@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,
|
||||
) {
|
||||
createSupplementaryCharge(@Body() dto: CreateSupplementaryChargeDto, @Req() req: any) {
|
||||
// Actor comes from the guarded session, not the client-settable `x-iam-user-id` header it
|
||||
// used to read (which defaulted to the literal string 'staff').
|
||||
const actor = resolveActingUser(req);
|
||||
return this.supplementaryService.create({
|
||||
...dto,
|
||||
createdBy: iamUserId ?? 'staff',
|
||||
createdBy: actor?.id ?? 'staff',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -510,9 +512,10 @@ export class PaymentsController {
|
||||
waiveSupplementaryCharge(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: WaiveSupplementaryChargeDto,
|
||||
@Headers('x-iam-user-id') iamUserId?: string,
|
||||
@Req() req: any,
|
||||
) {
|
||||
return this.supplementaryService.waive(id, dto.notes ?? '', iamUserId ?? 'staff');
|
||||
const actor = resolveActingUser(req);
|
||||
return this.supplementaryService.waive(id, dto.notes ?? '', actor?.id ?? 'staff');
|
||||
}
|
||||
|
||||
@Post('supplementary/:id/resend')
|
||||
|
||||
@@ -49,10 +49,14 @@ describe("PaymentsService", () => {
|
||||
excessBaggageCharge: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
// The charge handlers claim the PAID transition with a conditional updateMany so the
|
||||
// in-app path and this webhook cannot both write an audit row for one settlement.
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
supplementaryCharge: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
currencyExchangeRate: {
|
||||
findFirst: jest.fn(),
|
||||
@@ -600,9 +604,9 @@ describe("PaymentsService", () => {
|
||||
|
||||
const result = await service.handlePaymentEvent(succeededEvent());
|
||||
|
||||
expect(mockPrisma.excessBaggageCharge.update).toHaveBeenCalledWith(
|
||||
expect(mockPrisma.excessBaggageCharge.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: CHARGE_ID },
|
||||
where: expect.objectContaining({ id: CHARGE_ID }),
|
||||
data: expect.objectContaining({ status: "PAID" }),
|
||||
}),
|
||||
);
|
||||
@@ -617,7 +621,7 @@ describe("PaymentsService", () => {
|
||||
|
||||
await service.handlePaymentEvent(succeededEvent());
|
||||
|
||||
expect(mockPrisma.excessBaggageCharge.update).toHaveBeenCalledWith(
|
||||
expect(mockPrisma.excessBaggageCharge.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: "PAID" }),
|
||||
}),
|
||||
@@ -632,7 +636,7 @@ describe("PaymentsService", () => {
|
||||
|
||||
const result = await service.handlePaymentEvent(succeededEvent());
|
||||
|
||||
expect(mockPrisma.excessBaggageCharge.update).not.toHaveBeenCalled();
|
||||
expect(mockPrisma.excessBaggageCharge.updateMany).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ processed: true, alreadyFinalized: true });
|
||||
});
|
||||
|
||||
@@ -647,7 +651,7 @@ describe("PaymentsService", () => {
|
||||
succeededEvent({ amountMinor: 1625, currency: "DJF" }),
|
||||
);
|
||||
|
||||
expect(mockPrisma.excessBaggageCharge.update).toHaveBeenCalledWith(
|
||||
expect(mockPrisma.excessBaggageCharge.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: "PAID" }),
|
||||
}),
|
||||
|
||||
@@ -43,6 +43,17 @@ import {
|
||||
} from "./payment-client.service";
|
||||
import { CurrencyService } from "../currency/currency.service";
|
||||
import { AuditService } from "../../common/audit.service";
|
||||
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from "../../common/audit.actions";
|
||||
|
||||
const PAYMENT_METHOD_AUDIT_FIELDS = [
|
||||
"type",
|
||||
"displayName",
|
||||
"region",
|
||||
"currency",
|
||||
"providerId",
|
||||
"enabled",
|
||||
"sortOrder",
|
||||
] as const;
|
||||
import { rebaseUrlOrigin } from "../../common/utils/redirect-origin.util";
|
||||
import {
|
||||
PaymentService as PaymentServiceEnum,
|
||||
@@ -92,6 +103,18 @@ export class PaymentsService {
|
||||
});
|
||||
if (!intent) throw new NotFoundException("Payment intent not found");
|
||||
await this.prisma.paymentIntent.delete({ where: { id } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.DELETE,
|
||||
entityType: AUDIT_ENTITIES.Payment,
|
||||
entityId: id,
|
||||
oldData: {
|
||||
bookingId: intent.bookingId,
|
||||
status: intent.status,
|
||||
amountMinor: intent.amountMinor,
|
||||
currency: intent.currency,
|
||||
method: intent.method,
|
||||
},
|
||||
});
|
||||
return { deleted: true, id };
|
||||
}
|
||||
|
||||
@@ -1075,16 +1098,27 @@ export class PaymentsService {
|
||||
data: { status: "CANCELLED" },
|
||||
});
|
||||
}
|
||||
// The intent is moved to CANCELLED, not "REFUNDED" — recording the latter made the audit
|
||||
// row contradict the row it describes.
|
||||
await this.auditService.log({
|
||||
action: "UPDATE",
|
||||
entityType: "Payment",
|
||||
action: AUDIT_ACTIONS.REFUND,
|
||||
entityType: AUDIT_ENTITIES.Payment,
|
||||
entityId: intent.id,
|
||||
newData: { status: "REFUNDED", bookingId: dto.bookingId },
|
||||
oldData: { status: intent.status, bookingStatus: booking?.status },
|
||||
newData: {
|
||||
status: "CANCELLED",
|
||||
bookingId: dto.bookingId,
|
||||
bookingRef: booking?.bookingRef,
|
||||
bookingStatus: booking ? "CANCELLED" : undefined,
|
||||
amountMinor: intent.amountMinor,
|
||||
currency: intent.currency,
|
||||
reason: dto.reason,
|
||||
},
|
||||
});
|
||||
return { refunded: true, bookingRef: booking?.bookingRef };
|
||||
}
|
||||
|
||||
addPaymentMethod(dto: AddPaymentMethodDto) {
|
||||
async addPaymentMethod(dto: AddPaymentMethodDto) {
|
||||
const data = {
|
||||
type: dto.type as unknown as PaymentMethodType,
|
||||
displayName: dto.displayName,
|
||||
@@ -1094,11 +1128,37 @@ export class PaymentsService {
|
||||
enabled: dto.enabled ?? true,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
};
|
||||
return this.prisma.paymentMethod.upsert({
|
||||
|
||||
// This is an upsert keyed on `type`, so "add" silently overwrites an existing method. The
|
||||
// audit row reports which of the two actually happened rather than always claiming a create.
|
||||
const existing = await this.prisma.paymentMethod.findUnique({
|
||||
where: { type: data.type },
|
||||
});
|
||||
|
||||
const method = await this.prisma.paymentMethod.upsert({
|
||||
where: { type: data.type },
|
||||
update: data,
|
||||
create: data,
|
||||
});
|
||||
|
||||
await this.auditService.log({
|
||||
action: existing ? AUDIT_ACTIONS.UPDATE : AUDIT_ACTIONS.CREATE,
|
||||
entityType: AUDIT_ENTITIES.PaymentMethod,
|
||||
entityId: method.id,
|
||||
oldData: existing ? this.paymentMethodSnapshot(existing) : undefined,
|
||||
newData: this.paymentMethodSnapshot(method),
|
||||
});
|
||||
|
||||
return method;
|
||||
}
|
||||
|
||||
private paymentMethodSnapshot(method: Record<string, unknown>) {
|
||||
return Object.fromEntries(
|
||||
PAYMENT_METHOD_AUDIT_FIELDS.filter((k) => method[k] !== undefined).map((k) => [
|
||||
k,
|
||||
method[k],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async updatePaymentMethod(id: string, dto: Partial<AddPaymentMethodDto>) {
|
||||
@@ -1116,10 +1176,20 @@ export class PaymentsService {
|
||||
if (dto.enabled !== undefined) updateData.enabled = dto.enabled;
|
||||
if (dto.sortOrder !== undefined) updateData.sortOrder = dto.sortOrder;
|
||||
|
||||
return this.prisma.paymentMethod.update({
|
||||
const updated = await this.prisma.paymentMethod.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UPDATE,
|
||||
entityType: AUDIT_ENTITIES.PaymentMethod,
|
||||
entityId: id,
|
||||
oldData: this.paymentMethodSnapshot(existing),
|
||||
newData: this.paymentMethodSnapshot(updated),
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
|
||||
@@ -1512,21 +1582,34 @@ export class PaymentsService {
|
||||
}
|
||||
if (charge.status === "PAID")
|
||||
return { processed: true, alreadyFinalized: true };
|
||||
await this.prisma.supplementaryCharge.update({
|
||||
where: { id: charge.id },
|
||||
// Conditional claim, not a plain update: SupplementaryChargesService.markPaid can be
|
||||
// driving the same transition from the in-app path. Whichever caller flips the row writes
|
||||
// the audit event; the loser writes nothing, so the trail holds exactly one PAID row.
|
||||
const { count } = await this.prisma.supplementaryCharge.updateMany({
|
||||
where: { id: charge.id, status: { not: "PAID" } },
|
||||
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 };
|
||||
|
||||
if (count === 1) {
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.PAY,
|
||||
entityType: AUDIT_ENTITIES.SupplementaryCharge,
|
||||
entityId: charge.id,
|
||||
oldData: { status: charge.status },
|
||||
newData: {
|
||||
status: "PAID",
|
||||
bookingId: charge.bookingId,
|
||||
providerTxnId: event.providerTxnId,
|
||||
settledAmount: event.amountMinor,
|
||||
settledCurrency: event.currency,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { processed: true, alreadyFinalized: count === 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1573,8 +1656,10 @@ export class PaymentsService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.excessBaggageCharge.update({
|
||||
where: { id: charge.id },
|
||||
// Conditional claim for the same reason as the supplementary handler above:
|
||||
// ExcessBaggageService.markPaid drives this transition from the in-app path.
|
||||
const { count } = await this.prisma.excessBaggageCharge.updateMany({
|
||||
where: { id: charge.id, status: { notIn: ["PAID", "CASH_COLLECTED"] } },
|
||||
data: {
|
||||
status: "PAID",
|
||||
// The provider's own capture time, not when this event happened to be processed — a
|
||||
@@ -1582,13 +1667,19 @@ export class PaymentsService {
|
||||
paidAt: event.paidAt ? new Date(event.paidAt) : new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
return { processed: true, alreadyFinalized: true };
|
||||
}
|
||||
|
||||
await this.auditService.log({
|
||||
action: "UPDATE",
|
||||
entityType: "ExcessBaggageCharge",
|
||||
action: AUDIT_ACTIONS.PAY,
|
||||
entityType: AUDIT_ENTITIES.ExcessBaggageCharge,
|
||||
entityId: charge.id,
|
||||
oldData: { status: charge.status },
|
||||
newData: {
|
||||
status: "PAID",
|
||||
bookingId: charge.bookingId,
|
||||
providerTxnId: event.providerTxnId,
|
||||
settledAmount: event.amountMinor,
|
||||
settledCurrency: event.currency,
|
||||
@@ -1773,6 +1864,9 @@ export class PaymentsService {
|
||||
let intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId },
|
||||
});
|
||||
// Captured before the block below rewrites it, so the audit row can show what the override
|
||||
// moved the payment away from.
|
||||
const previousStatus = intent?.status ?? null;
|
||||
if (!intent) {
|
||||
intent = await this.prisma.paymentIntent.create({
|
||||
data: {
|
||||
@@ -1811,17 +1905,23 @@ export class PaymentsService {
|
||||
providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined,
|
||||
force: true,
|
||||
}).then(async (result) => {
|
||||
await this.auditService.log({
|
||||
action: "UPDATE",
|
||||
entityType: "Payment",
|
||||
entityId: intent.id,
|
||||
newData: {
|
||||
status: "FORCE_CONFIRMED",
|
||||
bookingId,
|
||||
paymentMethod: dto.paymentMethod,
|
||||
paymentReference: dto.paymentReference,
|
||||
},
|
||||
});
|
||||
// finalizePaymentSuccess reports `alreadyFinalized` when the booking was already
|
||||
// confirmed — logging a forced confirmation there would record an override that changed
|
||||
// nothing.
|
||||
if (!result?.alreadyFinalized) {
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.STATUS_CHANGE,
|
||||
entityType: AUDIT_ENTITIES.Payment,
|
||||
entityId: intent.id,
|
||||
oldData: { status: previousStatus },
|
||||
newData: {
|
||||
status: "FORCE_CONFIRMED",
|
||||
bookingId,
|
||||
paymentMethod: dto.paymentMethod,
|
||||
paymentReference: dto.paymentReference,
|
||||
},
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { SupplementaryChargesService } from './supplementary-charges.service';
|
||||
|
||||
/**
|
||||
* Supplementary charges are raised by staff against a confirmed booking, so "who raised this,
|
||||
* against which booking, and who later waived it" has to survive in AuditLog.
|
||||
*
|
||||
* The actor used to come from an `x-iam-user-id` request header defaulting to the literal
|
||||
* string 'staff' — a client-settable value in the column meant to identify a person.
|
||||
*/
|
||||
describe('SupplementaryChargesService — audit', () => {
|
||||
const CHARGE_ID = 'sc-1';
|
||||
const BOOKING_ID = 'booking-1';
|
||||
const BOOKING_REF = 'EDR-0001';
|
||||
|
||||
let prisma: Record<string, any>;
|
||||
let audit: { log: jest.Mock };
|
||||
let service: SupplementaryChargesService;
|
||||
|
||||
const build = (charge: Record<string, any> = {}) => {
|
||||
const row = {
|
||||
id: CHARGE_ID,
|
||||
bookingId: BOOKING_ID,
|
||||
reason: 'UNDERPAYMENT',
|
||||
amountMinor: 25000,
|
||||
currency: 'ETB',
|
||||
status: 'PENDING',
|
||||
paymentToken: 'tok-live-secret',
|
||||
notes: null,
|
||||
createdBy: 'iam-staff-1',
|
||||
expiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000),
|
||||
booking: { bookingRef: BOOKING_REF, contactPhone: '+251911223344', contactEmail: 'p@example.com' },
|
||||
...charge,
|
||||
};
|
||||
|
||||
prisma = {
|
||||
supplementaryCharge: {
|
||||
findUnique: jest.fn().mockResolvedValue(row),
|
||||
create: jest.fn().mockResolvedValue(row),
|
||||
update: jest.fn().mockResolvedValue({ ...row, status: 'WAIVED' }),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
booking: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: BOOKING_ID,
|
||||
bookingRef: BOOKING_REF,
|
||||
status: 'CONFIRMED',
|
||||
contactPhone: '+251911223344',
|
||||
contactEmail: 'p@example.com',
|
||||
passenger: { user: { phone: null, email: null } },
|
||||
}),
|
||||
},
|
||||
};
|
||||
audit = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
// Constructor order: prisma, audit, sms, email, paymentClient, currency.
|
||||
service = new SupplementaryChargesService(
|
||||
prisma as any,
|
||||
audit as any,
|
||||
{ sendSms: jest.fn() } as any,
|
||||
{ sendEmail: jest.fn() } as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
);
|
||||
return row;
|
||||
};
|
||||
|
||||
const rows = () => audit.log.mock.calls.map((c) => c[0]);
|
||||
const byAction = (action: string) => rows().filter((r) => r.action === action);
|
||||
|
||||
describe('create', () => {
|
||||
it('records one CREATE naming the booking the charge belongs to', async () => {
|
||||
build();
|
||||
await service.create({
|
||||
bookingRef: BOOKING_REF,
|
||||
amountMinor: 25000,
|
||||
reason: 'UNDERPAYMENT',
|
||||
createdBy: 'iam-staff-1',
|
||||
});
|
||||
|
||||
expect(byAction('CREATE')).toHaveLength(1);
|
||||
expect(byAction('CREATE')[0]).toMatchObject({
|
||||
entityType: 'SupplementaryCharge',
|
||||
entityId: CHARGE_ID,
|
||||
});
|
||||
expect(byAction('CREATE')[0].newData).toMatchObject({
|
||||
bookingId: BOOKING_ID,
|
||||
bookingRef: BOOKING_REF,
|
||||
amountMinor: 25000,
|
||||
reason: 'UNDERPAYMENT',
|
||||
createdBy: 'iam-staff-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('never writes the payment token into the row', async () => {
|
||||
build();
|
||||
await service.create({
|
||||
bookingRef: BOOKING_REF,
|
||||
amountMinor: 25000,
|
||||
reason: 'UNDERPAYMENT',
|
||||
createdBy: 'iam-staff-1',
|
||||
});
|
||||
expect(JSON.stringify(rows())).not.toContain('tok-live-secret');
|
||||
});
|
||||
|
||||
it('records nothing when the booking is not chargeable', async () => {
|
||||
build();
|
||||
prisma.booking.findUnique.mockResolvedValue({
|
||||
id: BOOKING_ID,
|
||||
bookingRef: BOOKING_REF,
|
||||
status: 'PENDING_PAYMENT',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.create({
|
||||
bookingRef: BOOKING_REF,
|
||||
amountMinor: 25000,
|
||||
reason: 'UNDERPAYMENT',
|
||||
createdBy: 'iam-staff-1',
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
expect(audit.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('waive', () => {
|
||||
it('records WAIVE rather than a generic UPDATE', async () => {
|
||||
build();
|
||||
await service.waive(CHARGE_ID, 'goodwill', 'iam-staff-1');
|
||||
|
||||
expect(byAction('WAIVE')).toHaveLength(1);
|
||||
expect(byAction('UPDATE')).toHaveLength(0);
|
||||
expect(byAction('WAIVE')[0]).toMatchObject({
|
||||
entityType: 'SupplementaryCharge',
|
||||
entityId: CHARGE_ID,
|
||||
oldData: { status: 'PENDING' },
|
||||
});
|
||||
expect(byAction('WAIVE')[0].newData).toMatchObject({ status: 'WAIVED', waivedBy: 'iam-staff-1' });
|
||||
});
|
||||
|
||||
it('records nothing when waiving a paid charge is refused', async () => {
|
||||
build({ status: 'PAID' });
|
||||
await expect(service.waive(CHARGE_ID, 'x', 'iam-staff-1')).rejects.toThrow();
|
||||
expect(audit.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('markPaid', () => {
|
||||
it('records PAY once when it claims the transition', async () => {
|
||||
build();
|
||||
await service.markPaid(CHARGE_ID, 'TXN-9');
|
||||
|
||||
expect(byAction('PAY')).toHaveLength(1);
|
||||
expect(byAction('PAY')[0]).toMatchObject({
|
||||
entityType: 'SupplementaryCharge',
|
||||
entityId: CHARGE_ID,
|
||||
oldData: { status: 'PENDING' },
|
||||
});
|
||||
});
|
||||
|
||||
it('stays silent when the webhook already claimed it', async () => {
|
||||
build();
|
||||
prisma.supplementaryCharge.updateMany.mockResolvedValue({ count: 0 });
|
||||
await service.markPaid(CHARGE_ID, 'TXN-9');
|
||||
|
||||
expect(byAction('PAY')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is a no-op on a charge already read as PAID', async () => {
|
||||
build({ status: 'PAID' });
|
||||
await service.markPaid(CHARGE_ID);
|
||||
|
||||
expect(audit.log).not.toHaveBeenCalled();
|
||||
expect(prisma.supplementaryCharge.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('actor', () => {
|
||||
it('never sets userId at the call site — AuditService reads the session', async () => {
|
||||
build();
|
||||
await service.waive(CHARGE_ID, 'goodwill', 'client-supplied');
|
||||
expect(rows().every((r) => r.userId === undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reads', () => {
|
||||
it('writes nothing when listing charges', async () => {
|
||||
build();
|
||||
prisma.supplementaryCharge.findMany = jest.fn().mockResolvedValue([]);
|
||||
prisma.supplementaryCharge.count = jest.fn().mockResolvedValue(0);
|
||||
|
||||
await service.getAll({});
|
||||
expect(audit.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { EmailClientService } from '../notifications/email-client.service';
|
||||
import { PaymentClientService } from './payment-client.service';
|
||||
@@ -80,10 +81,20 @@ export class SupplementaryChargesService {
|
||||
await this.sendLink(charge, booking.bookingRef, phone, email);
|
||||
|
||||
await this.auditService.log({
|
||||
action: 'CREATE',
|
||||
entityType: 'SupplementaryCharge',
|
||||
action: AUDIT_ACTIONS.CREATE,
|
||||
entityType: AUDIT_ENTITIES.SupplementaryCharge,
|
||||
entityId: charge.id,
|
||||
newData: { bookingRef: dto.bookingRef, amountMinor: dto.amountMinor, reason: dto.reason },
|
||||
newData: {
|
||||
bookingId: booking.id,
|
||||
bookingRef: dto.bookingRef,
|
||||
amountMinor: dto.amountMinor,
|
||||
currency: charge.currency,
|
||||
reason: dto.reason,
|
||||
notes: dto.notes,
|
||||
createdBy: dto.createdBy,
|
||||
status: charge.status,
|
||||
expiresAt: charge.expiresAt?.toISOString(),
|
||||
},
|
||||
});
|
||||
return charge;
|
||||
}
|
||||
@@ -134,12 +145,34 @@ export class SupplementaryChargesService {
|
||||
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 },
|
||||
|
||||
// Conditional update rather than a plain update: this transition is also reachable from the
|
||||
// payment webhook, and claiming it atomically means exactly one of the two racing callers
|
||||
// writes the audit row.
|
||||
const { count } = await this.prisma.supplementaryCharge.updateMany({
|
||||
where: { id, status: { not: 'PAID' } },
|
||||
data: { status: 'PAID', paidAt: new Date(), providerTxnId: providerTxnId ?? null },
|
||||
});
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'PAID' } });
|
||||
return updated;
|
||||
|
||||
const updated = await this.prisma.supplementaryCharge.findUnique({ where: { id } });
|
||||
|
||||
if (count === 1) {
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.PAY,
|
||||
entityType: AUDIT_ENTITIES.SupplementaryCharge,
|
||||
entityId: id,
|
||||
oldData: { status: charge.status },
|
||||
newData: {
|
||||
status: 'PAID',
|
||||
bookingId: charge.bookingId,
|
||||
amountMinor: charge.amountMinor,
|
||||
currency: charge.currency,
|
||||
providerTxnId: providerTxnId ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -354,7 +387,20 @@ export class SupplementaryChargesService {
|
||||
where: { id },
|
||||
data: { status: 'WAIVED', notes },
|
||||
});
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'WAIVED', waivedBy, notes } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.WAIVE,
|
||||
entityType: AUDIT_ENTITIES.SupplementaryCharge,
|
||||
entityId: id,
|
||||
oldData: { status: charge.status },
|
||||
newData: {
|
||||
status: 'WAIVED',
|
||||
bookingId: charge.bookingId,
|
||||
amountMinor: charge.amountMinor,
|
||||
currency: charge.currency,
|
||||
waivedBy,
|
||||
notes,
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -370,6 +416,19 @@ export class SupplementaryChargesService {
|
||||
data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) },
|
||||
});
|
||||
await this.sendLink(updated, charge.booking.bookingRef, charge.booking.contactPhone, charge.booking.contactEmail);
|
||||
// Re-exposes a live payment token and extends its deadline, so it is a state change worth
|
||||
// attributing even though the charge's status is unchanged. Contact details stay out of the
|
||||
// row — only the fact that a link was re-sent.
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.RESEND,
|
||||
entityType: AUDIT_ENTITIES.SupplementaryCharge,
|
||||
entityId: id,
|
||||
oldData: { expiresAt: charge.expiresAt?.toISOString() },
|
||||
newData: {
|
||||
bookingRef: charge.booking.bookingRef,
|
||||
expiresAt: updated.expiresAt?.toISOString(),
|
||||
},
|
||||
});
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ describe('SupplementaryChargesService — payment methods', () => {
|
||||
supplementaryCharge: {
|
||||
findUnique: jest.fn().mockResolvedValue(charge),
|
||||
update: jest.fn().mockResolvedValue({ ...charge, status: 'PAID' }),
|
||||
// markPaid claims the PAID transition conditionally so it cannot double-log with the
|
||||
// webhook path; count: 1 means this caller won.
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
booking: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
@@ -163,7 +166,7 @@ describe('SupplementaryChargesService — payment methods', () => {
|
||||
CHARGE_ID,
|
||||
);
|
||||
expect(paymentClient.confirmOtp).toHaveBeenCalledWith('intent-1', '4530');
|
||||
expect(prisma.supplementaryCharge.update).toHaveBeenCalledWith(
|
||||
expect(prisma.supplementaryCharge.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: 'PAID',
|
||||
@@ -181,6 +184,7 @@ describe('SupplementaryChargesService — payment methods', () => {
|
||||
});
|
||||
await service.confirmOtp(TOKEN, '0000');
|
||||
expect(prisma.supplementaryCharge.update).not.toHaveBeenCalled();
|
||||
expect(prisma.supplementaryCharge.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is idempotent once already paid', async () => {
|
||||
|
||||
@@ -3,6 +3,26 @@ import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
|
||||
import { snapshot } from '../../common/audit-snapshot';
|
||||
|
||||
const ROUTE_AUDIT_FIELDS = [
|
||||
'code',
|
||||
'name',
|
||||
'description',
|
||||
'active',
|
||||
'effectiveFrom',
|
||||
'effectiveUntil',
|
||||
'checkinMinutesBefore',
|
||||
] as const;
|
||||
const ROUTE_STOP_AUDIT_FIELDS = [
|
||||
'routeId',
|
||||
'stationId',
|
||||
'sequence',
|
||||
'distanceKm',
|
||||
'checkinMinutesBefore',
|
||||
'travelMinutesToStop',
|
||||
] as const;
|
||||
import { parseEthiopianTime } from '../../common/utils/timezone.utils';
|
||||
import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils';
|
||||
|
||||
@@ -75,7 +95,12 @@ export class RoutesService {
|
||||
},
|
||||
include: { stops: { include: { route: false }, orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
await this.auditService.log({ action: 'CREATE', entityType: 'Route', entityId: route.id, newData: { code: route.code, name: route.name } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.CREATE,
|
||||
entityType: AUDIT_ENTITIES.Route,
|
||||
entityId: route.id,
|
||||
newData: snapshot(route, ROUTE_AUDIT_FIELDS),
|
||||
});
|
||||
return route;
|
||||
}
|
||||
|
||||
@@ -165,11 +190,23 @@ export class RoutesService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Route', entityId: id, newData: { name: dto.name, active: dto.active } });
|
||||
return this.prisma.route.findUnique({
|
||||
const updated = await this.prisma.route.findUnique({
|
||||
where: { id },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UPDATE,
|
||||
entityType: AUDIT_ENTITIES.Route,
|
||||
entityId: id,
|
||||
oldData: snapshot(route, ROUTE_AUDIT_FIELDS),
|
||||
newData: {
|
||||
...snapshot(updated, ROUTE_AUDIT_FIELDS),
|
||||
// Stop edits arrive as a full replacement, so record the resulting shape rather than
|
||||
// every row — the RouteStop rows themselves are audited on the dedicated endpoints.
|
||||
...(dto.stops && dto.stops.length >= 2 ? { stopsReplaced: dto.stops.length } : {}),
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteRoute(id: string, cascade = false) {
|
||||
@@ -254,7 +291,13 @@ export class RoutesService {
|
||||
}
|
||||
|
||||
await this.prisma.route.delete({ where: { id } });
|
||||
await this.auditService.log({ action: 'DELETE', entityType: 'Route', entityId: id, oldData: { code: route.code, name: route.name } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.DELETE,
|
||||
entityType: AUDIT_ENTITIES.Route,
|
||||
entityId: id,
|
||||
oldData: snapshot(route, ROUTE_AUDIT_FIELDS),
|
||||
newData: { cascade },
|
||||
});
|
||||
return { deleted: true, id };
|
||||
}
|
||||
|
||||
@@ -276,7 +319,7 @@ export class RoutesService {
|
||||
const otherStops = await this.prisma.routeStop.findMany({ where: { routeId } });
|
||||
this.validateStopDistances([...otherStops, { sequence: dto.sequence, stationId: dto.stationId, distanceKm: dto.distanceKm }]);
|
||||
|
||||
return this.prisma.routeStop.create({
|
||||
const stop = await this.prisma.routeStop.create({
|
||||
data: {
|
||||
routeId,
|
||||
stationId: dto.stationId,
|
||||
@@ -286,6 +329,13 @@ export class RoutesService {
|
||||
travelMinutesToStop: dto.travelMinutesToStop ?? null,
|
||||
},
|
||||
});
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.CREATE,
|
||||
entityType: AUDIT_ENTITIES.RouteStop,
|
||||
entityId: stop.id,
|
||||
newData: { ...snapshot(stop, ROUTE_STOP_AUDIT_FIELDS), stationName: station.name },
|
||||
});
|
||||
return stop;
|
||||
}
|
||||
|
||||
async removeStop(routeId: string, sequence: number) {
|
||||
@@ -298,6 +348,12 @@ export class RoutesService {
|
||||
if (total <= 2) throw new BadRequestException('A route must retain at least 2 stops');
|
||||
|
||||
await this.prisma.routeStop.delete({ where: { routeId_sequence: { routeId, sequence } } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.DELETE,
|
||||
entityType: AUDIT_ENTITIES.RouteStop,
|
||||
entityId: stop.id,
|
||||
oldData: snapshot(stop, ROUTE_STOP_AUDIT_FIELDS),
|
||||
});
|
||||
return { deleted: true, sequence };
|
||||
}
|
||||
|
||||
@@ -351,18 +407,48 @@ export class RoutesService {
|
||||
const positions = dto.coaches.map(c => c.positionNumber);
|
||||
if (new Set(positions).size !== positions.length) throw new BadRequestException('Duplicate positionNumber values');
|
||||
|
||||
const previous = await this.prisma.routeCoachTemplate.findMany({
|
||||
where: { routeId },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
select: { coachId: true, positionNumber: true },
|
||||
});
|
||||
|
||||
await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } });
|
||||
await this.prisma.routeCoachTemplate.createMany({
|
||||
data: dto.coaches.map(c => ({ routeId, coachId: c.coachId, positionNumber: c.positionNumber })),
|
||||
});
|
||||
|
||||
// The template is replaced wholesale, so the audit row carries both compositions rather
|
||||
// than one row per coach — a reader wants "what does this route run now vs. before".
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.ASSIGN,
|
||||
entityType: AUDIT_ENTITIES.RouteCoachTemplate,
|
||||
entityId: routeId,
|
||||
oldData: { routeCode: route.code, coaches: previous },
|
||||
newData: {
|
||||
routeCode: route.code,
|
||||
coaches: dto.coaches.map(c => ({ coachId: c.coachId, positionNumber: c.positionNumber })),
|
||||
},
|
||||
});
|
||||
|
||||
return this.getRouteCoachTemplate(routeId);
|
||||
}
|
||||
|
||||
async removeRouteCoachTemplate(routeId: string) {
|
||||
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
const previous = await this.prisma.routeCoachTemplate.findMany({
|
||||
where: { routeId },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
select: { coachId: true, positionNumber: true },
|
||||
});
|
||||
await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UNASSIGN,
|
||||
entityType: AUDIT_ENTITIES.RouteCoachTemplate,
|
||||
entityId: routeId,
|
||||
oldData: { routeCode: route.code, coaches: previous },
|
||||
});
|
||||
return { deleted: true, routeId };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { SchedulesService } from './schedules.service';
|
||||
|
||||
/**
|
||||
* Master-data coverage, using schedules as the representative entity.
|
||||
*
|
||||
* `updateScheduleStatus` was a one-line Prisma update with no audit call at all, so "who
|
||||
* cancelled this schedule" had no answer. Fare-rule edits were similar: the previous price was
|
||||
* read and then discarded, leaving an UPDATE row that didn't say what changed.
|
||||
*/
|
||||
describe('SchedulesService — audit', () => {
|
||||
const SCHEDULE_ID = 'sched-1';
|
||||
|
||||
let prisma: Record<string, any>;
|
||||
let audit: { log: jest.Mock };
|
||||
let service: SchedulesService;
|
||||
|
||||
const scheduleRow = (over: Record<string, any> = {}) => ({
|
||||
id: SCHEDULE_ID,
|
||||
trainId: 'train-1',
|
||||
routeId: 'route-1',
|
||||
originStationId: 'station-a',
|
||||
destinationStationId: 'station-b',
|
||||
departureAt: new Date('2026-09-01T06:00:00.000Z'),
|
||||
arrivalAt: new Date('2026-09-01T18:00:00.000Z'),
|
||||
durationMinutes: 720,
|
||||
stopsCount: 3,
|
||||
status: 'SCHEDULED',
|
||||
...over,
|
||||
});
|
||||
|
||||
const build = (over: Record<string, any> = {}) => {
|
||||
const schedule = scheduleRow(over);
|
||||
prisma = {
|
||||
trainSchedule: {
|
||||
findUnique: jest.fn().mockResolvedValue(schedule),
|
||||
update: jest.fn(async ({ data }: any) => ({ ...schedule, ...data })),
|
||||
},
|
||||
fareRule: {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
routeFareRule: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
segmentFareRule: { findUnique: jest.fn(), update: jest.fn(), delete: jest.fn() },
|
||||
};
|
||||
audit = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
service = new SchedulesService(
|
||||
prisma as any,
|
||||
{} as any, // routesService
|
||||
{} as any, // fareEngine
|
||||
audit as any,
|
||||
{ updateLiveStatus: jest.fn() } as any, // liveService
|
||||
);
|
||||
return schedule;
|
||||
};
|
||||
|
||||
const rows = () => audit.log.mock.calls.map((c) => c[0]);
|
||||
|
||||
describe('updateScheduleStatus', () => {
|
||||
it('records one STATUS_CHANGE with the status it moved from and to', async () => {
|
||||
build();
|
||||
await service.updateScheduleStatus(SCHEDULE_ID, { status: 'BOARDING' } as any);
|
||||
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(rows()[0]).toMatchObject({
|
||||
action: 'STATUS_CHANGE',
|
||||
entityType: 'Schedule',
|
||||
entityId: SCHEDULE_ID,
|
||||
oldData: { status: 'SCHEDULED' },
|
||||
});
|
||||
expect(rows()[0].newData.status).toBe('BOARDING');
|
||||
});
|
||||
|
||||
it('uses CANCEL for a cancellation so it is not lost among ordinary updates', async () => {
|
||||
build();
|
||||
await service.updateScheduleStatus(SCHEDULE_ID, { status: 'CANCELLED' } as any);
|
||||
|
||||
expect(rows()[0]).toMatchObject({
|
||||
action: 'CANCEL',
|
||||
entityType: 'Schedule',
|
||||
entityId: SCHEDULE_ID,
|
||||
oldData: { status: 'SCHEDULED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('records nothing when the schedule does not exist', async () => {
|
||||
build();
|
||||
prisma.trainSchedule.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.updateScheduleStatus(SCHEDULE_ID, { status: 'CANCELLED' } as any),
|
||||
).rejects.toThrow();
|
||||
expect(audit.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records nothing when the write itself fails', async () => {
|
||||
build();
|
||||
prisma.trainSchedule.update.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
service.updateScheduleStatus(SCHEDULE_ID, { status: 'CANCELLED' } as any),
|
||||
).rejects.toThrow();
|
||||
expect(audit.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves the actor to AuditService', async () => {
|
||||
build();
|
||||
await service.updateScheduleStatus(SCHEDULE_ID, { status: 'DELAYED' } as any);
|
||||
expect(rows()[0].userId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fare rules', () => {
|
||||
it('records the previous price on an update, not just the new one', async () => {
|
||||
build();
|
||||
const before = {
|
||||
id: 'fr-1',
|
||||
tripId: SCHEDULE_ID,
|
||||
seatClassId: 'sc-1',
|
||||
baseFareMinor: 50000,
|
||||
currency: 'ETB',
|
||||
nationality: null,
|
||||
validFrom: new Date('2026-01-01'),
|
||||
validUntil: null,
|
||||
};
|
||||
prisma.fareRule.findUnique.mockResolvedValue(before);
|
||||
prisma.fareRule.update.mockResolvedValue({ ...before, baseFareMinor: 65000 });
|
||||
|
||||
await service.updateFareRule('fr-1', { baseFareMinor: 65000 } as any);
|
||||
|
||||
const row = rows()[0];
|
||||
expect(row).toMatchObject({ action: 'UPDATE', entityType: 'FareRule', entityId: 'fr-1' });
|
||||
expect(row.oldData).toMatchObject({ baseFareMinor: 50000 });
|
||||
expect(row.newData).toMatchObject({ baseFareMinor: 65000 });
|
||||
});
|
||||
|
||||
it('records what a deleted fare rule was worth', async () => {
|
||||
build();
|
||||
prisma.fareRule.findUnique.mockResolvedValue({
|
||||
id: 'fr-1',
|
||||
tripId: SCHEDULE_ID,
|
||||
seatClassId: 'sc-1',
|
||||
baseFareMinor: 50000,
|
||||
currency: 'ETB',
|
||||
});
|
||||
|
||||
await service.deleteFareRule('fr-1');
|
||||
|
||||
expect(rows()[0]).toMatchObject({ action: 'DELETE', entityType: 'FareRule', entityId: 'fr-1' });
|
||||
expect(rows()[0].oldData).toMatchObject({ baseFareMinor: 50000 });
|
||||
});
|
||||
|
||||
it('records a route fare-rule price change that previously left no trail', async () => {
|
||||
build();
|
||||
const before = {
|
||||
id: 'rfr-1',
|
||||
routeId: 'route-1',
|
||||
seatClassId: 'sc-1',
|
||||
passengerCategory: 'ADULT',
|
||||
baseFareMinor: 40000,
|
||||
surchargeMinor: 0,
|
||||
validFrom: new Date('2026-01-01'),
|
||||
validUntil: null,
|
||||
};
|
||||
prisma.routeFareRule.findUnique.mockResolvedValue(before);
|
||||
prisma.routeFareRule.update.mockResolvedValue({ ...before, baseFareMinor: 45000 });
|
||||
|
||||
await service.updateRouteFareRule('rfr-1', { baseFareMinor: 45000 });
|
||||
|
||||
expect(rows()[0]).toMatchObject({ action: 'UPDATE', entityType: 'RouteFareRule' });
|
||||
expect(rows()[0].oldData).toMatchObject({ baseFareMinor: 40000 });
|
||||
expect(rows()[0].newData).toMatchObject({ baseFareMinor: 45000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('reads', () => {
|
||||
it('writes nothing when listing schedules', async () => {
|
||||
build();
|
||||
prisma.trainSchedule.findMany = jest.fn().mockResolvedValue([]);
|
||||
|
||||
await service.listSchedules({} as any);
|
||||
expect(audit.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes nothing when listing fare rules', async () => {
|
||||
build();
|
||||
prisma.fareRule.findMany = jest.fn().mockResolvedValue([]);
|
||||
|
||||
await service.getFareRules(SCHEDULE_ID);
|
||||
expect(audit.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,59 @@ import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateSchedule
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
|
||||
import { snapshot } from '../../common/audit-snapshot';
|
||||
import { LiveService } from '../live/live.service';
|
||||
import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils';
|
||||
|
||||
const SCHEDULE_AUDIT_FIELDS = [
|
||||
'trainId',
|
||||
'routeId',
|
||||
'originStationId',
|
||||
'destinationStationId',
|
||||
'departureAt',
|
||||
'arrivalAt',
|
||||
'durationMinutes',
|
||||
'stopsCount',
|
||||
'status',
|
||||
] as const;
|
||||
const FARE_RULE_AUDIT_FIELDS = [
|
||||
'tripId',
|
||||
'seatClassId',
|
||||
'baseFareMinor',
|
||||
'currency',
|
||||
'nationality',
|
||||
'validFrom',
|
||||
'validUntil',
|
||||
] as const;
|
||||
const SEGMENT_FARE_AUDIT_FIELDS = [
|
||||
'routeId',
|
||||
'seatClassId',
|
||||
'originStopSequence',
|
||||
'destinationStopSequence',
|
||||
'baseFareMinor',
|
||||
'currency',
|
||||
'validFrom',
|
||||
'validUntil',
|
||||
] as const;
|
||||
const ROUTE_FARE_AUDIT_FIELDS = [
|
||||
'routeId',
|
||||
'seatClassId',
|
||||
'passengerCategory',
|
||||
'baseFareMinor',
|
||||
'surchargeMinor',
|
||||
'validFrom',
|
||||
'validUntil',
|
||||
] as const;
|
||||
const STOP_TIME_AUDIT_FIELDS = [
|
||||
'scheduleId',
|
||||
'stationId',
|
||||
'sequence',
|
||||
'plannedArrivalAt',
|
||||
'plannedDepartureAt',
|
||||
'status',
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService {
|
||||
private readonly logger = new Logger(SchedulesService.name);
|
||||
@@ -102,6 +152,24 @@ export class SchedulesService {
|
||||
currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
// One row for the whole sweep, not one per schedule — the operator performed a single
|
||||
// action and the created ids are the interesting part.
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.BULK_CREATE,
|
||||
entityType: AUDIT_ENTITIES.Schedule,
|
||||
entityId: dto.routeId,
|
||||
newData: {
|
||||
routeId: dto.routeId,
|
||||
trainId: dto.trainId,
|
||||
startDateTime: dto.startDateTime,
|
||||
forNextDays: dto.forNextDays,
|
||||
repeatEveryDays: dto.repeatEveryDays,
|
||||
schedulesCreated: scheduleCount,
|
||||
scheduleIds,
|
||||
errorCount: errors.length,
|
||||
},
|
||||
});
|
||||
|
||||
return { schedulesCreated: scheduleCount, errors, scheduleIds };
|
||||
}
|
||||
|
||||
@@ -234,7 +302,12 @@ export class SchedulesService {
|
||||
}
|
||||
|
||||
const result = await this.getSchedule(schedule.id);
|
||||
await this.auditService.log({ action: 'CREATE', entityType: 'Schedule', entityId: schedule.id, newData: { trainId: dto.trainId, routeId: dto.routeId, departureAt: dep } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.CREATE,
|
||||
entityType: AUDIT_ENTITIES.Schedule,
|
||||
entityId: schedule.id,
|
||||
newData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -352,12 +425,37 @@ export class SchedulesService {
|
||||
await this.routesService.applyRouteToSchedule(dto.routeId, id, plannedTimesMap);
|
||||
|
||||
const result = await this.getSchedule(id);
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Schedule', entityId: id, newData: { trainId: dto.trainId, departureAt: dep } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UPDATE,
|
||||
entityType: AUDIT_ENTITIES.Schedule,
|
||||
entityId: id,
|
||||
oldData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS),
|
||||
newData: snapshot(result as any, SCHEDULE_AUDIT_FIELDS),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) {
|
||||
return this.prisma.trainSchedule.update({ where: { id }, data: { status: dto.status } });
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const updated = await this.prisma.trainSchedule.update({
|
||||
where: { id },
|
||||
data: { status: dto.status },
|
||||
});
|
||||
|
||||
// CANCELLED is the one transition an operator is asked to justify after the fact, so it
|
||||
// gets its own verb; everything else is a plain status move.
|
||||
await this.auditService.log({
|
||||
action:
|
||||
dto.status === 'CANCELLED' ? AUDIT_ACTIONS.CANCEL : AUDIT_ACTIONS.STATUS_CHANGE,
|
||||
entityType: AUDIT_ENTITIES.Schedule,
|
||||
entityId: id,
|
||||
oldData: { status: schedule.status },
|
||||
newData: { status: updated.status, departureAt: updated.departureAt.toISOString() },
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteSchedule(id: string, cascade = false) {
|
||||
@@ -438,7 +536,13 @@ export class SchedulesService {
|
||||
await this.prisma.travelPackage.deleteMany({ where: { id: { in: packageIds } } });
|
||||
}
|
||||
await this.prisma.trainSchedule.delete({ where: { id } });
|
||||
await this.auditService.log({ action: 'DELETE', entityType: 'Schedule', entityId: id });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.DELETE,
|
||||
entityType: AUDIT_ENTITIES.Schedule,
|
||||
entityId: id,
|
||||
oldData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS),
|
||||
newData: { cascade, bookingsAffected: (schedule as any)._count?.bookings ?? 0 },
|
||||
});
|
||||
return { deleted: true, id };
|
||||
}
|
||||
|
||||
@@ -456,7 +560,7 @@ export class SchedulesService {
|
||||
});
|
||||
if (!stop) throw new NotFoundException(`Stop at sequence ${sequence} not found on schedule`);
|
||||
|
||||
return this.prisma.tripStopTime.update({
|
||||
const updated = await this.prisma.tripStopTime.update({
|
||||
where: { scheduleId_sequence: { scheduleId, sequence } },
|
||||
data: {
|
||||
plannedArrivalAt: dto.plannedArrivalAt ? parseEthiopianTime(dto.plannedArrivalAt) : undefined,
|
||||
@@ -465,6 +569,14 @@ export class SchedulesService {
|
||||
},
|
||||
include: { station: true },
|
||||
});
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UPDATE,
|
||||
entityType: AUDIT_ENTITIES.Schedule,
|
||||
entityId: scheduleId,
|
||||
oldData: snapshot(stop, STOP_TIME_AUDIT_FIELDS),
|
||||
newData: snapshot(updated, STOP_TIME_AUDIT_FIELDS),
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -522,10 +634,19 @@ export class SchedulesService {
|
||||
await this.liveService.updateLiveStatus(scheduleId, { delayMinutes: accumulatedDelayMinutes });
|
||||
|
||||
await this.auditService.log({
|
||||
action: 'UPDATE',
|
||||
entityType: 'Schedule',
|
||||
action: AUDIT_ACTIONS.UPDATE,
|
||||
entityType: AUDIT_ENTITIES.Schedule,
|
||||
entityId: scheduleId,
|
||||
newData: { delayMinutes: dto.delayMinutes, fromSequence: dto.fromSequence, accumulatedDelayMinutes },
|
||||
oldData: {
|
||||
delayMinutes: currentLive?.delayMinutes ?? 0,
|
||||
departureAt: schedule.departureAt.toISOString(),
|
||||
},
|
||||
newData: {
|
||||
delayMinutes: dto.delayMinutes,
|
||||
fromSequence: dto.fromSequence,
|
||||
accumulatedDelayMinutes,
|
||||
stopsShifted: stopsToShift.length,
|
||||
},
|
||||
});
|
||||
|
||||
return this.getSchedule(scheduleId);
|
||||
@@ -547,7 +668,12 @@ export class SchedulesService {
|
||||
const validFrom = dto.validFrom ? parseEthiopianTime(dto.validFrom) : now;
|
||||
const validUntil = dto.validUntil ? parseEthiopianTime(dto.validUntil) : null;
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const superseded = await this.prisma.fareRule.findFirst({
|
||||
where: { tripId: scheduleId, seatClassId, validUntil: null },
|
||||
orderBy: { validFrom: 'desc' },
|
||||
});
|
||||
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.fareRule.updateMany({
|
||||
where: { tripId: scheduleId, seatClassId, validUntil: null },
|
||||
data: { validUntil: now },
|
||||
@@ -557,11 +683,27 @@ export class SchedulesService {
|
||||
include: { seatClass: true },
|
||||
});
|
||||
});
|
||||
|
||||
// A schedule fare is versioned rather than edited, so the audit row pairs the rule that was
|
||||
// closed off with the one that replaced it — otherwise the price change is invisible.
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UPDATE,
|
||||
entityType: AUDIT_ENTITIES.ScheduleFare,
|
||||
entityId: created.id,
|
||||
oldData: snapshot(superseded, FARE_RULE_AUDIT_FIELDS),
|
||||
newData: {
|
||||
...snapshot(created, FARE_RULE_AUDIT_FIELDS),
|
||||
scheduleId,
|
||||
seatClassName: seatClass.name,
|
||||
},
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
createFareRule(dto: CreateFareRuleDto) {
|
||||
async createFareRule(dto: CreateFareRuleDto) {
|
||||
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
|
||||
const result = this.prisma.fareRule.create({
|
||||
const rule = await this.prisma.fareRule.create({
|
||||
data: {
|
||||
...rest,
|
||||
tripId: scheduleId,
|
||||
@@ -571,8 +713,15 @@ export class SchedulesService {
|
||||
},
|
||||
include: { seatClass: true },
|
||||
});
|
||||
result.then(r => this.auditService.log({ action: 'CREATE', entityType: 'FareRule', entityId: r.id, newData: { seatClassId: r.seatClassId, baseFareMinor: r.baseFareMinor } }));
|
||||
return result;
|
||||
// Awaited, not a floating .then(): an unhandled rejection there could outlive the response,
|
||||
// and the row could land after the caller had already moved on.
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.CREATE,
|
||||
entityType: AUDIT_ENTITIES.FareRule,
|
||||
entityId: rule.id,
|
||||
newData: snapshot(rule, FARE_RULE_AUDIT_FIELDS),
|
||||
});
|
||||
return rule;
|
||||
}
|
||||
|
||||
async updateFareRule(id: string, dto: Partial<CreateFareRuleDto>) {
|
||||
@@ -580,7 +729,7 @@ export class SchedulesService {
|
||||
if (!existing) throw new NotFoundException('Fare rule not found');
|
||||
|
||||
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
|
||||
return this.prisma.fareRule.update({
|
||||
const updated = await this.prisma.fareRule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
@@ -591,19 +740,32 @@ export class SchedulesService {
|
||||
},
|
||||
include: { seatClass: true },
|
||||
});
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UPDATE,
|
||||
entityType: AUDIT_ENTITIES.FareRule,
|
||||
entityId: id,
|
||||
oldData: snapshot(existing, FARE_RULE_AUDIT_FIELDS),
|
||||
newData: snapshot(updated, FARE_RULE_AUDIT_FIELDS),
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteFareRule(id: string) {
|
||||
const existing = await this.prisma.fareRule.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('Fare rule not found');
|
||||
await this.prisma.fareRule.delete({ where: { id } });
|
||||
await this.auditService.log({ action: 'DELETE', entityType: 'FareRule', entityId: id });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.DELETE,
|
||||
entityType: AUDIT_ENTITIES.FareRule,
|
||||
entityId: id,
|
||||
oldData: snapshot(existing, FARE_RULE_AUDIT_FIELDS),
|
||||
});
|
||||
return { deleted: true, id };
|
||||
}
|
||||
|
||||
createSegmentFareRule(dto: any) {
|
||||
async createSegmentFareRule(dto: any) {
|
||||
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
|
||||
return this.prisma.segmentFareRule.create({
|
||||
const rule = await this.prisma.segmentFareRule.create({
|
||||
data: {
|
||||
...rest,
|
||||
validFrom: parseEthiopianTime(validFrom),
|
||||
@@ -611,6 +773,13 @@ export class SchedulesService {
|
||||
},
|
||||
include: { seatClass: true, route: true },
|
||||
});
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.CREATE,
|
||||
entityType: AUDIT_ENTITIES.SegmentFareRule,
|
||||
entityId: rule.id,
|
||||
newData: snapshot(rule, SEGMENT_FARE_AUDIT_FIELDS),
|
||||
});
|
||||
return rule;
|
||||
}
|
||||
|
||||
getSegmentFares(routeId: string) {
|
||||
@@ -621,13 +790,25 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
deleteSegmentFareRule(id: string) {
|
||||
return this.prisma.segmentFareRule.delete({ where: { id } });
|
||||
async deleteSegmentFareRule(id: string) {
|
||||
const existing = await this.prisma.segmentFareRule.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('Segment fare rule not found');
|
||||
const deleted = await this.prisma.segmentFareRule.delete({ where: { id } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.DELETE,
|
||||
entityType: AUDIT_ENTITIES.SegmentFareRule,
|
||||
entityId: id,
|
||||
oldData: snapshot(existing, SEGMENT_FARE_AUDIT_FIELDS),
|
||||
});
|
||||
return deleted;
|
||||
}
|
||||
|
||||
updateSegmentFareRule(id: string, dto: any) {
|
||||
async updateSegmentFareRule(id: string, dto: any) {
|
||||
const existing = await this.prisma.segmentFareRule.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('Segment fare rule not found');
|
||||
|
||||
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
|
||||
return this.prisma.segmentFareRule.update({
|
||||
const updated = await this.prisma.segmentFareRule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
@@ -636,6 +817,14 @@ export class SchedulesService {
|
||||
},
|
||||
include: { seatClass: true, route: true },
|
||||
});
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UPDATE,
|
||||
entityType: AUDIT_ENTITIES.SegmentFareRule,
|
||||
entityId: id,
|
||||
oldData: snapshot(existing, SEGMENT_FARE_AUDIT_FIELDS),
|
||||
newData: snapshot(updated, SEGMENT_FARE_AUDIT_FIELDS),
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async getFareRules(scheduleId?: string) {
|
||||
@@ -700,6 +889,15 @@ export class SchedulesService {
|
||||
}
|
||||
}
|
||||
|
||||
// One row for the sweep: the operator pressed sync once, and every fare it rewrote is
|
||||
// reconstructable from the FareRule versions it created.
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.SYNC,
|
||||
entityType: AUDIT_ENTITIES.ScheduleFare,
|
||||
entityId: scheduleId,
|
||||
newData: { scheduleId, synced, errorCount: errors.length },
|
||||
});
|
||||
|
||||
return { synced, errors };
|
||||
}
|
||||
|
||||
@@ -718,6 +916,16 @@ export class SchedulesService {
|
||||
);
|
||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||
await this.routesService.applyRouteToSchedule(schedule.routeId, scheduleId, plannedTimesMap);
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.BULK_UPDATE,
|
||||
entityType: AUDIT_ENTITIES.Schedule,
|
||||
entityId: scheduleId,
|
||||
newData: {
|
||||
recalculatedStopTimes: true,
|
||||
routeId: schedule.routeId,
|
||||
stopCount: plannedTimes.length,
|
||||
},
|
||||
});
|
||||
return { recalculated: true, scheduleId, stopCount: plannedTimes.length };
|
||||
}
|
||||
|
||||
@@ -731,6 +939,12 @@ export class SchedulesService {
|
||||
const inactiveCoach = existingCoaches.find(c => c.status !== 'ACTIVE');
|
||||
if (inactiveCoach) throw new BadRequestException(`Coach ${inactiveCoach.number} is not active`);
|
||||
|
||||
const previous = await this.prisma.coachAssignment.findMany({
|
||||
where: { scheduleId },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
select: { coachId: true, positionNumber: true },
|
||||
});
|
||||
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
|
||||
|
||||
const data = coaches.map((c) => ({
|
||||
@@ -741,6 +955,20 @@ export class SchedulesService {
|
||||
}));
|
||||
|
||||
await this.prisma.coachAssignment.createMany({ data });
|
||||
|
||||
// Assignment is a wholesale replacement, so both compositions go on one row rather than a
|
||||
// delete row per coach followed by a create row per coach.
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.ASSIGN,
|
||||
entityType: AUDIT_ENTITIES.CoachAssignment,
|
||||
entityId: scheduleId,
|
||||
oldData: { scheduleId, coaches: previous },
|
||||
newData: {
|
||||
scheduleId,
|
||||
coaches: coaches.map((c) => ({ coachId: c.coachId, positionNumber: c.positionNumber })),
|
||||
},
|
||||
});
|
||||
|
||||
return { message: 'Coaches assigned successfully', count: coaches.length };
|
||||
}
|
||||
|
||||
@@ -795,19 +1023,55 @@ export class SchedulesService {
|
||||
|
||||
if (dto.coaches !== undefined) {
|
||||
if (dto.coaches.length > 0) {
|
||||
// Logs its own ASSIGN row; this method only audits the schedule's own fields, so the
|
||||
// two rows describe two facts rather than double-reporting one.
|
||||
await this.assignCoaches(id, dto.coaches);
|
||||
} else {
|
||||
const cleared = await this.prisma.coachAssignment.findMany({
|
||||
where: { scheduleId: id },
|
||||
select: { coachId: true, positionNumber: true },
|
||||
});
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UNASSIGN,
|
||||
entityType: AUDIT_ENTITIES.CoachAssignment,
|
||||
entityId: id,
|
||||
oldData: { scheduleId: id, coaches: cleared },
|
||||
newData: { scheduleId: id, coaches: [] },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.getSchedule(id);
|
||||
const result = await this.getSchedule(id);
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
const statusChanged = dto.status !== undefined && dto.status !== schedule.status;
|
||||
await this.auditService.log({
|
||||
action: statusChanged
|
||||
? dto.status === 'CANCELLED'
|
||||
? AUDIT_ACTIONS.CANCEL
|
||||
: AUDIT_ACTIONS.STATUS_CHANGE
|
||||
: AUDIT_ACTIONS.UPDATE,
|
||||
entityType: AUDIT_ENTITIES.Schedule,
|
||||
entityId: id,
|
||||
oldData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS),
|
||||
newData: snapshot(result as any, SCHEDULE_AUDIT_FIELDS),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async removeCoachAssignment(scheduleId: string, coachId: string) {
|
||||
const assignment = await this.prisma.coachAssignment.findFirst({ where: { scheduleId, coachId } });
|
||||
if (!assignment) throw new NotFoundException('Coach assignment not found');
|
||||
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UNASSIGN,
|
||||
entityType: AUDIT_ENTITIES.CoachAssignment,
|
||||
entityId: assignment.id,
|
||||
oldData: { scheduleId, coachId, positionNumber: assignment.positionNumber },
|
||||
});
|
||||
return { message: 'Coach assignment removed' };
|
||||
}
|
||||
|
||||
@@ -846,14 +1110,23 @@ export class SchedulesService {
|
||||
},
|
||||
include: { seatClass: true, route: true },
|
||||
});
|
||||
await this.auditService.log({ action: 'CREATE', entityType: 'RouteFareRule', entityId: rule.id, newData: { routeId: dto.routeId, seatClassId: dto.seatClassId, baseFareMinor: dto.baseFareMinor } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.CREATE,
|
||||
entityType: AUDIT_ENTITIES.RouteFareRule,
|
||||
entityId: rule.id,
|
||||
newData: {
|
||||
...snapshot(rule, ROUTE_FARE_AUDIT_FIELDS),
|
||||
routeCode: route.code,
|
||||
seatClassName: seatClass.name,
|
||||
},
|
||||
});
|
||||
return rule;
|
||||
}
|
||||
|
||||
async updateRouteFareRule(id: string, dto: { baseFareMinor?: number; surchargeMinor?: number; validFrom?: string; validUntil?: string }) {
|
||||
const rule = await this.prisma.routeFareRule.findUnique({ where: { id } });
|
||||
if (!rule) throw new NotFoundException('Route fare rule not found');
|
||||
return this.prisma.routeFareRule.update({
|
||||
const updated = await this.prisma.routeFareRule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.baseFareMinor !== undefined && { baseFareMinor: dto.baseFareMinor }),
|
||||
@@ -863,13 +1136,26 @@ export class SchedulesService {
|
||||
},
|
||||
include: { seatClass: true, route: true },
|
||||
});
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UPDATE,
|
||||
entityType: AUDIT_ENTITIES.RouteFareRule,
|
||||
entityId: id,
|
||||
oldData: snapshot(rule, ROUTE_FARE_AUDIT_FIELDS),
|
||||
newData: snapshot(updated, ROUTE_FARE_AUDIT_FIELDS),
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteRouteFareRule(id: string) {
|
||||
const rule = await this.prisma.routeFareRule.findUnique({ where: { id } });
|
||||
if (!rule) throw new NotFoundException('Route fare rule not found');
|
||||
await this.prisma.routeFareRule.delete({ where: { id } });
|
||||
await this.auditService.log({ action: 'DELETE', entityType: 'RouteFareRule', entityId: id });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.DELETE,
|
||||
entityType: AUDIT_ENTITIES.RouteFareRule,
|
||||
entityId: id,
|
||||
oldData: snapshot(rule, ROUTE_FARE_AUDIT_FIELDS),
|
||||
});
|
||||
return { deleted: true, id };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,22 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
|
||||
import { snapshot } from '../../common/audit-snapshot';
|
||||
|
||||
/**
|
||||
* Seat classes carry the per-km tariff rate, so `baseFareMinor` is the field an auditor is
|
||||
* actually chasing — logging only the name made a price change indistinguishable from a rename.
|
||||
*/
|
||||
const SEAT_CLASS_AUDIT_FIELDS = [
|
||||
'coachTypeId',
|
||||
'name',
|
||||
'description',
|
||||
'baseFareMinor',
|
||||
'premiumMinor',
|
||||
'insuranceFeeMinor',
|
||||
'isActive',
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class SeatClassesService {
|
||||
@@ -30,7 +46,13 @@ export class SeatClassesService {
|
||||
...(basePrice !== undefined && { baseFareMinor: basePrice }),
|
||||
};
|
||||
const updated = await this.prisma.seatClass.update({ where: { id }, data });
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'SeatClass', entityId: id, newData: { name: updated.name } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UPDATE,
|
||||
entityType: AUDIT_ENTITIES.SeatClass,
|
||||
entityId: id,
|
||||
oldData: snapshot(sc, SEAT_CLASS_AUDIT_FIELDS),
|
||||
newData: snapshot(updated, SEAT_CLASS_AUDIT_FIELDS),
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -42,7 +64,12 @@ export class SeatClassesService {
|
||||
...(basePrice !== undefined && { baseFareMinor: basePrice }),
|
||||
};
|
||||
const sc = await this.prisma.seatClass.create({ data });
|
||||
await this.auditService.log({ action: 'CREATE', entityType: 'SeatClass', entityId: sc.id, newData: { name: sc.name } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.CREATE,
|
||||
entityType: AUDIT_ENTITIES.SeatClass,
|
||||
entityId: sc.id,
|
||||
newData: snapshot(sc, SEAT_CLASS_AUDIT_FIELDS),
|
||||
});
|
||||
return sc;
|
||||
} catch (e: any) {
|
||||
if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`);
|
||||
@@ -76,7 +103,13 @@ export class SeatClassesService {
|
||||
}
|
||||
|
||||
const deleted = await this.prisma.seatClass.delete({ where: { id } });
|
||||
await this.auditService.log({ action: 'DELETE', entityType: 'SeatClass', entityId: id, oldData: { name: sc.name } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.DELETE,
|
||||
entityType: AUDIT_ENTITIES.SeatClass,
|
||||
entityId: id,
|
||||
oldData: snapshot(sc, SEAT_CLASS_AUDIT_FIELDS),
|
||||
newData: { cascade, fareRulesDeleted: cascade ? totalFareRules : 0 },
|
||||
});
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { SegmentsService } from '../segments/segments.service';
|
||||
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||
import { checkDirectionConflict } from '../../common/utils/journey-direction.utils';
|
||||
@@ -899,6 +900,13 @@ export class SeatsService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.IMPORT,
|
||||
entityType: AUDIT_ENTITIES.Seat,
|
||||
entityId: scheduleId,
|
||||
newData: { scheduleId, rows: lines.length, imported, errorCount: errors.length },
|
||||
});
|
||||
|
||||
return { imported, errors: errors.slice(0, 10) };
|
||||
}
|
||||
|
||||
@@ -932,10 +940,11 @@ export class SeatsService {
|
||||
});
|
||||
}
|
||||
await this.auditService.log({
|
||||
action: 'UPDATE',
|
||||
entityType: 'Seat',
|
||||
action: AUDIT_ACTIONS.BLOCK,
|
||||
entityType: AUDIT_ENTITIES.Seat,
|
||||
entityId: seatId,
|
||||
newData: { status: 'BLOCKED', reason, reasonCategory, scheduleId, blockedBy },
|
||||
oldData: { status: seat.status, seatNumber: seat.seatNumber, coachId: seat.coachId },
|
||||
newData: { status: 'BLOCKED', reason, reasonCategory, scheduleId, blockedBy, blockedByName },
|
||||
});
|
||||
return { blocked: true, seatId, reason, reasonCategory, scheduleId, blockedBy, blockedByName };
|
||||
}
|
||||
@@ -950,7 +959,13 @@ export class SeatsService {
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } });
|
||||
await this.prisma.seatBlock.deleteMany({ where: { seatId, scheduleId: null } });
|
||||
}
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'AVAILABLE', scheduleId } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UNBLOCK,
|
||||
entityType: AUDIT_ENTITIES.Seat,
|
||||
entityId: seatId,
|
||||
oldData: { status: seat.status, seatNumber: seat.seatNumber, coachId: seat.coachId },
|
||||
newData: { status: 'AVAILABLE', scheduleId },
|
||||
});
|
||||
return { unblocked: true, seatId, scheduleId };
|
||||
}
|
||||
|
||||
@@ -968,6 +983,13 @@ export class SeatsService {
|
||||
blockedByName: actor?.name ?? 'System',
|
||||
},
|
||||
});
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.STATUS_CHANGE,
|
||||
entityType: AUDIT_ENTITIES.Seat,
|
||||
entityId: seatId,
|
||||
oldData: { status: seat.status, seatNumber: seat.seatNumber, coachId: seat.coachId },
|
||||
newData: { status: 'UNDER_MAINTENANCE', reason, blockedBy: actor?.id ?? 'SYSTEM' },
|
||||
});
|
||||
return { maintenance: true, seatId, reason };
|
||||
}
|
||||
|
||||
@@ -976,6 +998,13 @@ export class SeatsService {
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' as any } });
|
||||
await this.prisma.seatBlock.deleteMany({ where: { seatId } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.STATUS_CHANGE,
|
||||
entityType: AUDIT_ENTITIES.Seat,
|
||||
entityId: seatId,
|
||||
oldData: { status: seat.status, seatNumber: seat.seatNumber, coachId: seat.coachId },
|
||||
newData: { status: 'AVAILABLE' },
|
||||
});
|
||||
return { maintenance: false, seatId };
|
||||
}
|
||||
|
||||
@@ -991,7 +1020,12 @@ export class SeatsService {
|
||||
});
|
||||
|
||||
await this.renumberCoachSeats(seat.coachId);
|
||||
await this.auditService.log({ action: 'DELETE', entityType: 'Seat', entityId: seatId, oldData: { seatNumber: seat.seatNumber, coachId: seat.coachId } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.DELETE,
|
||||
entityType: AUDIT_ENTITIES.Seat,
|
||||
entityId: seatId,
|
||||
oldData: { seatNumber: seat.seatNumber, coachId: seat.coachId, status: seat.status },
|
||||
});
|
||||
return { removed: true, seatId, originalSeatNumber: seat.seatNumber };
|
||||
}
|
||||
|
||||
@@ -1007,6 +1041,13 @@ export class SeatsService {
|
||||
await this.renumberCoachSeats(seat.coachId);
|
||||
|
||||
const restored = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.RESTORE,
|
||||
entityType: AUDIT_ENTITIES.Seat,
|
||||
entityId: seatId,
|
||||
oldData: { seatNumber: seat.seatNumber, coachId: seat.coachId },
|
||||
newData: { seatNumber: restored?.seatNumber, coachId: seat.coachId },
|
||||
});
|
||||
return { restored: true, seatId, seatNumber: restored?.seatNumber };
|
||||
}
|
||||
|
||||
@@ -1582,6 +1623,24 @@ export class SeatsService {
|
||||
});
|
||||
}
|
||||
|
||||
// `results` carries contactPhone for the SMS step — the audit row keeps only the seat move
|
||||
// itself, so a passenger's number never lands in a log retained for a year.
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.BULK_UPDATE,
|
||||
entityType: AUDIT_ENTITIES.Seat,
|
||||
entityId: coachIds[0],
|
||||
newData: {
|
||||
coachIds,
|
||||
resolved: results.length,
|
||||
unresolved: unresolved.length,
|
||||
moves: results.map((r) => ({
|
||||
bookingRef: r.bookingRef,
|
||||
oldSeatNumber: r.oldSeatNumber,
|
||||
newSeatNumber: r.newSeatNumber,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
resolved: results.length,
|
||||
unresolved: unresolved.length,
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { Injectable, NotFoundException, Inject, Optional, BadRequestException } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { Injectable, NotFoundException, BadRequestException } 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';
|
||||
import { CreateStationDto } from './stations.dto';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
|
||||
/** The station fields worth carrying into an audit row — identity, placement, and status. */
|
||||
const STATION_AUDIT_FIELDS = [
|
||||
'code',
|
||||
'name',
|
||||
'city',
|
||||
'countryCode',
|
||||
'lat',
|
||||
'lng',
|
||||
'sequence',
|
||||
'isOperational',
|
||||
] as const;
|
||||
|
||||
interface StationFilters {
|
||||
search?: string;
|
||||
country?: string;
|
||||
@@ -16,7 +29,6 @@ export class StationsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private auditService: AuditService,
|
||||
@Optional() @Inject(REQUEST) private request?: any,
|
||||
) {}
|
||||
|
||||
findAll(filters: StationFilters = {}) {
|
||||
@@ -61,11 +73,10 @@ export class StationsService {
|
||||
});
|
||||
|
||||
await this.auditService.log({
|
||||
userId: this.request?.user?.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Station',
|
||||
action: AUDIT_ACTIONS.CREATE,
|
||||
entityType: AUDIT_ENTITIES.Station,
|
||||
entityId: station.id,
|
||||
newData: station,
|
||||
newData: snapshot(station, STATION_AUDIT_FIELDS),
|
||||
});
|
||||
|
||||
return station;
|
||||
@@ -85,12 +96,11 @@ export class StationsService {
|
||||
});
|
||||
|
||||
await this.auditService.log({
|
||||
userId: this.request?.user?.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Station',
|
||||
action: AUDIT_ACTIONS.UPDATE,
|
||||
entityType: AUDIT_ENTITIES.Station,
|
||||
entityId: id,
|
||||
oldData: oldStation,
|
||||
newData: updatedStation,
|
||||
oldData: snapshot(oldStation, STATION_AUDIT_FIELDS),
|
||||
newData: snapshot(updatedStation, STATION_AUDIT_FIELDS),
|
||||
});
|
||||
|
||||
return updatedStation;
|
||||
@@ -139,11 +149,11 @@ export class StationsService {
|
||||
const deleted = await this.prisma.station.delete({ where: { id } });
|
||||
|
||||
await this.auditService.log({
|
||||
userId: this.request?.user?.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Station',
|
||||
action: AUDIT_ACTIONS.DELETE,
|
||||
entityType: AUDIT_ENTITIES.Station,
|
||||
entityId: id,
|
||||
oldData: station,
|
||||
oldData: snapshot(station, STATION_AUDIT_FIELDS),
|
||||
newData: { cascade },
|
||||
});
|
||||
|
||||
return deleted;
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { TicketsService } from './tickets.service';
|
||||
|
||||
/**
|
||||
* "USER X boarded TICKET Y" has to be answerable from AuditLog alone.
|
||||
*
|
||||
* Before this, boarding wrote `action: 'VERIFY'` with no actor and no previous status, and the
|
||||
* only name on the row was `validatorId` — a request-body field, so whoever scanned could put
|
||||
* anyone's id in the trail. These pin: one row per boarding (not one per layer), the actor
|
||||
* coming from the session, and the ticket's before/after status both being recorded.
|
||||
*/
|
||||
describe('TicketsService — boarding audit', () => {
|
||||
const ACTOR = { id: 'iam-staff-1', name: 'Abebe Kebede', phone: '+251911223344' };
|
||||
const TICKET_ID = 'ticket-1';
|
||||
const BOOKING_ID = 'booking-1';
|
||||
const BOOKING_REF = 'EDR-0001';
|
||||
|
||||
let prisma: Record<string, any>;
|
||||
let audit: { log: jest.Mock };
|
||||
let service: TicketsService;
|
||||
|
||||
const build = (
|
||||
opts: {
|
||||
bookingType?: string;
|
||||
ticket?: Record<string, any>;
|
||||
booking?: Record<string, any>;
|
||||
approvedLegs?: string[];
|
||||
} = {},
|
||||
) => {
|
||||
const ticket = {
|
||||
id: TICKET_ID,
|
||||
bookingId: BOOKING_ID,
|
||||
bookingRef: BOOKING_REF,
|
||||
seatId: 'seat-9',
|
||||
leg: 1,
|
||||
status: 'ACTIVE',
|
||||
validatedAt: null,
|
||||
boardedAt: null,
|
||||
...opts.ticket,
|
||||
};
|
||||
// Departure an hour out, so scanAndBoard's boarding window is open.
|
||||
const departureAt = new Date(Date.now() + 60 * 60 * 1000);
|
||||
const booking = {
|
||||
id: BOOKING_ID,
|
||||
bookingRef: BOOKING_REF,
|
||||
bookingType: opts.bookingType ?? 'ONE_WAY',
|
||||
status: 'CONFIRMED',
|
||||
originStationId: 'station-a',
|
||||
destinationStationId: 'station-b',
|
||||
outboundBoardedAt: null,
|
||||
returnBoardedAt: null,
|
||||
tickets: [ticket],
|
||||
seats: [],
|
||||
schedule: {
|
||||
id: 'sched-1',
|
||||
departureAt,
|
||||
arrivalAt: new Date(departureAt.getTime() + 6 * 60 * 60 * 1000),
|
||||
originStationId: 'station-a',
|
||||
destinationStationId: 'station-b',
|
||||
originStation: { id: 'station-a', name: 'Furi Labu' },
|
||||
destinationStation: { id: 'station-b', name: 'Dire Dawa' },
|
||||
train: { id: 'train-1', number: 'T1' },
|
||||
stopTimes: [],
|
||||
},
|
||||
returnSchedule: null,
|
||||
...opts.booking,
|
||||
};
|
||||
|
||||
prisma = {
|
||||
ticket: {
|
||||
findUnique: jest.fn().mockResolvedValue(ticket),
|
||||
findFirst: jest.fn().mockResolvedValue(ticket),
|
||||
update: jest.fn().mockResolvedValue(ticket),
|
||||
},
|
||||
booking: {
|
||||
findUnique: jest.fn().mockResolvedValue(booking),
|
||||
update: jest.fn().mockResolvedValue(booking),
|
||||
},
|
||||
gateValidationLog: {
|
||||
create: jest.fn().mockResolvedValue({}),
|
||||
findMany: jest
|
||||
.fn()
|
||||
.mockResolvedValue((opts.approvedLegs ?? []).map((leg) => ({ leg, status: 'APPROVED' }))),
|
||||
},
|
||||
};
|
||||
audit = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
// Constructor order: prisma, notifications, systemConfig, auditService, dataSource.
|
||||
service = new TicketsService(
|
||||
prisma as any,
|
||||
{ sendSms: jest.fn(), sendEmail: jest.fn() } as any,
|
||||
{ get: jest.fn().mockResolvedValue(null), getNumber: jest.fn().mockResolvedValue(4) } as any,
|
||||
audit as any,
|
||||
{} as any,
|
||||
);
|
||||
return { ticket, booking };
|
||||
};
|
||||
|
||||
const rows = () => audit.log.mock.calls.map((c) => c[0]);
|
||||
const boardRows = () => rows().filter((r) => r.action === 'BOARD');
|
||||
|
||||
describe('a successful one-way boarding', () => {
|
||||
it('writes exactly one BOARD row', async () => {
|
||||
build();
|
||||
await service.validate(BOOKING_REF, 'GATE-1', 'MOBILE-GATE', undefined, ACTOR);
|
||||
expect(boardRows()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('identifies the ticket and the booking it belongs to', async () => {
|
||||
build();
|
||||
await service.validate(BOOKING_REF, 'GATE-1', 'MOBILE-GATE', undefined, ACTOR);
|
||||
|
||||
expect(boardRows()[0]).toMatchObject({
|
||||
action: 'BOARD',
|
||||
entityType: 'Ticket',
|
||||
entityId: TICKET_ID,
|
||||
});
|
||||
expect(boardRows()[0].newData).toMatchObject({
|
||||
bookingRef: BOOKING_REF,
|
||||
bookingId: BOOKING_ID,
|
||||
seatId: 'seat-9',
|
||||
});
|
||||
});
|
||||
|
||||
it('records the status the ticket moved from and to', async () => {
|
||||
build();
|
||||
await service.validate(BOOKING_REF, 'GATE-1', undefined, undefined, ACTOR);
|
||||
|
||||
const row = boardRows()[0];
|
||||
expect(row.oldData).toMatchObject({ status: 'ACTIVE', validatedAt: null });
|
||||
expect(row.newData.status).toBe('USED');
|
||||
expect(row.newData.boardedAt).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('leaves the actor to AuditService rather than passing a client-supplied id', async () => {
|
||||
build();
|
||||
await service.validate(BOOKING_REF, 'anyone-can-type-this', undefined, undefined, ACTOR);
|
||||
|
||||
// `userId` is never set at the call site — AuditService reads the guarded session, so the
|
||||
// body value below can only ever appear as descriptive context.
|
||||
expect(boardRows()[0].userId).toBeUndefined();
|
||||
expect(boardRows()[0].newData.validatorId).toBe('anyone-can-type-this');
|
||||
});
|
||||
|
||||
it('names the authenticated user on the gate log when no validatorId is sent', async () => {
|
||||
build();
|
||||
await service.validate(BOOKING_REF, '', undefined, undefined, ACTOR);
|
||||
|
||||
// Previously fell straight through to the anonymous 'BACKOFFICE' literal.
|
||||
expect(prisma.gateValidationLog.create.mock.calls[0][0].data.validatorId).toBe(ACTOR.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanAndBoard', () => {
|
||||
it('produces one row, not one per layer', async () => {
|
||||
build();
|
||||
// scanAndBoard delegates to validate(); logging in both would double every boarding.
|
||||
await service.scanAndBoard(BOOKING_REF, 'GATE-1', 'MOBILE-GATE', ACTOR);
|
||||
expect(boardRows()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a refused boarding', () => {
|
||||
it('records BOARD_DENIED with the reason on a round-trip leg already used', async () => {
|
||||
build({ bookingType: 'ROUND_TRIP', booking: { outboundBoardedAt: new Date() } });
|
||||
|
||||
await expect(
|
||||
service.validate(BOOKING_REF, 'GATE-1', undefined, 'OUTBOUND', ACTOR),
|
||||
).rejects.toThrow();
|
||||
|
||||
const denied = rows().filter((r) => r.action === 'BOARD_DENIED');
|
||||
expect(denied).toHaveLength(1);
|
||||
expect(denied[0]).toMatchObject({ entityType: 'Ticket', entityId: TICKET_ID });
|
||||
expect(denied[0].newData).toMatchObject({
|
||||
result: 'REJECTED',
|
||||
reason: 'OUTBOUND_ALREADY_USED',
|
||||
bookingRef: BOOKING_REF,
|
||||
});
|
||||
});
|
||||
|
||||
it('writes no BOARD row when the boarding was refused', async () => {
|
||||
build({ bookingType: 'TRANSIT', approvedLegs: ['LEG1'] });
|
||||
|
||||
await expect(
|
||||
service.validate(BOOKING_REF, 'GATE-1', undefined, 'LEG1', ACTOR),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(boardRows()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reads', () => {
|
||||
it('writes nothing when simply fetching a ticket', async () => {
|
||||
build();
|
||||
await service.getByRef(BOOKING_REF).catch(() => undefined);
|
||||
expect(audit.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('failed operations', () => {
|
||||
it('records nothing when the booking does not exist', async () => {
|
||||
build();
|
||||
prisma.booking.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.validate(BOOKING_REF, 'GATE-1', undefined, undefined, ACTOR),
|
||||
).rejects.toThrow();
|
||||
expect(audit.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records nothing when the ticket write itself fails', async () => {
|
||||
build();
|
||||
prisma.ticket.update.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
service.validate(BOOKING_REF, 'GATE-1', undefined, undefined, ACTOR),
|
||||
).rejects.toThrow();
|
||||
expect(boardRows()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sensitive data', () => {
|
||||
it('keeps the QR payload and passenger name off the row', async () => {
|
||||
build({ ticket: { qrPayload: 'QR-SECRET', passengerName: 'Abebe Kebede' } });
|
||||
await service.validate(BOOKING_REF, 'GATE-1', undefined, undefined, ACTOR);
|
||||
|
||||
const serialized = JSON.stringify(boardRows()[0]);
|
||||
expect(serialized).not.toContain('QR-SECRET');
|
||||
expect(serialized).not.toContain('Abebe Kebede');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Query, Req, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
import { resolveActingUser } from '../../common/acting-user';
|
||||
|
||||
@ApiTags('Tickets')
|
||||
@Controller('tickets')
|
||||
@@ -135,10 +136,13 @@ export class TicketsController {
|
||||
})
|
||||
scanAndBoard(
|
||||
@Param('qrCodeOrRef') qrCodeOrRef: string,
|
||||
@Req() req: any,
|
||||
@Body('validatorId') validatorId: string,
|
||||
@Body('gateId') gateId?: string,
|
||||
) {
|
||||
return this.service.scanAndBoard(qrCodeOrRef, validatorId, gateId);
|
||||
// `validatorId` still labels the gate/agent on the gate log; who is accountable for the
|
||||
// boarding comes from the JWT, which the body cannot influence.
|
||||
return this.service.scanAndBoard(qrCodeOrRef, validatorId, gateId, resolveActingUser(req));
|
||||
}
|
||||
|
||||
@Post(':bookingRef/validate')
|
||||
@@ -165,11 +169,12 @@ export class TicketsController {
|
||||
})
|
||||
validate(
|
||||
@Param('bookingRef') ref: string,
|
||||
@Req() req: any,
|
||||
@Body('validatorId') validatorId: string,
|
||||
@Body('gateId') gateId?: string,
|
||||
@Body('leg') leg?: string,
|
||||
) {
|
||||
return this.service.validate(ref, validatorId, gateId, leg);
|
||||
return this.service.validate(ref, validatorId, gateId, leg, resolveActingUser(req));
|
||||
}
|
||||
|
||||
@Get(':ticketId/validation-logs')
|
||||
@@ -216,8 +221,8 @@ export class TicketsController {
|
||||
},
|
||||
},
|
||||
})
|
||||
validateOfflineBatch(@Body() body: { validations: any[] }) {
|
||||
return this.service.validateOfflineBatch(body.validations);
|
||||
validateOfflineBatch(@Body() body: { validations: any[] }, @Req() req: any) {
|
||||
return this.service.validateOfflineBatch(body.validations, resolveActingUser(req));
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
|
||||
@@ -5,6 +5,8 @@ import { PrismaService } from '../../common/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
|
||||
import { ActingUser } from '../../common/acting-user';
|
||||
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
@@ -289,12 +291,16 @@ export class TicketsService {
|
||||
reassigned.push({ seatNumber: bs.seat.seatNumber, newSeatNumber: candidate.seatNumber });
|
||||
}
|
||||
|
||||
await this.auditService.log({
|
||||
action: 'UPDATE',
|
||||
entityType: 'Booking',
|
||||
entityId: bookingId,
|
||||
newData: { smartReassigned: true, changes: reassigned },
|
||||
});
|
||||
// Only when a seat actually moved: this runs on every reassignment sweep, including from
|
||||
// generateMissing()'s batch loop, and an empty `changes` row says nothing happened.
|
||||
if (reassigned.length > 0) {
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.UPDATE,
|
||||
entityType: AUDIT_ENTITIES.Booking,
|
||||
entityId: bookingId,
|
||||
newData: { smartReassigned: true, changes: reassigned },
|
||||
});
|
||||
}
|
||||
|
||||
return this.generate(bookingId);
|
||||
}
|
||||
@@ -506,7 +512,15 @@ export class TicketsService {
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
await this.auditService.log({ action: 'CREATE', entityType: 'Ticket', entityId: booking.id, newData: { bookingRef: booking.bookingRef, totalTickets: tickets.length } });
|
||||
// NOTE: entityId is the booking's id, not a ticket id — pre-existing and left as-is so
|
||||
// historical rows stay queryable the same way. Ticket generation is outside this change's
|
||||
// scope; see the audit-trail report.
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.CREATE,
|
||||
entityType: AUDIT_ENTITIES.Ticket,
|
||||
entityId: booking.id,
|
||||
newData: { bookingRef: booking.bookingRef, totalTickets: tickets.length },
|
||||
});
|
||||
return { tickets, totalTickets: tickets.length };
|
||||
}
|
||||
|
||||
@@ -587,7 +601,12 @@ export class TicketsService {
|
||||
};
|
||||
}
|
||||
|
||||
async scanAndBoard(qrCodeOrRef: string, validatorId: string, gateId?: string) {
|
||||
async scanAndBoard(
|
||||
qrCodeOrRef: string,
|
||||
validatorId: string,
|
||||
gateId?: string,
|
||||
actor?: ActingUser | null,
|
||||
) {
|
||||
try {
|
||||
// Extract booking reference from QR code if it's JSON
|
||||
let bookingRef = qrCodeOrRef;
|
||||
@@ -662,7 +681,7 @@ export class TicketsService {
|
||||
}
|
||||
|
||||
// Use existing validation logic to handle round trips properly
|
||||
const result = await this.validate(bookingRef, validatorId, gateId);
|
||||
const result = await this.validate(bookingRef, validatorId, gateId, undefined, actor);
|
||||
if ((result as any).alreadyValidated) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -745,7 +764,21 @@ export class TicketsService {
|
||||
}
|
||||
}
|
||||
|
||||
async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) {
|
||||
/**
|
||||
* Boards a ticket at the gate.
|
||||
*
|
||||
* `validatorId` is the gate/agent label the client sends and keeps its existing meaning on
|
||||
* `Ticket.validatorId` and `GateValidationLog`. `actor` is the authenticated staff member from
|
||||
* the request — it is what the audit trail attributes the boarding to, so a caller cannot board
|
||||
* a passenger under someone else's name by editing the request body.
|
||||
*/
|
||||
async validate(
|
||||
ticketIdOrRef: string,
|
||||
validatorId: string,
|
||||
gateId?: string,
|
||||
leg?: string,
|
||||
actor?: ActingUser | null,
|
||||
) {
|
||||
// Accept either a ticket UUID or a bookingRef
|
||||
let bookingRef = ticketIdOrRef;
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ticketIdOrRef);
|
||||
@@ -754,7 +787,9 @@ export class TicketsService {
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
bookingRef = ticket.bookingRef;
|
||||
}
|
||||
const resolvedValidatorId = validatorId || 'BACKOFFICE';
|
||||
// Falls back to the authenticated user before the anonymous 'BACKOFFICE' literal, so an
|
||||
// omitted validatorId still names a real person on the gate log.
|
||||
const resolvedValidatorId = validatorId || actor?.id || 'BACKOFFICE';
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
const ticket = await this.prisma.ticket.findFirst({ where: { bookingId: booking.id } });
|
||||
@@ -763,6 +798,14 @@ export class TicketsService {
|
||||
const type = booking.bookingType;
|
||||
const now = new Date();
|
||||
|
||||
// Snapshotted before any branch mutates the rows, so every audit row below can report the
|
||||
// status the ticket actually moved away from.
|
||||
const previousTicketStatus = ticket.status;
|
||||
const previousValidatedAt = ticket.validatedAt?.toISOString() ?? null;
|
||||
const previousOutboundBoardedAt =
|
||||
(booking as any).outboundBoardedAt?.toISOString() ?? null;
|
||||
const previousReturnBoardedAt = (booking as any).returnBoardedAt?.toISOString() ?? null;
|
||||
|
||||
const markTicketUsed = async () => {
|
||||
if (ticket.status !== 'USED') {
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'USED' } });
|
||||
@@ -782,7 +825,24 @@ export class TicketsService {
|
||||
});
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
|
||||
this.fireBoardingPassNotification(booking, ticket, null);
|
||||
await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: 'ONE_WAY' } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.BOARD,
|
||||
entityType: AUDIT_ENTITIES.Ticket,
|
||||
entityId: ticket.id,
|
||||
oldData: { status: ticket.status, validatedAt: null, boardedAt: null },
|
||||
newData: {
|
||||
status: 'USED',
|
||||
validatedAt: now.toISOString(),
|
||||
boardedAt: now.toISOString(),
|
||||
leg: 'ONE_WAY',
|
||||
bookingRef,
|
||||
bookingId: booking.id,
|
||||
seatId: ticket.seatId,
|
||||
ticketLeg: ticket.leg,
|
||||
gateId,
|
||||
validatorId: resolvedValidatorId,
|
||||
},
|
||||
});
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: now };
|
||||
}
|
||||
|
||||
@@ -797,6 +857,23 @@ export class TicketsService {
|
||||
if (alreadyValidated) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
|
||||
await markTicketUsed();
|
||||
// A refused boarding is exactly the attempt an investigator wants attributed; the gate
|
||||
// log records it but carries no IAM actor, IP, or user-agent.
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.BOARD_DENIED,
|
||||
entityType: AUDIT_ENTITIES.Ticket,
|
||||
entityId: ticket.id,
|
||||
oldData: { status: ticket.status, validatedAt: ticket.validatedAt?.toISOString() ?? null },
|
||||
newData: {
|
||||
result: 'REJECTED',
|
||||
reason: `${resolvedLeg}_ALREADY_USED`,
|
||||
leg: resolvedLeg,
|
||||
bookingRef,
|
||||
bookingId: booking.id,
|
||||
gateId,
|
||||
validatorId: resolvedValidatorId,
|
||||
},
|
||||
});
|
||||
throw new BadRequestException(`${resolvedLeg} already validated`);
|
||||
}
|
||||
const validatedAt = ticket.validatedAt ?? now;
|
||||
@@ -810,7 +887,24 @@ export class TicketsService {
|
||||
}
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
|
||||
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
|
||||
await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.BOARD,
|
||||
entityType: AUDIT_ENTITIES.Ticket,
|
||||
entityId: ticket.id,
|
||||
oldData: { status: previousTicketStatus, validatedAt: previousValidatedAt },
|
||||
newData: {
|
||||
status: 'USED',
|
||||
validatedAt: validatedAt.toISOString(),
|
||||
boardedAt: now.toISOString(),
|
||||
leg: resolvedLeg,
|
||||
bookingRef,
|
||||
bookingId: booking.id,
|
||||
seatId: ticket.seatId,
|
||||
ticketLeg: ticket.leg,
|
||||
gateId,
|
||||
validatorId: resolvedValidatorId,
|
||||
},
|
||||
});
|
||||
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt };
|
||||
}
|
||||
|
||||
@@ -826,6 +920,21 @@ export class TicketsService {
|
||||
if ((booking as any).outboundBoardedAt) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any });
|
||||
await markTicketUsed();
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.BOARD_DENIED,
|
||||
entityType: AUDIT_ENTITIES.Ticket,
|
||||
entityId: ticket.id,
|
||||
oldData: { status: previousTicketStatus, validatedAt: previousValidatedAt },
|
||||
newData: {
|
||||
result: 'REJECTED',
|
||||
reason: 'OUTBOUND_ALREADY_USED',
|
||||
leg: resolvedLeg,
|
||||
bookingRef,
|
||||
bookingId: booking.id,
|
||||
gateId,
|
||||
validatorId: resolvedValidatorId,
|
||||
},
|
||||
});
|
||||
throw new BadRequestException('Outbound leg already validated');
|
||||
}
|
||||
bookingData.outboundBoardedAt = now;
|
||||
@@ -833,6 +942,21 @@ export class TicketsService {
|
||||
if ((booking as any).returnBoardedAt) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any });
|
||||
await markTicketUsed();
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.BOARD_DENIED,
|
||||
entityType: AUDIT_ENTITIES.Ticket,
|
||||
entityId: ticket.id,
|
||||
oldData: { status: previousTicketStatus, validatedAt: previousValidatedAt },
|
||||
newData: {
|
||||
result: 'REJECTED',
|
||||
reason: 'RETURN_ALREADY_USED',
|
||||
leg: resolvedLeg,
|
||||
bookingRef,
|
||||
bookingId: booking.id,
|
||||
gateId,
|
||||
validatorId: resolvedValidatorId,
|
||||
},
|
||||
});
|
||||
throw new BadRequestException('Return leg already validated');
|
||||
}
|
||||
bookingData.returnBoardedAt = now;
|
||||
@@ -852,7 +976,29 @@ export class TicketsService {
|
||||
}
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
|
||||
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
|
||||
await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } });
|
||||
await this.auditService.log({
|
||||
action: AUDIT_ACTIONS.BOARD,
|
||||
entityType: AUDIT_ENTITIES.Ticket,
|
||||
entityId: ticket.id,
|
||||
oldData: {
|
||||
status: previousTicketStatus,
|
||||
validatedAt: previousValidatedAt,
|
||||
outboundBoardedAt: previousOutboundBoardedAt,
|
||||
returnBoardedAt: previousReturnBoardedAt,
|
||||
},
|
||||
newData: {
|
||||
status: 'USED',
|
||||
validatedAt: validatedAt.toISOString(),
|
||||
boardedAt: now.toISOString(),
|
||||
leg: resolvedLeg,
|
||||
bookingRef,
|
||||
bookingId: booking.id,
|
||||
seatId: ticket.seatId,
|
||||
ticketLeg: ticket.leg,
|
||||
gateId,
|
||||
validatorId: resolvedValidatorId,
|
||||
},
|
||||
});
|
||||
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt };
|
||||
}
|
||||
|
||||
@@ -894,7 +1040,7 @@ export class TicketsService {
|
||||
}));
|
||||
}
|
||||
|
||||
async validateOfflineBatch(validations: OfflineValidation[]) {
|
||||
async validateOfflineBatch(validations: OfflineValidation[], actor?: ActingUser | null) {
|
||||
const results = [];
|
||||
|
||||
for (const validation of validations) {
|
||||
@@ -903,7 +1049,8 @@ export class TicketsService {
|
||||
validation.bookingRef,
|
||||
validation.validatorId,
|
||||
validation.gateId,
|
||||
validation.leg
|
||||
validation.leg,
|
||||
actor,
|
||||
);
|
||||
results.push({
|
||||
bookingRef: validation.bookingRef,
|
||||
|
||||
Reference in New Issue
Block a user