mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
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:
@@ -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')
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user