fix(overview): stop non-ETB/USD revenue vanishing from the dashboards

Seven selects in overview.repository sum payments with a literal currency
filter, one column for ETB and one for USD:

  COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)

Anything else is summed nowhere. A DJF payment would not appear as a
separate figure — it would simply be absent from revenue MTD, the payment
trend, the per-method breakdown and the direction and freight-type splits,
with no error and nothing to notice.

Adds the third column at each site, the matching response fields, and the
tiles and stacked bar that render them. The payment chart's tooltip now
derives the label from the series key instead of a ternary on
"amountUsd", so it does not need touching again.

ponytail: a third hardcoded currency is still the shorter diff. A fourth
should force these seven into a GROUP BY payment.currency returning
IOverviewCurrencyAmount[] — the shape getRevenueByCurrency already uses a
few lines below.

Note the ordering dependency: the DJF enum label must exist before these
run. payments.currency is enum-typed, so `= 'DJF'` against a database
where the migration has not been applied is a runtime error, not an empty
result. All three query shapes were EXPLAIN-checked against the dev
database.
This commit is contained in:
Nathnael
2026-08-29 08:13:19 +00:00
parent df221a873d
commit 8299181261
7 changed files with 101 additions and 22 deletions

View File

@@ -262,9 +262,13 @@ export class OverviewRepository {
};
}
// ponytail: a third hardcoded currency is still a shorter diff than rewriting seven raw
// selects. A FOURTH should force these into `GROUP BY payment.currency` returning
// IOverviewCurrencyAmount[] — the shape getRevenueByCurrency below already uses.
async getBillingKpis(dirs?: string[]): Promise<{
revenueMtdEtb: number;
revenueMtdUsd: number;
revenueMtdDjf: number;
pendingPayments: number;
successfulPaymentsMtd: number;
}> {
@@ -279,6 +283,10 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"revenueMtdUsd",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
"revenueMtdDjf",
)
.addSelect(`COUNT(*)::int`, "successfulPaymentsMtd")
.where("payment.status = :status", { status: "success" })
.andWhere(
@@ -298,6 +306,7 @@ export class OverviewRepository {
return {
revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0),
revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0),
revenueMtdDjf: Number(revenueRow?.revenueMtdDjf ?? 0),
pendingPayments,
successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0),
};
@@ -370,7 +379,7 @@ export class OverviewRepository {
days: number,
dirs?: string[],
offsetDays = 0,
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
): Promise<{ date: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> {
const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository
.createQueryBuilder("payment")
@@ -386,6 +395,10 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"amountUsd",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
"amountDjf",
)
.where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND COALESCE(payment.paid_at, payment.created_at) < CURRENT_DATE - :offsetDays::int + 1`,
@@ -394,12 +407,13 @@ export class OverviewRepository {
.andWhere(scope.sql, scope.params)
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC")
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>();
.getRawMany<{ date: string; amountEtb: string; amountUsd: string; amountDjf: string }>();
return rows.map((row) => ({
date: row.date,
amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd),
amountDjf: Number(row.amountDjf),
}));
}
@@ -510,7 +524,7 @@ export class OverviewRepository {
async getPaymentsByMethod(
dirs?: string[],
): Promise<
{ method: string; count: number; amountEtb: number; amountUsd: number }[]
{ method: string; count: number; amountEtb: number; amountUsd: number; amountDjf: number }[]
> {
const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository
@@ -525,6 +539,10 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
"amountUsd",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF' AND payment.status = 'success'), 0)`,
"amountDjf",
)
.where(scope.sql, scope.params)
.groupBy("payment.method")
.orderBy("count", "DESC")
@@ -533,6 +551,7 @@ export class OverviewRepository {
count: string;
amountEtb: string;
amountUsd: string;
amountDjf: string;
}>();
return rows.map((row) => ({
@@ -540,6 +559,7 @@ export class OverviewRepository {
count: Number(row.count),
amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd),
amountDjf: Number(row.amountDjf),
}));
}
@@ -580,6 +600,7 @@ export class OverviewRepository {
bookingsCreated: number;
revenueEtb: number;
revenueUsd: number;
revenueDjf: number;
tons: number;
}> {
const bookingScope = directionScopeSql("booking.trade_direction", dirs);
@@ -605,13 +626,17 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"revenueUsd",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
"revenueDjf",
)
.where("payment.status = :status", { status: "success" })
.andWhere(
windowSql("COALESCE(payment.paid_at, payment.created_at)"),
{ days, offsetDays },
)
.andWhere(paymentScope.sql, paymentScope.params)
.getRawOne<{ revenueEtb: string; revenueUsd: string }>(),
.getRawOne<{ revenueEtb: string; revenueUsd: string; revenueDjf: string }>(),
this.cargoRepository
.createQueryBuilder("cargo")
.leftJoin(Booking, "booking", "booking.id = cargo.booking_id")
@@ -626,6 +651,7 @@ export class OverviewRepository {
bookingsCreated,
revenueEtb: Number(revenueRow?.revenueEtb ?? 0),
revenueUsd: Number(revenueRow?.revenueUsd ?? 0),
revenueDjf: Number(revenueRow?.revenueDjf ?? 0),
tons: Number(tonsRow?.tons ?? 0),
};
}
@@ -634,7 +660,7 @@ export class OverviewRepository {
async getRevenueByDirection(
days: number,
dirs?: string[],
): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> {
): Promise<{ label: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> {
const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository
.createQueryBuilder("payment")
@@ -648,6 +674,10 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"amountUsd",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
"amountDjf",
)
.where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
@@ -656,12 +686,13 @@ export class OverviewRepository {
.andWhere(scope.sql, scope.params)
.andWhere("booking.trade_direction IS NOT NULL")
.groupBy("booking.trade_direction")
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>();
.getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>();
return rows.map((row) => ({
label: row.label,
amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd),
amountDjf: Number(row.amountDjf),
}));
}
@@ -669,7 +700,7 @@ export class OverviewRepository {
async getRevenueByFreightType(
days: number,
dirs?: string[],
): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> {
): Promise<{ label: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> {
const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository
.createQueryBuilder("payment")
@@ -683,6 +714,10 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"amountUsd",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
"amountDjf",
)
.where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
@@ -691,12 +726,13 @@ export class OverviewRepository {
.andWhere(scope.sql, scope.params)
.andWhere("booking.freight_type IS NOT NULL")
.groupBy("booking.freight_type")
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>();
.getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>();
return rows.map((row) => ({
label: row.label,
amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd),
amountDjf: Number(row.amountDjf),
}));
}
@@ -734,6 +770,7 @@ export class OverviewRepository {
freightType: string;
amountEtb: number;
amountUsd: number;
amountDjf: number;
}[]
> {
const scope = bookingRefScopeSql("payment.ref_id", dirs);
@@ -750,6 +787,10 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
"amountUsd",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
"amountDjf",
)
.where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
@@ -765,6 +806,7 @@ export class OverviewRepository {
freightType: string;
amountEtb: string;
amountUsd: string;
amountDjf: string;
}>();
return rows.map((row) => ({
@@ -772,6 +814,7 @@ export class OverviewRepository {
freightType: row.freightType,
amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd),
amountDjf: Number(row.amountDjf),
}));
}

View File

@@ -1,3 +1,4 @@
import { isPaymentCurrency, type PaymentCurrency } from "@edr/types";
import {
Bar,
BarChart,
@@ -18,7 +19,7 @@ function formatDateLabel(date: string) {
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
function formatAmount(value: number, currency: "ETB" | "USD") {
function formatAmount(value: number, currency: PaymentCurrency) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
@@ -27,7 +28,9 @@ function formatAmount(value: number, currency: "ETB" | "USD") {
}
export function OverviewPaymentChart({ data }: { data: IOverviewPaymentTrendPoint[] }) {
const hasData = data.some((point) => point.amountEtb > 0 || point.amountUsd > 0);
const hasData = data.some(
(point) => point.amountEtb > 0 || point.amountUsd > 0 || point.amountDjf > 0,
);
return (
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
@@ -50,10 +53,18 @@ export function OverviewPaymentChart({ data }: { data: IOverviewPaymentTrendPoin
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" />
<Tooltip
labelFormatter={(value) => formatDateLabel(String(value))}
formatter={(value, name) => [
formatAmount(Number(value), name === "amountUsd" ? "USD" : "ETB"),
name === "amountUsd" ? "USD" : "ETB",
]}
formatter={(value, name) => {
// Series keys are amountEtb / amountUsd / amountDjf — the label is the
// suffix, so a new currency series needs no change here.
const currency = String(name).slice("amount".length).toUpperCase();
return [
formatAmount(
Number(value),
isPaymentCurrency(currency) ? currency : "ETB",
),
currency,
];
}}
/>
<Legend />
<Bar
@@ -68,6 +79,13 @@ export function OverviewPaymentChart({ data }: { data: IOverviewPaymentTrendPoin
name="USD"
stackId="payments"
fill={overviewChartColors.usd}
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="amountDjf"
name="DJF"
stackId="payments"
fill={overviewChartColors.djf}
radius={[4, 4, 0, 0]}
/>
</BarChart>

View File

@@ -7,6 +7,7 @@ export const overviewChartColors = {
muted: freightBrand.mutedBg,
etb: "#F2A516",
usd: "#0369a1",
djf: "#7c3aed",
/** Vibrant, well-separated categorical palette for charts (gold-forward). */
pipeline: [
"#F2A516", // gold (brand accent)

View File

@@ -1,10 +1,11 @@
import type { PaymentCurrency } from "@edr/types";
import { Banknote, FileSignature, FileText, Package } from "lucide-react";
import { KpiStrip, type KpiItem } from "@/components/page";
import type { IOverviewKpis, IOverviewPeriodTotals } from "@/types/overview";
import { CountUp } from "./CountUp";
function formatCurrency(amount: number, currency: "ETB" | "USD") {
function formatCurrency(amount: number, currency: PaymentCurrency) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
@@ -51,7 +52,11 @@ export function OverviewHeroKpis({ kpis, current, previous, rangeLabel }: Overvi
{
label: `Revenue (${rangeLabel})`,
value: <CountUp value={current.revenueEtb} format={(n) => formatCompactCurrency(n, "ETB")} />,
hint: formatCurrency(current.revenueUsd, "USD"),
// Both non-ETB currencies, so DJF revenue is not simply invisible here.
hint: [
formatCurrency(current.revenueUsd, "USD"),
...(current.revenueDjf ? [formatCurrency(current.revenueDjf, "DJF")] : []),
].join(" · "),
icon: Banknote,
color: "yellow",
delta: pctDelta(current.revenueEtb, previous?.revenueEtb ?? 0),

View File

@@ -9,7 +9,7 @@ describe("mergeTrend", () => {
{ date: "2026-06-01", count: 3 },
{ date: "2026-06-02", count: 5 },
],
[{ date: "2026-06-02", amountEtb: 1000, amountUsd: 0 }],
[{ date: "2026-06-02", amountEtb: 1000, amountUsd: 0, amountDjf: 0 }],
);
expect(result).toEqual([
@@ -21,7 +21,7 @@ describe("mergeTrend", () => {
it("sorts chronologically regardless of input order", () => {
const result = mergeTrend(
[{ date: "2026-06-03", count: 1 }],
[{ date: "2026-06-01", amountEtb: 500, amountUsd: 0 }],
[{ date: "2026-06-01", amountEtb: 500, amountUsd: 0, amountDjf: 0 }],
);
expect(result.map((p) => p.date)).toEqual(["2026-06-01", "2026-06-03"]);
@@ -37,8 +37,8 @@ describe("mergeTrend", () => {
[],
[
// 7d range: 2026-06-01 + 7 = 2026-06-08 (existing row), 06-02 + 7 = 06-09 (new row)
{ date: "2026-06-01", amountEtb: 400, amountUsd: 0 },
{ date: "2026-06-02", amountEtb: 250, amountUsd: 0 },
{ date: "2026-06-01", amountEtb: 400, amountUsd: 0, amountDjf: 0 },
{ date: "2026-06-02", amountEtb: 250, amountUsd: 0, amountDjf: 0 },
],
7,
);
@@ -50,7 +50,7 @@ describe("mergeTrend", () => {
});
it("shifts across a month boundary without timezone drift", () => {
const result = mergeTrend([], [], [{ date: "2026-05-28", amountEtb: 100, amountUsd: 0 }], 7);
const result = mergeTrend([], [], [{ date: "2026-05-28", amountEtb: 100, amountUsd: 0, amountDjf: 0 }], 7);
expect(result[0].date).toBe("2026-06-04");
});
});

View File

@@ -1,3 +1,4 @@
import type { PaymentCurrency } from "@edr/types";
import { Banknote, CreditCard, Wallet } from "lucide-react";
import {
Bar,
@@ -18,7 +19,7 @@ import { OverviewKpiStrip } from "../OverviewKpiStrip";
import { OverviewPaymentChart } from "../OverviewPaymentChart";
import { overviewChartColors } from "../overview.styles";
function formatCurrency(amount: number, currency: "ETB" | "USD") {
function formatCurrency(amount: number, currency: PaymentCurrency) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
@@ -57,6 +58,11 @@ export function OverviewBillingTabPanel({ data }: OverviewBillingTabPanelProps)
value: formatCurrency(data.kpis.revenueMtdUsd, "USD"),
icon: Wallet,
},
{
label: "Revenue MTD (DJF)",
value: formatCurrency(data.kpis.revenueMtdDjf, "DJF"),
icon: Wallet,
},
{
label: "Pending payments",
value: data.kpis.pendingPayments,

View File

@@ -28,6 +28,7 @@ export interface IOverviewCustomerKpis {
export interface IOverviewBillingKpis {
revenueMtdEtb: number;
revenueMtdUsd: number;
revenueMtdDjf: number;
pendingPayments: number;
successfulPaymentsMtd: number;
}
@@ -75,6 +76,7 @@ export interface IOverviewPaymentTrendPoint {
date: string;
amountEtb: number;
amountUsd: number;
amountDjf: number;
}
export interface IOverviewRecentBooking {
@@ -105,6 +107,7 @@ export interface IOverviewPeriodTotals {
bookingsCreated: number;
revenueEtb: number;
revenueUsd: number;
revenueDjf: number;
tons: number;
}
@@ -113,6 +116,7 @@ export interface IOverviewRevenueSlice {
label: string;
amountEtb: number;
amountUsd: number;
amountDjf: number;
}
export interface IOverviewTonsTrendPoint {
@@ -126,6 +130,7 @@ export interface IOverviewRevenueFlow {
freightType: string;
amountEtb: number;
amountUsd: number;
amountDjf: number;
}
/** Booking arrivals for one weekday × 3-hour block. */
@@ -167,6 +172,7 @@ export interface IOverviewPaymentMethodBreakdown {
count: number;
amountEtb: number;
amountUsd: number;
amountDjf: number;
}
export interface IOverviewCurrencyAmount {