mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Updated financial report and sms outside of Ethiopia
This commit is contained in:
@@ -85,14 +85,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,
|
||||
) {
|
||||
@@ -100,6 +106,7 @@ export class PaymentsController {
|
||||
search,
|
||||
status,
|
||||
method,
|
||||
bookingStatus,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 10,
|
||||
});
|
||||
|
||||
@@ -99,10 +99,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 = {};
|
||||
@@ -118,6 +121,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({
|
||||
@@ -133,6 +142,7 @@ export class PaymentsService {
|
||||
childCount: true,
|
||||
totalMinor: true,
|
||||
currency: true,
|
||||
status: true,
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
},
|
||||
},
|
||||
@@ -172,6 +182,7 @@ export class PaymentsService {
|
||||
bookingRef: b?.bookingRef,
|
||||
totalMinor: b?.totalMinor,
|
||||
currency: b?.currency,
|
||||
status: b?.status,
|
||||
},
|
||||
amountMinor,
|
||||
currency: item.currency,
|
||||
|
||||
@@ -122,12 +122,16 @@ export class ReportsController {
|
||||
|
||||
@Get("finance")
|
||||
@ApiOperation({
|
||||
summary: "Finance summary — revenue by period, origin/destination segment, and payment method",
|
||||
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, and payment method. 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. " +
|
||||
"Returns per-bucket rows plus roll-ups by period, segment, and method for charting.",
|
||||
"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);
|
||||
|
||||
@@ -152,8 +152,17 @@ export interface FinanceBucket {
|
||||
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;
|
||||
revenueEtbMinor: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -1724,8 +1733,20 @@ export class ReportsService {
|
||||
|
||||
/**
|
||||
* Revenue collected in the window, grouped by reporting period (day/week/month), origin →
|
||||
* destination station pair, and payment method — the shape finance reconciles against
|
||||
* provider settlement statements.
|
||||
* 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
|
||||
@@ -1734,29 +1755,26 @@ export class ReportsService {
|
||||
*
|
||||
* 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 rateRows = await this.prisma.currencyExchangeRate.findMany({
|
||||
where: { toCurrency: "ETB" as any },
|
||||
orderBy: { effectiveDate: "desc" },
|
||||
});
|
||||
const rateToEtb = new Map<string, number>();
|
||||
for (const r of rateRows) {
|
||||
if (!rateToEtb.has(r.fromCurrency)) rateToEtb.set(r.fromCurrency, Number(r.rate));
|
||||
}
|
||||
const toEtbMinor = (minor: number, currency: string): number => {
|
||||
if (currency === "ETB") return minor;
|
||||
const rate = rateToEtb.get(currency);
|
||||
return rate ? Math.round(minor * rate) : minor;
|
||||
};
|
||||
|
||||
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 } : {}),
|
||||
},
|
||||
@@ -1766,6 +1784,8 @@ export class ReportsService {
|
||||
select: {
|
||||
totalMinor: true,
|
||||
currency: true,
|
||||
displayTotalMinor: true,
|
||||
displayCurrency: true,
|
||||
originStationId: true,
|
||||
destinationStationId: true,
|
||||
schedule: { select: { originStationId: true, destinationStationId: true } },
|
||||
@@ -1795,11 +1815,12 @@ export class ReportsService {
|
||||
destinationStationId: string,
|
||||
segmentLabel: string,
|
||||
method: string,
|
||||
currency: string,
|
||||
): FinanceBucket => {
|
||||
const key = `${period}|${originStationId}|${destinationStationId}|${method}`;
|
||||
const key = `${period}|${originStationId}|${destinationStationId}|${method}|${currency}`;
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { period, originStationId, destinationStationId, segmentLabel, method, bookingCount: 0, revenueEtbMinor: 0 };
|
||||
bucket = { period, originStationId, destinationStationId, segmentLabel, method, currency, bookingCount: 0, revenueMinor: 0 };
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
return bucket;
|
||||
@@ -1811,65 +1832,62 @@ export class ReportsService {
|
||||
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 bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, pi.method);
|
||||
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.revenueEtbMinor += toEtbMinor(b.totalMinor, b.currency);
|
||||
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.segmentLabel.localeCompare(b.segmentLabel) || a.method.localeCompare(b.method) || a.currency.localeCompare(b.currency)
|
||||
: a.period.localeCompare(b.period),
|
||||
);
|
||||
|
||||
const totals = rows.reduce(
|
||||
(acc, r) => {
|
||||
acc.bookingCount += r.bookingCount;
|
||||
acc.revenueEtbMinor += r.revenueEtbMinor;
|
||||
return acc;
|
||||
},
|
||||
{ bookingCount: 0, revenueEtbMinor: 0 },
|
||||
);
|
||||
|
||||
const rollUp = (keyOf: (r: FinanceBucket) => string, labelOf: (r: FinanceBucket) => string) => {
|
||||
const map = new Map<string, { key: string; label: string; revenueEtbMinor: number; bookingCount: number }>();
|
||||
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), revenueEtbMinor: 0, bookingCount: 0 };
|
||||
entry = { key, label: labelOf(r), currency: r.currency, revenueMinor: 0, bookingCount: 0 };
|
||||
map.set(key, entry);
|
||||
}
|
||||
entry.revenueEtbMinor += r.revenueEtbMinor;
|
||||
entry.revenueMinor += r.revenueMinor;
|
||||
entry.bookingCount += r.bookingCount;
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.revenueEtbMinor - a.revenueEtbMinor);
|
||||
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,
|
||||
currency: "ETB",
|
||||
totals,
|
||||
byPeriod: rollUp((r) => r.period, (r) => r.period),
|
||||
bySegment: rollUp((r) => `${r.originStationId}|${r.destinationStationId}`, (r) => r.segmentLabel),
|
||||
byMethod: rollUp((r) => r.method, (r) => r.method),
|
||||
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. */
|
||||
/** 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", "Bookings", "Revenue (ETB)"];
|
||||
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.revenueEtbMinor / 100).toFixed(2),
|
||||
(r.revenueMinor / 100).toFixed(2),
|
||||
]);
|
||||
|
||||
return [headers, ...rows].map((row) => row.map(toCsvCell).join(",")).join("\n");
|
||||
|
||||
Reference in New Issue
Block a user