mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -46,7 +46,7 @@
|
||||
"@prisma/client": "^6.19.3",
|
||||
"@sendgrid/mail": "^8.1.0",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.1.0.tgz",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
|
||||
49
apps/edr-passenger-api/prisma/fix-payment-method-currency.ts
Normal file
49
apps/edr-passenger-api/prisma/fix-payment-method-currency.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* One-off data fix: corrects PaymentMethod.currency for methods whose settlement currency
|
||||
* was never set at seed time and silently defaulted to the schema's ETB default.
|
||||
*
|
||||
* payments.service.ts's chargeCurrency resolution reads this column directly (see the
|
||||
* comment above `chargeCurrency` in `initiatePayment`): WAAFI settles in DJF, CARD in USD.
|
||||
* With WAAFI stuck on the ETB default, live Waafi payments were charged in ETB instead of
|
||||
* being converted to DJF — not just a mislabeled report. This script only touches the
|
||||
* PaymentMethod config row; it does NOT rewrite any existing PaymentIntent/Booking records,
|
||||
* since correcting historical transaction currency is a financial decision, not a data-fix
|
||||
* this script should make unilaterally.
|
||||
*
|
||||
* Safe to re-run. Only updates rows that already exist; does not create new ones.
|
||||
*
|
||||
* Usage: node --env-file=.env -r ts-node/register prisma/fix-payment-method-currency.ts
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const CORRECTIONS: { type: string; currency: string }[] = [
|
||||
{ type: 'WAAFI', currency: 'DJF' },
|
||||
{ type: 'CARD', currency: 'USD' },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
for (const { type, currency } of CORRECTIONS) {
|
||||
const existing = await prisma.paymentMethod.findUnique({ where: { type: type as any } });
|
||||
if (!existing) {
|
||||
console.log(` ⚠️ No PaymentMethod row for ${type} — skipping (nothing to correct).`);
|
||||
continue;
|
||||
}
|
||||
if (existing.currency === currency) {
|
||||
console.log(` ℹ️ ${type} already set to ${currency} — no change.`);
|
||||
continue;
|
||||
}
|
||||
await prisma.paymentMethod.update({ where: { type: type as any }, data: { currency } });
|
||||
console.log(` ✅ ${type}: ${existing.currency} → ${currency}`);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ fix-payment-method-currency failed:', e);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import {
|
||||
assertPassengerPermission,
|
||||
collectPermissionKeys,
|
||||
hasPassengerPermission,
|
||||
hasPassengerPermissionStrict,
|
||||
isOrganizationAdmin,
|
||||
isSuperAdmin,
|
||||
} from './passenger-permission.util';
|
||||
|
||||
const TICKETS_MANAGE = 'edr_passenger_app:tickets:manage';
|
||||
const TICKETS_VIEW = 'edr_passenger_app:tickets:view';
|
||||
const BOOKINGS_VIEW = 'edr_passenger_app:bookings:view';
|
||||
const FLEET_MANAGE = 'edr_passenger_app:fleet:manage';
|
||||
|
||||
/** IAM shape: position types nest the key one level deeper, under `.permission`. */
|
||||
const positionType = (keys: string[]) => ({
|
||||
positionTypePermissions: keys.map((key) => ({ permission: { key } })),
|
||||
});
|
||||
|
||||
/** Mirrors the ticket-officer position IAM returns: own grant + two position types. */
|
||||
const ticketOfficerPosition = () => ({
|
||||
id: 'pos-ticket-officer',
|
||||
permissions: [{ key: BOOKINGS_VIEW }],
|
||||
positionType: positionType([TICKETS_VIEW, TICKETS_MANAGE]),
|
||||
positionTypes: [
|
||||
positionType(['can:view:expectation']),
|
||||
positionType([TICKETS_VIEW, TICKETS_MANAGE]),
|
||||
],
|
||||
});
|
||||
|
||||
const hrPosition = () => ({
|
||||
id: 'pos-hr',
|
||||
permissions: [{ key: 'hr_app:employees:view' }],
|
||||
positionType: positionType(['hr_app:leave:approve']),
|
||||
positionTypes: [positionType(['hr_app:leave:approve'])],
|
||||
});
|
||||
|
||||
/** `/v1/auth/me` — `employee` is an array of employees, each with `positions[]`. */
|
||||
const meShape = (positions: any[]) => ({
|
||||
roles: [],
|
||||
permissions: [],
|
||||
employee: [{ id: 'emp-1', positions }],
|
||||
});
|
||||
|
||||
/**
|
||||
* `request.user` — `JwtGuard.parseToken` spreads the employee, then overwrites
|
||||
* `position` with the one it selected and adds `delegatedPositions[]`. The full
|
||||
* `positions[]` survives the spread.
|
||||
*/
|
||||
const requestShape = (positions: any[], selectedIndex = 0) => ({
|
||||
roles: [],
|
||||
permissions: [],
|
||||
employee: {
|
||||
id: 'emp-1',
|
||||
positions,
|
||||
position: positions[selectedIndex],
|
||||
delegatedPositions: positions.filter((p: any) => p.isDelegate),
|
||||
},
|
||||
});
|
||||
|
||||
describe('collectPermissionKeys', () => {
|
||||
it('returns nothing for a missing user', () => {
|
||||
expect(collectPermissionKeys(null)).toEqual([]);
|
||||
expect(collectPermissionKeys(undefined)).toEqual([]);
|
||||
expect(collectPermissionKeys({})).toEqual([]);
|
||||
});
|
||||
|
||||
it('collects role permissions when there is no employee record', () => {
|
||||
expect(collectPermissionKeys({ permissions: [{ key: TICKETS_VIEW }] })).toEqual([TICKETS_VIEW]);
|
||||
expect(collectPermissionKeys({ permissions: [{ key: TICKETS_VIEW }], employee: null })).toEqual([
|
||||
TICKETS_VIEW,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('backward compatibility — positions with no position types', () => {
|
||||
it('still reads a position own permissions[] in the /v1/auth/me shape', () => {
|
||||
const user = meShape([{ id: 'p1', permissions: [{ key: BOOKINGS_VIEW }] }]);
|
||||
expect(collectPermissionKeys(user)).toEqual([BOOKINGS_VIEW]);
|
||||
});
|
||||
|
||||
it('still reads a position own permissions[] in the request.user shape', () => {
|
||||
const user = requestShape([{ id: 'p1', permissions: [{ key: BOOKINGS_VIEW }] }]);
|
||||
expect(collectPermissionKeys(user)).toEqual([BOOKINGS_VIEW]);
|
||||
});
|
||||
|
||||
it('merges role permissions with position permissions', () => {
|
||||
const user = {
|
||||
permissions: [{ key: 'role:key' }],
|
||||
employee: [{ positions: [{ permissions: [{ key: BOOKINGS_VIEW }] }] }],
|
||||
};
|
||||
expect(collectPermissionKeys(user).sort()).toEqual([BOOKINGS_VIEW, 'role:key'].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('position types', () => {
|
||||
it('collects from the legacy singular positionType', () => {
|
||||
const user = meShape([{ permissions: [], positionType: positionType([TICKETS_MANAGE]) }]);
|
||||
expect(collectPermissionKeys(user)).toEqual([TICKETS_MANAGE]);
|
||||
});
|
||||
|
||||
it('collects from the new positionTypes[] array', () => {
|
||||
const user = meShape([{ permissions: [], positionTypes: [positionType([TICKETS_MANAGE])] }]);
|
||||
expect(collectPermissionKeys(user)).toEqual([TICKETS_MANAGE]);
|
||||
});
|
||||
|
||||
it('collects from every entry of positionTypes[], not just the first', () => {
|
||||
const user = meShape([
|
||||
{
|
||||
permissions: [],
|
||||
positionTypes: [positionType(['a']), positionType(['b']), positionType(['c'])],
|
||||
},
|
||||
]);
|
||||
expect(collectPermissionKeys(user).sort()).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('unions permissions[], positionType and positionTypes[] without duplicates', () => {
|
||||
// ticket-officer appears as BOTH the singular positionType and inside positionTypes[]
|
||||
const keys = collectPermissionKeys(meShape([ticketOfficerPosition()]));
|
||||
expect(keys.sort()).toEqual(
|
||||
[BOOKINGS_VIEW, TICKETS_VIEW, TICKETS_MANAGE, 'can:view:expectation'].sort(),
|
||||
);
|
||||
expect(keys).toHaveLength(new Set(keys).size);
|
||||
});
|
||||
|
||||
it('works identically in the request.user shape', () => {
|
||||
expect(collectPermissionKeys(requestShape([ticketOfficerPosition()])).sort()).toEqual(
|
||||
collectPermissionKeys(meShape([ticketOfficerPosition()])).sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('all positions count, not only the selected one', () => {
|
||||
it('grants a permission held by a position that is not positions[0]', () => {
|
||||
// parseToken selected positions[0] (HR) because no x-current-position-id was sent
|
||||
const user = requestShape([hrPosition(), ticketOfficerPosition()], 0);
|
||||
expect(collectPermissionKeys(user)).toContain(TICKETS_MANAGE);
|
||||
});
|
||||
|
||||
it('matches what the backoffice computes from the same payload', () => {
|
||||
const positions = [hrPosition(), ticketOfficerPosition()];
|
||||
expect(collectPermissionKeys(requestShape(positions, 0)).sort()).toEqual(
|
||||
collectPermissionKeys(meShape(positions)).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('spans multiple employee records', () => {
|
||||
const user = {
|
||||
employee: [{ positions: [hrPosition()] }, { positions: [ticketOfficerPosition()] }],
|
||||
};
|
||||
expect(collectPermissionKeys(user)).toContain(TICKETS_MANAGE);
|
||||
expect(collectPermissionKeys(user)).toContain('hr_app:leave:approve');
|
||||
});
|
||||
|
||||
it('includes delegated positions', () => {
|
||||
const delegated = { ...ticketOfficerPosition(), isDelegate: true };
|
||||
const user = {
|
||||
employee: { positions: undefined, position: hrPosition(), delegatedPositions: [delegated] },
|
||||
};
|
||||
expect(collectPermissionKeys(user)).toContain(TICKETS_MANAGE);
|
||||
});
|
||||
|
||||
it('does not invent permissions nobody was granted', () => {
|
||||
const user = requestShape([hrPosition(), ticketOfficerPosition()]);
|
||||
expect(collectPermissionKeys(user)).not.toContain(FLEET_MANAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed payloads', () => {
|
||||
it('survives nulls, missing keys and non-array fields', () => {
|
||||
const user: any = {
|
||||
permissions: [{}, { key: 'kept' }],
|
||||
employee: {
|
||||
// parseToken passes `positions` through untouched when it is not an array
|
||||
positions: { id: 'not-an-array' },
|
||||
position: {
|
||||
permissions: null,
|
||||
positionType: null,
|
||||
positionTypes: [null, { positionTypePermissions: null }, positionType([TICKETS_VIEW])],
|
||||
},
|
||||
delegatedPositions: null,
|
||||
},
|
||||
};
|
||||
expect(collectPermissionKeys(user).sort()).toEqual(['kept', TICKETS_VIEW].sort());
|
||||
});
|
||||
|
||||
it('ignores positionTypePermissions entries with no permission object', () => {
|
||||
const user: any = {
|
||||
employee: [
|
||||
{
|
||||
positions: [
|
||||
{
|
||||
positionTypes: [
|
||||
{
|
||||
positionTypePermissions: [
|
||||
{},
|
||||
{ permission: null },
|
||||
{ permission: { key: 'ok' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(collectPermissionKeys(user)).toEqual(['ok']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('role helpers', () => {
|
||||
it('detects super admin and org admin', () => {
|
||||
expect(isSuperAdmin({ roles: [{ key: 'super_admin' }] })).toBe(true);
|
||||
expect(isOrganizationAdmin({ roles: [{ key: 'organization_admin' }] })).toBe(true);
|
||||
expect(isSuperAdmin({ roles: [{ key: 'ticket_officer' }] })).toBe(false);
|
||||
expect(isOrganizationAdmin({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasPassengerPermission', () => {
|
||||
it('is true for a permission granted only through a position type', () => {
|
||||
expect(hasPassengerPermission(requestShape([ticketOfficerPosition()]), TICKETS_MANAGE)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('is true when the granting position is not the selected one', () => {
|
||||
const user = requestShape([hrPosition(), ticketOfficerPosition()], 0);
|
||||
expect(hasPassengerPermission(user, TICKETS_MANAGE)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a permission nobody granted', () => {
|
||||
expect(hasPassengerPermission(requestShape([ticketOfficerPosition()]), FLEET_MANAGE)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('is false without a user', () => {
|
||||
expect(hasPassengerPermission(null, TICKETS_MANAGE)).toBe(false);
|
||||
});
|
||||
|
||||
it('lets super admins and org admins bypass', () => {
|
||||
expect(hasPassengerPermission({ roles: [{ key: 'super_admin' }] }, FLEET_MANAGE)).toBe(true);
|
||||
expect(hasPassengerPermission({ roles: [{ key: 'organization_admin' }] }, FLEET_MANAGE)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasPassengerPermissionStrict', () => {
|
||||
it('does not let super admins bypass', () => {
|
||||
expect(hasPassengerPermissionStrict({ roles: [{ key: 'super_admin' }] }, FLEET_MANAGE)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('honours a position-type grant', () => {
|
||||
expect(hasPassengerPermissionStrict(requestShape([ticketOfficerPosition()]), TICKETS_MANAGE)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertPassengerPermission', () => {
|
||||
it('passes silently when granted through a position type', () => {
|
||||
expect(() =>
|
||||
assertPassengerPermission(requestShape([ticketOfficerPosition()]), TICKETS_MANAGE),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws ForbiddenException naming the missing key', () => {
|
||||
expect(() => assertPassengerPermission(requestShape([hrPosition()]), TICKETS_MANAGE)).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(() => assertPassengerPermission(requestShape([hrPosition()]), TICKETS_MANAGE)).toThrow(
|
||||
TICKETS_MANAGE,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,34 @@ const SUPER_ADMIN_ROLE = 'super_admin';
|
||||
const ORGANIZATION_ADMIN_ROLE = 'organization_admin';
|
||||
|
||||
type PermissionLike = { key?: string };
|
||||
|
||||
/**
|
||||
* A position type carries its own grants. IAM ships them as
|
||||
* `positionTypePermissions[].permission.key` — note the extra `permission`
|
||||
* wrapper, unlike the flat `permissions[]` on a position.
|
||||
*/
|
||||
type PositionTypeLike = {
|
||||
positionTypePermissions?: ({ permission?: PermissionLike | null } | null)[] | null;
|
||||
};
|
||||
|
||||
type PositionLike = {
|
||||
permissions?: PermissionLike[];
|
||||
/** Legacy single position type. */
|
||||
positionType?: PositionTypeLike | null;
|
||||
/** Newer array — a position can now carry several position types. */
|
||||
positionTypes?: (PositionTypeLike | null)[] | null;
|
||||
};
|
||||
|
||||
type EmployeeLike = {
|
||||
position?: PositionLike;
|
||||
positions?: PositionLike[];
|
||||
delegatedPositions?: PositionLike[];
|
||||
};
|
||||
|
||||
type MeLikeUser = {
|
||||
roles?: { key?: string }[];
|
||||
permissions?: PermissionLike[];
|
||||
employee?:
|
||||
| { position?: { permissions?: PermissionLike[] }; delegatedPositions?: { permissions?: PermissionLike[] }[] }
|
||||
| { positions?: { permissions?: PermissionLike[] }[] }[]
|
||||
| null;
|
||||
employee?: EmployeeLike | EmployeeLike[] | null;
|
||||
};
|
||||
|
||||
export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean {
|
||||
@@ -21,6 +42,42 @@ export function isOrganizationAdmin(user: MeLikeUser | null | undefined): boolea
|
||||
return user?.roles?.some((r) => r.key === ORGANIZATION_ADMIN_ROLE) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add every permission key a single position grants.
|
||||
*
|
||||
* A position's own `permissions[]` used to be the whole story. IAM now also
|
||||
* hangs grants off *position types* — the legacy singular `positionType` plus
|
||||
* the newer `positionTypes[]` array — so we union all three rather than trust
|
||||
* IAM to have merged them back into `permissions[]`.
|
||||
*/
|
||||
function addPositionPermissionKeys(
|
||||
position: PositionLike | null | undefined,
|
||||
keys: Set<string>,
|
||||
): void {
|
||||
if (!position) return;
|
||||
|
||||
for (const p of position.permissions ?? []) {
|
||||
if (p?.key) keys.add(p.key);
|
||||
}
|
||||
|
||||
const positionTypes: (PositionTypeLike | null | undefined)[] = [
|
||||
position.positionType,
|
||||
...(position.positionTypes ?? []),
|
||||
];
|
||||
|
||||
for (const positionType of positionTypes) {
|
||||
for (const ptp of positionType?.positionTypePermissions ?? []) {
|
||||
const key = ptp?.permission?.key;
|
||||
if (key) keys.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `parseToken` passes `positions` through untouched, so it is not always an array. */
|
||||
function asArray<T>(value: T[] | null | undefined): T[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
|
||||
if (!user) return [];
|
||||
|
||||
@@ -33,23 +90,25 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
|
||||
const employee = user.employee;
|
||||
if (!employee) return [...keys];
|
||||
|
||||
if (Array.isArray(employee)) {
|
||||
for (const emp of employee) {
|
||||
for (const pos of emp.positions ?? []) {
|
||||
for (const p of pos.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
// `/v1/auth/me` hands back `employee` as an array of employees, each with
|
||||
// `positions[]`. `JwtGuard.parseToken` collapses it to a single employee with
|
||||
// the active `position` plus `delegatedPositions[]` — but it spreads the
|
||||
// employee, so the full `positions[]` survives on `request.user` too. Both
|
||||
// shapes reach here.
|
||||
const employees = Array.isArray(employee) ? employee : [employee];
|
||||
|
||||
for (const p of employee.position?.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
for (const delegated of employee.delegatedPositions ?? []) {
|
||||
for (const p of delegated.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
for (const emp of employees) {
|
||||
// Every position the person holds counts, not just the one
|
||||
// `parseToken` selected. Without a `x-current-position-id` header it picks
|
||||
// `positions[0]`, so a second-listed passenger position would 403 here while
|
||||
// the backoffice — which unions all positions at login — renders the action
|
||||
// as available. Union them here so the two agree.
|
||||
addPositionPermissionKeys(emp.position, keys);
|
||||
for (const pos of asArray(emp.positions)) {
|
||||
addPositionPermissionKeys(pos, keys);
|
||||
}
|
||||
for (const delegated of asArray(emp.delegatedPositions)) {
|
||||
addPositionPermissionKeys(delegated, keys);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,14 +101,20 @@ export class PaymentsController {
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Get all payments with filters (staff/admin only)" })
|
||||
@ApiQuery({ name: "search", required: false })
|
||||
@ApiQuery({ name: "status", required: false })
|
||||
@ApiQuery({ name: "status", required: false, description: "PaymentIntentStatus value, e.g. SUCCEEDED" })
|
||||
@ApiQuery({ name: "method", required: false })
|
||||
@ApiQuery({
|
||||
name: "bookingStatus",
|
||||
required: false,
|
||||
description: "Comma-separated Booking.status values, e.g. CONFIRMED,BOARDED — restricts to payments backing bookings in those states.",
|
||||
})
|
||||
@ApiQuery({ name: "page", required: false })
|
||||
@ApiQuery({ name: "pageSize", required: false })
|
||||
async getAll(
|
||||
@Query("search") search?: string,
|
||||
@Query("status") status?: string,
|
||||
@Query("method") method?: string,
|
||||
@Query("bookingStatus") bookingStatus?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
) {
|
||||
@@ -116,6 +122,7 @@ export class PaymentsController {
|
||||
search,
|
||||
status,
|
||||
method,
|
||||
bookingStatus,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 10,
|
||||
});
|
||||
|
||||
@@ -122,10 +122,13 @@ export class PaymentsService {
|
||||
search?: string;
|
||||
status?: string;
|
||||
method?: string;
|
||||
/** Comma-separated Booking.status values, e.g. "CONFIRMED,BOARDED" — lets a caller ask
|
||||
* for exactly the payments that back confirmed revenue, not every payment attempt. */
|
||||
bookingStatus?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
const { search, status, method, page = 1, pageSize = 10 } = filters;
|
||||
const { search, status, method, bookingStatus, page = 1, pageSize = 10 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
@@ -141,6 +144,12 @@ export class PaymentsService {
|
||||
if (method) {
|
||||
where.method = method;
|
||||
}
|
||||
if (bookingStatus) {
|
||||
const statuses = bookingStatus.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (statuses.length > 0) {
|
||||
where.booking = { status: { in: statuses } };
|
||||
}
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.paymentIntent.findMany({
|
||||
@@ -156,6 +165,7 @@ export class PaymentsService {
|
||||
childCount: true,
|
||||
totalMinor: true,
|
||||
currency: true,
|
||||
status: true,
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
},
|
||||
},
|
||||
@@ -195,6 +205,7 @@ export class PaymentsService {
|
||||
bookingRef: b?.bookingRef,
|
||||
totalMinor: b?.totalMinor,
|
||||
currency: b?.currency,
|
||||
status: b?.status,
|
||||
},
|
||||
amountMinor,
|
||||
currency: item.currency,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
ApiProduces,
|
||||
} from "@nestjs/swagger";
|
||||
import { ReportsService } from "./reports.service";
|
||||
import { BlockedSeatsRevenueLossQueryDto, GenerateReportDto } from "./reports.dto";
|
||||
import { BlockedSeatsRevenueLossQueryDto, FinanceSummaryQueryDto, GenerateReportDto } from "./reports.dto";
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
|
||||
@@ -118,6 +118,39 @@ export class ReportsController {
|
||||
return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort });
|
||||
}
|
||||
|
||||
// ── Finance Summary ──────────────────────────────────────────────────────
|
||||
|
||||
@Get("finance")
|
||||
@ApiOperation({
|
||||
summary: "Finance summary — revenue by period, origin/destination segment, payment method, and currency",
|
||||
description:
|
||||
"Revenue collected in the window (PaymentIntent.paidAt), grouped by day/week/month, origin → " +
|
||||
"destination station pair, payment method, and currency. Amounts are never converted to ETB — a " +
|
||||
"Waafi payment is reported in whatever currency Waafi actually charged, and with no method filter " +
|
||||
"every currency present is listed separately rather than summed. Filter by originStationId and/or " +
|
||||
"destinationStationId independently to query any station-pair segment (A→B, A→D, B→C), not just a " +
|
||||
"whole predefined route. Only counts CONFIRMED/BOARDED bookings with a SUCCEEDED payment — the same " +
|
||||
"revenue definition as the dashboard and /payments confirmed-revenue filter. Returns per-bucket rows " +
|
||||
"plus roll-ups by period, segment, and method for charting.",
|
||||
})
|
||||
getFinanceSummary(@Query() query: FinanceSummaryQueryDto) {
|
||||
return this.service.getFinanceSummary(query);
|
||||
}
|
||||
|
||||
@Get("finance/export")
|
||||
@ApiOperation({ summary: "Finance summary as CSV — one row per period + route + payment method" })
|
||||
@ApiProduces("text/csv")
|
||||
@ApiOkResponse({ description: "CSV export", schema: { type: "string" } })
|
||||
async exportFinanceSummary(@Query() query: FinanceSummaryQueryDto, @Res() res: Response): Promise<void> {
|
||||
const csv = await this.service.exportFinanceSummaryCsv(query);
|
||||
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="finance-summary-${new Date().toISOString().split("T")[0]}.csv"`,
|
||||
);
|
||||
res.send(csv);
|
||||
}
|
||||
|
||||
// ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
|
||||
|
||||
@Get("blocked-seats-revenue-loss")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IsString, IsDateString, IsOptional, IsEnum, IsInt, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { PaymentMethodType } from '@prisma/client';
|
||||
import { SeatBlockReasonCategory } from '../seats/seats.dto';
|
||||
|
||||
export enum ReportType {
|
||||
@@ -103,3 +104,31 @@ export class BlockedSeatsRevenueLossQueryDto {
|
||||
})
|
||||
@IsOptional() @IsEnum(BlockedSeatsLossSortBy) sortBy?: BlockedSeatsLossSortBy;
|
||||
}
|
||||
|
||||
// ── Finance Summary ──────────────────────────────────────────────────────────
|
||||
|
||||
export enum FinanceGranularity {
|
||||
DAILY = 'daily',
|
||||
WEEKLY = 'weekly',
|
||||
MONTHLY = 'monthly',
|
||||
}
|
||||
|
||||
export class FinanceSummaryQueryDto {
|
||||
@ApiProperty({ example: '2026-07-01', description: 'Start of the window, inclusive, matched on PaymentIntent.paidAt.' })
|
||||
@IsDateString() dateFrom: string;
|
||||
|
||||
@ApiProperty({ example: '2026-07-31', description: 'End of the window, inclusive, matched on PaymentIntent.paidAt.' })
|
||||
@IsDateString() dateTo: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: FinanceGranularity, default: FinanceGranularity.DAILY })
|
||||
@IsOptional() @IsEnum(FinanceGranularity) granularity?: FinanceGranularity;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Restrict to bookings departing from this station.' })
|
||||
@IsOptional() @IsString() originStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Restrict to bookings arriving at this station.' })
|
||||
@IsOptional() @IsString() destinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PaymentMethodType, description: 'Restrict to payments made with this method.' })
|
||||
@IsOptional() @IsEnum(PaymentMethodType) method?: PaymentMethodType;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { FareEngineService } from "../fare-engine/fare-engine.service";
|
||||
import {
|
||||
BlockedSeatsLossSortBy,
|
||||
BlockedSeatsRevenueLossQueryDto,
|
||||
FinanceGranularity,
|
||||
FinanceSummaryQueryDto,
|
||||
GenerateReportDto,
|
||||
ReportType,
|
||||
} from "./reports.dto";
|
||||
@@ -127,6 +129,42 @@ function toCsvCell(value: string | number): string {
|
||||
return `"${String(value).replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buckets a paid-at timestamp into the requested reporting period, keyed so buckets sort
|
||||
* chronologically as plain strings. Weekly buckets are labelled by their Monday (UTC).
|
||||
*/
|
||||
function periodKeyFor(date: Date, granularity: FinanceGranularity): string {
|
||||
if (granularity === FinanceGranularity.MONTHLY) {
|
||||
return date.toISOString().slice(0, 7);
|
||||
}
|
||||
if (granularity === FinanceGranularity.WEEKLY) {
|
||||
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||
const isoDay = d.getUTCDay() || 7; // Monday=1 .. Sunday=7
|
||||
d.setUTCDate(d.getUTCDate() - (isoDay - 1));
|
||||
return d.toISOString().split("T")[0];
|
||||
}
|
||||
return date.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
export interface FinanceBucket {
|
||||
period: string;
|
||||
originStationId: string;
|
||||
destinationStationId: string;
|
||||
segmentLabel: string;
|
||||
method: string;
|
||||
currency: string;
|
||||
bookingCount: number;
|
||||
revenueMinor: number;
|
||||
}
|
||||
|
||||
export interface FinanceRollupRow {
|
||||
key: string;
|
||||
label: string;
|
||||
currency: string;
|
||||
revenueMinor: number;
|
||||
bookingCount: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
private readonly logger = new Logger(ReportsService.name);
|
||||
@@ -1691,6 +1729,170 @@ export class ReportsService {
|
||||
return { totalActualEtbMinor, totalPaidEtbMinor, byMethod, rows };
|
||||
}
|
||||
|
||||
// ── Finance Summary ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Revenue collected in the window, grouped by reporting period (day/week/month), origin →
|
||||
* destination station pair, payment method, and currency — the shape finance reconciles
|
||||
* against provider settlement statements.
|
||||
*
|
||||
* Amounts are never converted to ETB. A Waafi payment settles in whatever currency Waafi
|
||||
* actually charged (DJF/USD), not an exchange-rate estimate of its ETB equivalent — so
|
||||
* filtering to one method shows exactly what that method collected, in its own currency,
|
||||
* and leaving every method selected lists each currency's total separately rather than
|
||||
* summing unlike currencies into one converted figure.
|
||||
*
|
||||
* The "actual" amount/currency is `displayTotalMinor`/`displayCurrency` when set, falling
|
||||
* back to `totalMinor`/`currency` — the same resolution `getPaymentDiscrepancyReport` and
|
||||
* `getPaymentsReport` use, because `Booking.currency` is often just the internal ETB
|
||||
* charge basis (many booking-creation paths hardcode it to ETB); the currency the
|
||||
* passenger was actually shown and charged in lives in the display fields.
|
||||
*
|
||||
* Grouped by the booking's own origin/destination, not the parent Route — a route like
|
||||
* "Sebeta - Dire Dawa" has intermediate stops, and a passenger may have booked any
|
||||
* sub-segment of it (e.g. Lebu → Adama). Filtering by station lets finance ask about any
|
||||
* A→B pair, not just whole routes.
|
||||
*
|
||||
* Bucketed on `PaymentIntent.paidAt` (cash actually received), not `Booking.createdAt`,
|
||||
* so a booking made in one period but paid in another lands in the period it was paid.
|
||||
*
|
||||
* Same revenue definition as `getBackofficeStats` and the `/payments` "confirmed revenue"
|
||||
* filter: `Booking.status` must still be CONFIRMED/BOARDED (a booking that was paid and
|
||||
* later cancelled is not revenue) and `PaymentIntent.status` must be SUCCEEDED, not just
|
||||
* carry a stale `paidAt` from before a cancellation.
|
||||
*/
|
||||
async getFinanceSummary(query: FinanceSummaryQueryDto) {
|
||||
const dateFrom = new Date(query.dateFrom + "T00:00:00.000Z");
|
||||
const dateTo = new Date(query.dateTo + "T23:59:59.999Z");
|
||||
const granularity = query.granularity ?? FinanceGranularity.DAILY;
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
// Same revenue definition as the dashboard's backoffice-stats and the /payments
|
||||
// "confirmed revenue" filter: the booking must still be CONFIRMED/BOARDED (a booking
|
||||
// that was paid and later cancelled is not revenue) and the payment itself must have
|
||||
// actually succeeded, not just carry a stale paidAt.
|
||||
status: { in: ["CONFIRMED", "BOARDED"] },
|
||||
paymentIntent: {
|
||||
status: "SUCCEEDED",
|
||||
paidAt: { gte: dateFrom, lte: dateTo },
|
||||
...(query.method ? { method: query.method } : {}),
|
||||
},
|
||||
...(query.originStationId ? { originStationId: query.originStationId } : {}),
|
||||
...(query.destinationStationId ? { destinationStationId: query.destinationStationId } : {}),
|
||||
},
|
||||
select: {
|
||||
totalMinor: true,
|
||||
currency: true,
|
||||
displayTotalMinor: true,
|
||||
displayCurrency: true,
|
||||
originStationId: true,
|
||||
destinationStationId: true,
|
||||
schedule: { select: { originStationId: true, destinationStationId: true } },
|
||||
paymentIntent: { select: { paidAt: true, method: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Booking.originStationId/destinationStationId are set on every create path (guest and
|
||||
// authenticated booking both pass them from the DTO); the schedule's own endpoints are
|
||||
// only a fallback for the rare legacy row that predates those columns.
|
||||
const stationIds = new Set<string>();
|
||||
for (const b of bookings) {
|
||||
const origin = b.originStationId ?? b.schedule.originStationId;
|
||||
const destination = b.destinationStationId ?? b.schedule.destinationStationId;
|
||||
if (origin) stationIds.add(origin);
|
||||
if (destination) stationIds.add(destination);
|
||||
}
|
||||
const stations = stationIds.size > 0
|
||||
? await this.prisma.station.findMany({ where: { id: { in: [...stationIds] } }, select: { id: true, name: true } })
|
||||
: [];
|
||||
const stationName = new Map(stations.map((s) => [s.id, s.name]));
|
||||
|
||||
const buckets = new Map<string, FinanceBucket>();
|
||||
const bucketFor = (
|
||||
period: string,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
segmentLabel: string,
|
||||
method: string,
|
||||
currency: string,
|
||||
): FinanceBucket => {
|
||||
const key = `${period}|${originStationId}|${destinationStationId}|${method}|${currency}`;
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { period, originStationId, destinationStationId, segmentLabel, method, currency, bookingCount: 0, revenueMinor: 0 };
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
return bucket;
|
||||
};
|
||||
|
||||
for (const b of bookings) {
|
||||
const pi = b.paymentIntent!;
|
||||
const period = periodKeyFor(pi.paidAt!, granularity);
|
||||
const originStationId = b.originStationId ?? b.schedule.originStationId ?? "UNKNOWN";
|
||||
const destinationStationId = b.destinationStationId ?? b.schedule.destinationStationId ?? "UNKNOWN";
|
||||
const segmentLabel = `${stationName.get(originStationId) ?? "Unknown"} → ${stationName.get(destinationStationId) ?? "Unknown"}`;
|
||||
const currency = (b.displayCurrency as string | null) ?? b.currency;
|
||||
const amountMinor = b.displayTotalMinor ?? b.totalMinor;
|
||||
const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, pi.method, currency);
|
||||
bucket.bookingCount += 1;
|
||||
bucket.revenueMinor += amountMinor;
|
||||
}
|
||||
|
||||
const rows = [...buckets.values()].sort((a, b) =>
|
||||
a.period === b.period
|
||||
? a.segmentLabel.localeCompare(b.segmentLabel) || a.method.localeCompare(b.method) || a.currency.localeCompare(b.currency)
|
||||
: a.period.localeCompare(b.period),
|
||||
);
|
||||
|
||||
const rollUp = (keyOf: (r: FinanceBucket) => string, labelOf: (r: FinanceBucket) => string): FinanceRollupRow[] => {
|
||||
const map = new Map<string, FinanceRollupRow>();
|
||||
for (const r of rows) {
|
||||
const key = keyOf(r);
|
||||
let entry = map.get(key);
|
||||
if (!entry) {
|
||||
entry = { key, label: labelOf(r), currency: r.currency, revenueMinor: 0, bookingCount: 0 };
|
||||
map.set(key, entry);
|
||||
}
|
||||
entry.revenueMinor += r.revenueMinor;
|
||||
entry.bookingCount += r.bookingCount;
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.revenueMinor - a.revenueMinor);
|
||||
};
|
||||
|
||||
// Currency is folded into every rollup key so amounts in different currencies are never
|
||||
// summed together — see class-level note on why this endpoint doesn't convert to ETB.
|
||||
const totals = rollUp((r) => r.currency, (r) => r.currency);
|
||||
|
||||
return {
|
||||
granularity,
|
||||
dateFrom: query.dateFrom,
|
||||
dateTo: query.dateTo,
|
||||
totals,
|
||||
byPeriod: rollUp((r) => `${r.period}|${r.currency}`, (r) => r.period),
|
||||
bySegment: rollUp((r) => `${r.originStationId}|${r.destinationStationId}|${r.currency}`, (r) => r.segmentLabel),
|
||||
byMethod: rollUp((r) => `${r.method}|${r.currency}`, (r) => r.method),
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
/** CSV of the finance summary, one row per period + origin/destination segment + payment method + currency. */
|
||||
async exportFinanceSummaryCsv(query: FinanceSummaryQueryDto): Promise<string> {
|
||||
const report = await this.getFinanceSummary(query);
|
||||
|
||||
const headers = ["Period", "Origin → Destination", "Payment Method", "Currency", "Bookings", "Revenue"];
|
||||
const rows = report.rows.map((r) => [
|
||||
r.period,
|
||||
r.segmentLabel,
|
||||
r.method,
|
||||
r.currency,
|
||||
r.bookingCount,
|
||||
(r.revenueMinor / 100).toFixed(2),
|
||||
]);
|
||||
|
||||
return [headers, ...rows].map((row) => row.map(toCsvCell).join(",")).join("\n");
|
||||
}
|
||||
|
||||
async getPaymentDiscrepancyBySchedule(scheduleId: string, params: {
|
||||
search?: string;
|
||||
seatClass?: string;
|
||||
|
||||
Reference in New Issue
Block a user