Merge pull request #1338 from Tria-plc/alpha

feat: ( audit ) resolve the actor from the session and audit all back…
This commit is contained in:
Abubeker Yasin
2026-08-18 16:19:15 +03:00
committed by GitHub
32 changed files with 3068 additions and 420 deletions

View File

@@ -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";
@@ -393,13 +395,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',
});
}
@@ -517,9 +519,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')

View File

@@ -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" }),
}),

View File

@@ -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 };
}
@@ -1086,16 +1109,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,
@@ -1105,11 +1139,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>) {
@@ -1127,10 +1187,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) {
@@ -1523,21 +1593,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 };
}
/**
@@ -1584,8 +1667,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
@@ -1593,13 +1678,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,
@@ -1784,6 +1875,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: {
@@ -1822,17 +1916,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;
});
}

View File

@@ -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();
});
});
});

View File

@@ -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 };
}

View File

@@ -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 () => {