@voyant-travel/finance 0.119.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +192 -0
- package/dist/action-ledger-drift.d.ts +29 -0
- package/dist/action-ledger-drift.d.ts.map +1 -0
- package/dist/action-ledger-drift.js +166 -0
- package/dist/booking-tax.d.ts +124 -0
- package/dist/booking-tax.d.ts.map +1 -0
- package/dist/booking-tax.js +264 -0
- package/dist/checkout-routes.d.ts +1154 -0
- package/dist/checkout-routes.d.ts.map +1 -0
- package/dist/checkout-routes.js +116 -0
- package/dist/checkout-service-plan.d.ts +137 -0
- package/dist/checkout-service-plan.d.ts.map +1 -0
- package/dist/checkout-service-plan.js +119 -0
- package/dist/checkout-service.d.ts +9 -0
- package/dist/checkout-service.d.ts.map +1 -0
- package/dist/checkout-service.js +324 -0
- package/dist/checkout-validation.d.ts +1682 -0
- package/dist/checkout-validation.d.ts.map +1 -0
- package/dist/checkout-validation.js +228 -0
- package/dist/document-download.d.ts +3 -0
- package/dist/document-download.d.ts.map +1 -0
- package/dist/document-download.js +1 -0
- package/dist/fx-money.d.ts +17 -0
- package/dist/fx-money.d.ts.map +1 -0
- package/dist/fx-money.js +194 -0
- package/dist/index.d.ts +65 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +108 -0
- package/dist/invoice-fx.d.ts +134 -0
- package/dist/invoice-fx.d.ts.map +1 -0
- package/dist/invoice-fx.js +240 -0
- package/dist/invoice-number-errors.d.ts +2 -0
- package/dist/invoice-number-errors.d.ts.map +1 -0
- package/dist/invoice-number-errors.js +58 -0
- package/dist/markets-ref.d.ts +149 -0
- package/dist/markets-ref.d.ts.map +1 -0
- package/dist/markets-ref.js +17 -0
- package/dist/payment-link.d.ts +23 -0
- package/dist/payment-link.d.ts.map +1 -0
- package/dist/payment-link.js +30 -0
- package/dist/payment-policy.d.ts +113 -0
- package/dist/payment-policy.d.ts.map +1 -0
- package/dist/payment-policy.js +193 -0
- package/dist/route-runtime.d.ts +22 -0
- package/dist/route-runtime.d.ts.map +1 -0
- package/dist/route-runtime.js +18 -0
- package/dist/routes-action-ledger.d.ts +181 -0
- package/dist/routes-action-ledger.d.ts.map +1 -0
- package/dist/routes-action-ledger.js +142 -0
- package/dist/routes-booking-billing.d.ts +852 -0
- package/dist/routes-booking-billing.d.ts.map +1 -0
- package/dist/routes-booking-billing.js +223 -0
- package/dist/routes-booking-create.d.ts +3 -0
- package/dist/routes-booking-create.d.ts.map +1 -0
- package/dist/routes-booking-create.js +194 -0
- package/dist/routes-booking-reads.d.ts +46 -0
- package/dist/routes-booking-reads.d.ts.map +1 -0
- package/dist/routes-booking-reads.js +20 -0
- package/dist/routes-documents.d.ts +195 -0
- package/dist/routes-documents.d.ts.map +1 -0
- package/dist/routes-documents.js +93 -0
- package/dist/routes-invoice-core.d.ts +794 -0
- package/dist/routes-invoice-core.d.ts.map +1 -0
- package/dist/routes-invoice-core.js +238 -0
- package/dist/routes-invoice-documents.d.ts +401 -0
- package/dist/routes-invoice-documents.d.ts.map +1 -0
- package/dist/routes-invoice-documents.js +91 -0
- package/dist/routes-invoice-issue.d.ts +384 -0
- package/dist/routes-invoice-issue.d.ts.map +1 -0
- package/dist/routes-invoice-issue.js +208 -0
- package/dist/routes-payment-processing.d.ts +1193 -0
- package/dist/routes-payment-processing.d.ts.map +1 -0
- package/dist/routes-payment-processing.js +238 -0
- package/dist/routes-payments.d.ts +309 -0
- package/dist/routes-payments.d.ts.map +1 -0
- package/dist/routes-payments.js +94 -0
- package/dist/routes-public.d.ts +1948 -0
- package/dist/routes-public.d.ts.map +1 -0
- package/dist/routes-public.js +275 -0
- package/dist/routes-reference-data.d.ts +977 -0
- package/dist/routes-reference-data.d.ts.map +1 -0
- package/dist/routes-reference-data.js +191 -0
- package/dist/routes-reports.d.ts +344 -0
- package/dist/routes-reports.d.ts.map +1 -0
- package/dist/routes-reports.js +93 -0
- package/dist/routes-runtime.d.ts +71 -0
- package/dist/routes-runtime.d.ts.map +1 -0
- package/dist/routes-runtime.js +59 -0
- package/dist/routes-settlement.d.ts +67 -0
- package/dist/routes-settlement.d.ts.map +1 -0
- package/dist/routes-settlement.js +23 -0
- package/dist/routes-shared.d.ts +35 -0
- package/dist/routes-shared.d.ts.map +1 -0
- package/dist/routes-shared.js +10 -0
- package/dist/routes-supplier-invoices.d.ts +778 -0
- package/dist/routes-supplier-invoices.d.ts.map +1 -0
- package/dist/routes-supplier-invoices.js +159 -0
- package/dist/routes-vouchers.d.ts +228 -0
- package/dist/routes-vouchers.d.ts.map +1 -0
- package/dist/routes-vouchers.js +54 -0
- package/dist/routes.d.ts +5577 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +44 -0
- package/dist/schema/booking-billing.d.ts +1006 -0
- package/dist/schema/booking-billing.d.ts.map +1 -0
- package/dist/schema/booking-billing.js +106 -0
- package/dist/schema/enums.d.ts +48 -0
- package/dist/schema/enums.d.ts.map +1 -0
- package/dist/schema/enums.js +237 -0
- package/dist/schema/invoice-documents.d.ts +1245 -0
- package/dist/schema/invoice-documents.d.ts.map +1 -0
- package/dist/schema/invoice-documents.js +140 -0
- package/dist/schema/payment-instruments.d.ts +418 -0
- package/dist/schema/payment-instruments.d.ts.map +1 -0
- package/dist/schema/payment-instruments.js +45 -0
- package/dist/schema/payment-processing.d.ts +563 -0
- package/dist/schema/payment-processing.d.ts.map +1 -0
- package/dist/schema/payment-processing.js +65 -0
- package/dist/schema/payment-sessions.d.ts +728 -0
- package/dist/schema/payment-sessions.d.ts.map +1 -0
- package/dist/schema/payment-sessions.js +79 -0
- package/dist/schema/receivables.d.ts +1474 -0
- package/dist/schema/receivables.d.ts.map +1 -0
- package/dist/schema/receivables.js +179 -0
- package/dist/schema/relations.d.ts +82 -0
- package/dist/schema/relations.d.ts.map +1 -0
- package/dist/schema/relations.js +144 -0
- package/dist/schema/supplier-invoices.d.ts +1619 -0
- package/dist/schema/supplier-invoices.d.ts.map +1 -0
- package/dist/schema/supplier-invoices.js +228 -0
- package/dist/schema/tax.d.ts +712 -0
- package/dist/schema/tax.d.ts.map +1 -0
- package/dist/schema/tax.js +98 -0
- package/dist/schema/vouchers.d.ts +444 -0
- package/dist/schema/vouchers.d.ts.map +1 -0
- package/dist/schema/vouchers.js +64 -0
- package/dist/schema.d.ts +12 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +11 -0
- package/dist/service-accountant-shares.d.ts +106 -0
- package/dist/service-accountant-shares.d.ts.map +1 -0
- package/dist/service-accountant-shares.js +331 -0
- package/dist/service-action-ledger-accounting.d.ts +104 -0
- package/dist/service-action-ledger-accounting.d.ts.map +1 -0
- package/dist/service-action-ledger-accounting.js +386 -0
- package/dist/service-action-ledger-booking-payments.d.ts +48 -0
- package/dist/service-action-ledger-booking-payments.d.ts.map +1 -0
- package/dist/service-action-ledger-booking-payments.js +178 -0
- package/dist/service-action-ledger-bookings.d.ts +44 -0
- package/dist/service-action-ledger-bookings.d.ts.map +1 -0
- package/dist/service-action-ledger-bookings.js +81 -0
- package/dist/service-action-ledger-payment-authorizations.d.ts +48 -0
- package/dist/service-action-ledger-payment-authorizations.d.ts.map +1 -0
- package/dist/service-action-ledger-payment-authorizations.js +209 -0
- package/dist/service-action-ledger-payment-sessions.d.ts +83 -0
- package/dist/service-action-ledger-payment-sessions.d.ts.map +1 -0
- package/dist/service-action-ledger-payment-sessions.js +294 -0
- package/dist/service-action-ledger-supplier-invoices.d.ts +27 -0
- package/dist/service-action-ledger-supplier-invoices.d.ts.map +1 -0
- package/dist/service-action-ledger-supplier-invoices.js +111 -0
- package/dist/service-action-ledger-supplier-payments.d.ts +21 -0
- package/dist/service-action-ledger-supplier-payments.d.ts.map +1 -0
- package/dist/service-action-ledger-supplier-payments.js +97 -0
- package/dist/service-action-ledger.d.ts +7 -0
- package/dist/service-action-ledger.d.ts.map +1 -0
- package/dist/service-action-ledger.js +6 -0
- package/dist/service-aggregates.d.ts +96 -0
- package/dist/service-aggregates.d.ts.map +1 -0
- package/dist/service-aggregates.js +294 -0
- package/dist/service-booking-billing.d.ts +2322 -0
- package/dist/service-booking-billing.d.ts.map +1 -0
- package/dist/service-booking-billing.js +8 -0
- package/dist/service-booking-create.d.ts +410 -0
- package/dist/service-booking-create.d.ts.map +1 -0
- package/dist/service-booking-create.js +1256 -0
- package/dist/service-booking-guarantees.d.ts +725 -0
- package/dist/service-booking-guarantees.d.ts.map +1 -0
- package/dist/service-booking-guarantees.js +153 -0
- package/dist/service-booking-item-billing.d.ts +1062 -0
- package/dist/service-booking-item-billing.d.ts.map +1 -0
- package/dist/service-booking-item-billing.js +77 -0
- package/dist/service-booking-payment-schedules.d.ts +557 -0
- package/dist/service-booking-payment-schedules.d.ts.map +1 -0
- package/dist/service-booking-payment-schedules.js +372 -0
- package/dist/service-bookings-dual-create.d.ts +308 -0
- package/dist/service-bookings-dual-create.d.ts.map +1 -0
- package/dist/service-bookings-dual-create.js +131 -0
- package/dist/service-boundary-sql.d.ts +6 -0
- package/dist/service-boundary-sql.d.ts.map +1 -0
- package/dist/service-boundary-sql.js +15 -0
- package/dist/service-cost-categories.d.ts +26 -0
- package/dist/service-cost-categories.d.ts.map +1 -0
- package/dist/service-cost-categories.js +76 -0
- package/dist/service-documents.d.ts +80 -0
- package/dist/service-documents.d.ts.map +1 -0
- package/dist/service-documents.js +228 -0
- package/dist/service-invoice-artifacts.d.ts +246 -0
- package/dist/service-invoice-artifacts.d.ts.map +1 -0
- package/dist/service-invoice-artifacts.js +277 -0
- package/dist/service-invoice-core.d.ts +405 -0
- package/dist/service-invoice-core.d.ts.map +1 -0
- package/dist/service-invoice-core.js +290 -0
- package/dist/service-invoice-credit-notes.d.ts +973 -0
- package/dist/service-invoice-credit-notes.d.ts.map +1 -0
- package/dist/service-invoice-credit-notes.js +142 -0
- package/dist/service-invoice-from-booking.d.ts +41 -0
- package/dist/service-invoice-from-booking.d.ts.map +1 -0
- package/dist/service-invoice-from-booking.js +267 -0
- package/dist/service-invoice-line-items.d.ts +432 -0
- package/dist/service-invoice-line-items.d.ts.map +1 -0
- package/dist/service-invoice-line-items.js +102 -0
- package/dist/service-invoice-numbering.d.ts +227 -0
- package/dist/service-invoice-numbering.d.ts.map +1 -0
- package/dist/service-invoice-numbering.js +260 -0
- package/dist/service-invoice-payments.d.ts +673 -0
- package/dist/service-invoice-payments.d.ts.map +1 -0
- package/dist/service-invoice-payments.js +398 -0
- package/dist/service-invoices.d.ts +2501 -0
- package/dist/service-invoices.d.ts.map +1 -0
- package/dist/service-invoices.js +12 -0
- package/dist/service-issue.d.ts +207 -0
- package/dist/service-issue.d.ts.map +1 -0
- package/dist/service-issue.js +431 -0
- package/dist/service-payment-authorizations.d.ts +164 -0
- package/dist/service-payment-authorizations.d.ts.map +1 -0
- package/dist/service-payment-authorizations.js +227 -0
- package/dist/service-payment-instruments.d.ts +116 -0
- package/dist/service-payment-instruments.d.ts.map +1 -0
- package/dist/service-payment-instruments.js +99 -0
- package/dist/service-payment-processing.d.ts +676 -0
- package/dist/service-payment-processing.d.ts.map +1 -0
- package/dist/service-payment-processing.js +10 -0
- package/dist/service-payment-session-completion.d.ts +48 -0
- package/dist/service-payment-session-completion.d.ts.map +1 -0
- package/dist/service-payment-session-completion.js +238 -0
- package/dist/service-payment-sessions.d.ts +361 -0
- package/dist/service-payment-sessions.d.ts.map +1 -0
- package/dist/service-payment-sessions.js +280 -0
- package/dist/service-profitability.d.ts +114 -0
- package/dist/service-profitability.d.ts.map +1 -0
- package/dist/service-profitability.js +794 -0
- package/dist/service-public.d.ts +553 -0
- package/dist/service-public.d.ts.map +1 -0
- package/dist/service-public.js +583 -0
- package/dist/service-reference-data.d.ts +272 -0
- package/dist/service-reference-data.d.ts.map +1 -0
- package/dist/service-reference-data.js +280 -0
- package/dist/service-rendition-wait.d.ts +38 -0
- package/dist/service-rendition-wait.d.ts.map +1 -0
- package/dist/service-rendition-wait.js +67 -0
- package/dist/service-reports.d.ts +37 -0
- package/dist/service-reports.d.ts.map +1 -0
- package/dist/service-reports.js +62 -0
- package/dist/service-settlement.d.ts +46 -0
- package/dist/service-settlement.d.ts.map +1 -0
- package/dist/service-settlement.js +185 -0
- package/dist/service-shared.d.ts +541 -0
- package/dist/service-shared.d.ts.map +1 -0
- package/dist/service-shared.js +764 -0
- package/dist/service-supplier-invoices.d.ts +871 -0
- package/dist/service-supplier-invoices.d.ts.map +1 -0
- package/dist/service-supplier-invoices.js +744 -0
- package/dist/service-supplier-payments.d.ts +69 -0
- package/dist/service-supplier-payments.d.ts.map +1 -0
- package/dist/service-supplier-payments.js +136 -0
- package/dist/service-vouchers-migration.d.ts +44 -0
- package/dist/service-vouchers-migration.d.ts.map +1 -0
- package/dist/service-vouchers-migration.js +148 -0
- package/dist/service-vouchers.d.ts +157 -0
- package/dist/service-vouchers.d.ts.map +1 -0
- package/dist/service-vouchers.js +191 -0
- package/dist/service.d.ts +6490 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +29 -0
- package/dist/validation-billing.d.ts +2 -0
- package/dist/validation-billing.d.ts.map +1 -0
- package/dist/validation-billing.js +1 -0
- package/dist/validation-payments.d.ts +2 -0
- package/dist/validation-payments.d.ts.map +1 -0
- package/dist/validation-payments.js +1 -0
- package/dist/validation-public.d.ts +2 -0
- package/dist/validation-public.d.ts.map +1 -0
- package/dist/validation-public.js +1 -0
- package/dist/validation-shared.d.ts +2 -0
- package/dist/validation-shared.d.ts.map +1 -0
- package/dist/validation-shared.js +1 -0
- package/dist/validation-vouchers.d.ts +2 -0
- package/dist/validation-vouchers.d.ts.map +1 -0
- package/dist/validation-vouchers.js +1 -0
- package/dist/validation.d.ts +2 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +1 -0
- package/package.json +121 -0
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
// agent-quality: file-size exception -- owner: finance; existing service module stays co-located until a dedicated split preserves behavior and tests.
|
|
2
|
+
import { bookingItems, bookingTravelers } from "@voyant-travel/bookings/schema";
|
|
3
|
+
import { and, eq, inArray, isNotNull, isNull, ne, sql } from "drizzle-orm";
|
|
4
|
+
import { resolveFxMoneyBaseAmount } from "./fx-money.js";
|
|
5
|
+
import { resolveInvoiceFxSettingsOrDefault } from "./invoice-fx.js";
|
|
6
|
+
import { costCategories, invoices, supplierCostAllocations, supplierInvoiceLines, supplierInvoices, } from "./schema.js";
|
|
7
|
+
import { executeBoundaryRows, normalizeDateOnly, sqlList } from "./service-boundary-sql.js";
|
|
8
|
+
const num = (value) => Number(value ?? 0);
|
|
9
|
+
function bucket(map, currency) {
|
|
10
|
+
let entry = map.get(currency);
|
|
11
|
+
if (!entry) {
|
|
12
|
+
entry = { revenue: 0, actual: 0, planned: 0 };
|
|
13
|
+
map.set(currency, entry);
|
|
14
|
+
}
|
|
15
|
+
return entry;
|
|
16
|
+
}
|
|
17
|
+
function newBaseSplit() {
|
|
18
|
+
return { snapshotBase: 0, residual: new Map() };
|
|
19
|
+
}
|
|
20
|
+
function addResidual(map, currency, amount) {
|
|
21
|
+
if (amount === 0)
|
|
22
|
+
return;
|
|
23
|
+
map.set(currency, (map.get(currency) ?? 0) + amount);
|
|
24
|
+
}
|
|
25
|
+
/** Resolve a base split to a single accounting-base figure via the rate map. */
|
|
26
|
+
function resolveBaseSplit(split, rates, unconvertible) {
|
|
27
|
+
let total = split.snapshotBase;
|
|
28
|
+
for (const [currency, amount] of split.residual) {
|
|
29
|
+
const rate = rates.get(currency);
|
|
30
|
+
if (rate == null) {
|
|
31
|
+
if (amount !== 0)
|
|
32
|
+
unconvertible.add(currency);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
total += amount * rate;
|
|
36
|
+
}
|
|
37
|
+
return total;
|
|
38
|
+
}
|
|
39
|
+
function margin(profitCents, revenueCents) {
|
|
40
|
+
if (revenueCents <= 0)
|
|
41
|
+
return null;
|
|
42
|
+
return Math.round((profitCents / revenueCents) * 1000) / 10;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Shared loader: runs the source queries once and assembles a per-departure
|
|
46
|
+
* accumulator plus the cost-breakdown aggregates that both reports surface.
|
|
47
|
+
*
|
|
48
|
+
* Each money source carries TWO base figures: the sum of recorded base snapshots
|
|
49
|
+
* (already in `baseCurrency`, summed verbatim) and the original-currency residual
|
|
50
|
+
* from rows lacking a snapshot (converted later via fallback rates). This keeps
|
|
51
|
+
* the rollup faithful to the rate that was in effect when each invoice was
|
|
52
|
+
* issued, instead of re-valuing everything at the latest rate.
|
|
53
|
+
*/
|
|
54
|
+
async function loadDepartureAccumulators(db, baseCurrency) {
|
|
55
|
+
const base = baseCurrency;
|
|
56
|
+
// Net sign for AR (credit notes subtract). Reused for total + base + residual.
|
|
57
|
+
// agent-quality: raw-sql reviewed -- owner: finance; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
58
|
+
const arSign = sql `case when ${invoices.invoiceType} = 'credit_note' then -1 else 1 end`;
|
|
59
|
+
// True when an invoice/allocation has a usable base snapshot in the accounting base.
|
|
60
|
+
// agent-quality: raw-sql reviewed -- owner: finance; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
61
|
+
const invSnapshotted = sql `${invoices.baseCurrency} = ${base} and ${invoices.baseTotalCents} is not null`;
|
|
62
|
+
// agent-quality: raw-sql reviewed -- owner: finance; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
63
|
+
const allocSnapshotted = sql `${supplierInvoices.baseCurrency} = ${base} and ${supplierCostAllocations.baseAmountCents} is not null`;
|
|
64
|
+
// agent-quality: raw-sql reviewed -- owner: finance; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
65
|
+
const lineSnapshotted = sql `${supplierInvoices.baseCurrency} = ${base} and ${supplierInvoices.baseTotalCents} is not null and ${supplierInvoices.totalCents} <> 0`;
|
|
66
|
+
const [itemRows, invoiceRows, departureCostRows, productCostRows, serviceTypeRows, unattributedRows,] = await Promise.all([
|
|
67
|
+
// Booked sell + planned cost per (departure, booking), with display snapshots.
|
|
68
|
+
db
|
|
69
|
+
.select({
|
|
70
|
+
departureId: bookingItems.availabilitySlotId,
|
|
71
|
+
bookingId: bookingItems.bookingId,
|
|
72
|
+
productId: sql `max(${bookingItems.productId})`,
|
|
73
|
+
productName: sql `max(${bookingItems.productNameSnapshot})`,
|
|
74
|
+
departureLabel: sql `max(${bookingItems.departureLabelSnapshot})`,
|
|
75
|
+
departureDate: sql `to_char(min(${bookingItems.startsAt}) at time zone 'UTC', 'YYYY-MM-DD')`,
|
|
76
|
+
sellCurrency: bookingItems.sellCurrency,
|
|
77
|
+
sellCents: sql `coalesce(sum(${bookingItems.totalSellAmountCents}), 0)::bigint`,
|
|
78
|
+
costCurrency: bookingItems.costCurrency,
|
|
79
|
+
plannedCostCents: sql `coalesce(sum(${bookingItems.totalCostAmountCents}), 0)::bigint`,
|
|
80
|
+
})
|
|
81
|
+
.from(bookingItems)
|
|
82
|
+
.where(isNotNull(bookingItems.availabilitySlotId))
|
|
83
|
+
.groupBy(bookingItems.availabilitySlotId, bookingItems.bookingId, bookingItems.sellCurrency, bookingItems.costCurrency),
|
|
84
|
+
// Invoiced AR per (booking, currency). Credit notes net down; proforma/draft/void excluded.
|
|
85
|
+
// `baseTotalCents` sums recorded base snapshots; `residualTotalCents` is the
|
|
86
|
+
// original-currency remainder from invoices without one.
|
|
87
|
+
db
|
|
88
|
+
.select({
|
|
89
|
+
bookingId: invoices.bookingId,
|
|
90
|
+
currency: invoices.currency,
|
|
91
|
+
totalCents: sql `coalesce(sum(${arSign} * ${invoices.totalCents}), 0)::bigint`,
|
|
92
|
+
baseTotalCents: sql `coalesce(sum(${arSign} * (case when ${invSnapshotted} then ${invoices.baseTotalCents} else 0 end)), 0)::bigint`,
|
|
93
|
+
residualTotalCents: sql `coalesce(sum(${arSign} * (case when ${invSnapshotted} then 0 else ${invoices.totalCents} end)), 0)::bigint`,
|
|
94
|
+
})
|
|
95
|
+
.from(invoices)
|
|
96
|
+
.where(and(ne(invoices.status, "void"), ne(invoices.status, "draft"), ne(invoices.invoiceType, "proforma")))
|
|
97
|
+
.groupBy(invoices.bookingId, invoices.currency),
|
|
98
|
+
// Actual cost: departure-targeted allocations per (departure, currency).
|
|
99
|
+
db
|
|
100
|
+
.select({
|
|
101
|
+
departureId: supplierCostAllocations.departureId,
|
|
102
|
+
currency: supplierInvoices.currency,
|
|
103
|
+
amountCents: sql `coalesce(sum(${supplierCostAllocations.amountCents}), 0)::bigint`,
|
|
104
|
+
baseAmountCents: sql `coalesce(sum(case when ${allocSnapshotted} then ${supplierCostAllocations.baseAmountCents} else 0 end), 0)::bigint`,
|
|
105
|
+
residualAmountCents: sql `coalesce(sum(case when ${allocSnapshotted} then 0 else ${supplierCostAllocations.amountCents} end), 0)::bigint`,
|
|
106
|
+
})
|
|
107
|
+
.from(supplierCostAllocations)
|
|
108
|
+
.innerJoin(supplierInvoices, eq(supplierCostAllocations.supplierInvoiceId, supplierInvoices.id))
|
|
109
|
+
.where(and(eq(supplierCostAllocations.targetType, "departure"), isNotNull(supplierCostAllocations.departureId), ne(supplierInvoices.status, "void"), isNull(supplierInvoices.deletedAt)))
|
|
110
|
+
.groupBy(supplierCostAllocations.departureId, supplierInvoices.currency),
|
|
111
|
+
// Actual cost: product-targeted allocations per (product, currency) — not tied to a departure.
|
|
112
|
+
db
|
|
113
|
+
.select({
|
|
114
|
+
productId: supplierCostAllocations.productId,
|
|
115
|
+
currency: supplierInvoices.currency,
|
|
116
|
+
amountCents: sql `coalesce(sum(${supplierCostAllocations.amountCents}), 0)::bigint`,
|
|
117
|
+
baseAmountCents: sql `coalesce(sum(case when ${allocSnapshotted} then ${supplierCostAllocations.baseAmountCents} else 0 end), 0)::bigint`,
|
|
118
|
+
residualAmountCents: sql `coalesce(sum(case when ${allocSnapshotted} then 0 else ${supplierCostAllocations.amountCents} end), 0)::bigint`,
|
|
119
|
+
})
|
|
120
|
+
.from(supplierCostAllocations)
|
|
121
|
+
.innerJoin(supplierInvoices, eq(supplierCostAllocations.supplierInvoiceId, supplierInvoices.id))
|
|
122
|
+
.where(and(eq(supplierCostAllocations.targetType, "product"), isNotNull(supplierCostAllocations.productId), ne(supplierInvoices.status, "void"), isNull(supplierInvoices.deletedAt)))
|
|
123
|
+
.groupBy(supplierCostAllocations.productId, supplierInvoices.currency),
|
|
124
|
+
// Cost breakdown by configurable cost category. Summed from supplier-invoice
|
|
125
|
+
// LINE totals (not allocations) so categorizing a line shows up immediately,
|
|
126
|
+
// even before the cost is allocated to a departure. Lines without a category
|
|
127
|
+
// fall to "Uncategorized". Base = line total pro-rated by the invoice's
|
|
128
|
+
// snapshotted base/total ratio.
|
|
129
|
+
db
|
|
130
|
+
.select({
|
|
131
|
+
serviceType: sql `coalesce(${costCategories.name}, 'Uncategorized')`,
|
|
132
|
+
currency: supplierInvoices.currency,
|
|
133
|
+
amountCents: sql `coalesce(sum(${supplierInvoiceLines.totalAmountCents}), 0)::bigint`,
|
|
134
|
+
baseAmountCents: sql `coalesce(sum(case when ${lineSnapshotted} then round(${supplierInvoiceLines.totalAmountCents}::numeric * ${supplierInvoices.baseTotalCents} / ${supplierInvoices.totalCents}) else 0 end), 0)::bigint`,
|
|
135
|
+
residualAmountCents: sql `coalesce(sum(case when ${lineSnapshotted} then 0 else ${supplierInvoiceLines.totalAmountCents} end), 0)::bigint`,
|
|
136
|
+
})
|
|
137
|
+
.from(supplierInvoiceLines)
|
|
138
|
+
.innerJoin(supplierInvoices, eq(supplierInvoiceLines.supplierInvoiceId, supplierInvoices.id))
|
|
139
|
+
.leftJoin(costCategories, eq(supplierInvoiceLines.costCategoryId, costCategories.id))
|
|
140
|
+
.where(and(ne(supplierInvoices.status, "void"), isNull(supplierInvoices.deletedAt)))
|
|
141
|
+
// agent-quality: raw-sql reviewed -- owner: finance; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
142
|
+
.groupBy(sql `coalesce(${costCategories.name}, 'Uncategorized')`, supplierInvoices.currency),
|
|
143
|
+
// Recorded-but-unattributed cost — appears in AP totals, excluded from P&L.
|
|
144
|
+
db
|
|
145
|
+
.select({
|
|
146
|
+
currency: supplierInvoices.currency,
|
|
147
|
+
amountCents: sql `coalesce(sum(${supplierCostAllocations.amountCents}), 0)::bigint`,
|
|
148
|
+
baseAmountCents: sql `coalesce(sum(case when ${allocSnapshotted} then ${supplierCostAllocations.baseAmountCents} else 0 end), 0)::bigint`,
|
|
149
|
+
residualAmountCents: sql `coalesce(sum(case when ${allocSnapshotted} then 0 else ${supplierCostAllocations.amountCents} end), 0)::bigint`,
|
|
150
|
+
})
|
|
151
|
+
.from(supplierCostAllocations)
|
|
152
|
+
.innerJoin(supplierInvoices, eq(supplierCostAllocations.supplierInvoiceId, supplierInvoices.id))
|
|
153
|
+
.where(and(eq(supplierCostAllocations.targetType, "unattributed"), ne(supplierInvoices.status, "void"), isNull(supplierInvoices.deletedAt)))
|
|
154
|
+
.groupBy(supplierInvoices.currency),
|
|
155
|
+
]);
|
|
156
|
+
const departures = new Map();
|
|
157
|
+
const ensure = (departureId) => {
|
|
158
|
+
let acc = departures.get(departureId);
|
|
159
|
+
if (!acc) {
|
|
160
|
+
acc = {
|
|
161
|
+
departureId,
|
|
162
|
+
productId: null,
|
|
163
|
+
productName: null,
|
|
164
|
+
departureLabel: null,
|
|
165
|
+
departureDate: null,
|
|
166
|
+
byCurrency: new Map(),
|
|
167
|
+
base: { revenue: 0, actual: 0, planned: 0 },
|
|
168
|
+
residual: new Map(),
|
|
169
|
+
};
|
|
170
|
+
departures.set(departureId, acc);
|
|
171
|
+
}
|
|
172
|
+
return acc;
|
|
173
|
+
};
|
|
174
|
+
const ensureResidual = (acc, currency) => {
|
|
175
|
+
let entry = acc.residual.get(currency);
|
|
176
|
+
if (!entry) {
|
|
177
|
+
entry = { revenue: 0, actual: 0, planned: 0 };
|
|
178
|
+
acc.residual.set(currency, entry);
|
|
179
|
+
}
|
|
180
|
+
return entry;
|
|
181
|
+
};
|
|
182
|
+
// Booking totals + per-departure sell, for proportional revenue attribution.
|
|
183
|
+
const bookingTotalSell = new Map();
|
|
184
|
+
const bookingSlotSell = new Map();
|
|
185
|
+
for (const row of itemRows) {
|
|
186
|
+
const departureId = row.departureId;
|
|
187
|
+
if (!departureId)
|
|
188
|
+
continue;
|
|
189
|
+
const acc = ensure(departureId);
|
|
190
|
+
acc.productId ??= row.productId;
|
|
191
|
+
acc.productName ??= row.productName;
|
|
192
|
+
acc.departureLabel ??= row.departureLabel;
|
|
193
|
+
acc.departureDate ??= row.departureDate;
|
|
194
|
+
const sellCents = num(row.sellCents);
|
|
195
|
+
const plannedCost = num(row.plannedCostCents);
|
|
196
|
+
if (row.costCurrency && plannedCost !== 0) {
|
|
197
|
+
bucket(acc.byCurrency, row.costCurrency).planned += plannedCost;
|
|
198
|
+
// Planned cost is a budget snapshot with no recorded FX, so it always
|
|
199
|
+
// routes through the fallback conversion (latest rate for that currency).
|
|
200
|
+
ensureResidual(acc, row.costCurrency).planned += plannedCost;
|
|
201
|
+
}
|
|
202
|
+
bookingTotalSell.set(row.bookingId, (bookingTotalSell.get(row.bookingId) ?? 0) + sellCents);
|
|
203
|
+
const slots = bookingSlotSell.get(row.bookingId) ?? [];
|
|
204
|
+
slots.push({ departureId, sellCents });
|
|
205
|
+
bookingSlotSell.set(row.bookingId, slots);
|
|
206
|
+
}
|
|
207
|
+
const invoicesByBooking = new Map();
|
|
208
|
+
for (const row of invoiceRows) {
|
|
209
|
+
const list = invoicesByBooking.get(row.bookingId) ?? [];
|
|
210
|
+
list.push({
|
|
211
|
+
currency: row.currency,
|
|
212
|
+
totalCents: num(row.totalCents),
|
|
213
|
+
baseTotalCents: num(row.baseTotalCents),
|
|
214
|
+
residualTotalCents: num(row.residualTotalCents),
|
|
215
|
+
});
|
|
216
|
+
invoicesByBooking.set(row.bookingId, list);
|
|
217
|
+
}
|
|
218
|
+
for (const [bookingId, invoiceList] of invoicesByBooking) {
|
|
219
|
+
const slots = bookingSlotSell.get(bookingId);
|
|
220
|
+
if (!slots || slots.length === 0)
|
|
221
|
+
continue; // invoice for a booking with no slotted items → not departure-attributable
|
|
222
|
+
const totalSell = bookingTotalSell.get(bookingId) ?? 0;
|
|
223
|
+
for (const slot of slots) {
|
|
224
|
+
const ratio = totalSell > 0 ? slot.sellCents / totalSell : 1 / slots.length;
|
|
225
|
+
const acc = ensure(slot.departureId);
|
|
226
|
+
for (const inv of invoiceList) {
|
|
227
|
+
bucket(acc.byCurrency, inv.currency).revenue += inv.totalCents * ratio;
|
|
228
|
+
acc.base.revenue += inv.baseTotalCents * ratio;
|
|
229
|
+
if (inv.residualTotalCents !== 0) {
|
|
230
|
+
ensureResidual(acc, inv.currency).revenue += inv.residualTotalCents * ratio;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (const row of departureCostRows) {
|
|
236
|
+
if (!row.departureId)
|
|
237
|
+
continue;
|
|
238
|
+
const acc = ensure(row.departureId);
|
|
239
|
+
bucket(acc.byCurrency, row.currency).actual += num(row.amountCents);
|
|
240
|
+
acc.base.actual += num(row.baseAmountCents);
|
|
241
|
+
ensureResidual(acc, row.currency).actual += num(row.residualAmountCents);
|
|
242
|
+
}
|
|
243
|
+
// Resolve friendly labels from availability_slots (+ product name) for every
|
|
244
|
+
// departure — fills cost-only departures that have no booking-item snapshot.
|
|
245
|
+
const departureIds = [...departures.keys()];
|
|
246
|
+
if (departureIds.length > 0) {
|
|
247
|
+
const slotRows = await executeBoundaryRows(db,
|
|
248
|
+
// agent-quality: raw-sql reviewed -- owner: finance; Availability/Product are read-only profitability label sources and ids are parameter-bound.
|
|
249
|
+
sql `
|
|
250
|
+
SELECT avs.id, avs.date_local, avs.product_id, p.name AS product_name
|
|
251
|
+
FROM availability_slots avs
|
|
252
|
+
LEFT JOIN products p ON avs.product_id = p.id
|
|
253
|
+
WHERE avs.id IN (${sqlList(departureIds)})
|
|
254
|
+
`);
|
|
255
|
+
for (const slot of slotRows) {
|
|
256
|
+
const acc = departures.get(slot.id);
|
|
257
|
+
if (!acc)
|
|
258
|
+
continue;
|
|
259
|
+
const dateLocal = normalizeDateOnly(slot.date_local);
|
|
260
|
+
acc.departureLabel ??= dateLocal;
|
|
261
|
+
acc.departureDate ??= dateLocal;
|
|
262
|
+
acc.productId ??= slot.product_id;
|
|
263
|
+
acc.productName ??= slot.product_name;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const productActualCost = [];
|
|
267
|
+
const productActualCostBase = new Map();
|
|
268
|
+
for (const row of productCostRows) {
|
|
269
|
+
if (!row.productId)
|
|
270
|
+
continue;
|
|
271
|
+
productActualCost.push({
|
|
272
|
+
productId: row.productId,
|
|
273
|
+
currency: row.currency,
|
|
274
|
+
amountCents: num(row.amountCents),
|
|
275
|
+
});
|
|
276
|
+
let split = productActualCostBase.get(row.productId);
|
|
277
|
+
if (!split) {
|
|
278
|
+
split = newBaseSplit();
|
|
279
|
+
productActualCostBase.set(row.productId, split);
|
|
280
|
+
}
|
|
281
|
+
split.snapshotBase += num(row.baseAmountCents);
|
|
282
|
+
addResidual(split.residual, row.currency, num(row.residualAmountCents));
|
|
283
|
+
}
|
|
284
|
+
const costByServiceType = [];
|
|
285
|
+
const costByServiceTypeBase = new Map();
|
|
286
|
+
for (const row of serviceTypeRows) {
|
|
287
|
+
const amountCents = num(row.amountCents);
|
|
288
|
+
if (amountCents !== 0) {
|
|
289
|
+
costByServiceType.push({ serviceType: row.serviceType, currency: row.currency, amountCents });
|
|
290
|
+
}
|
|
291
|
+
let split = costByServiceTypeBase.get(row.serviceType);
|
|
292
|
+
if (!split) {
|
|
293
|
+
split = newBaseSplit();
|
|
294
|
+
costByServiceTypeBase.set(row.serviceType, split);
|
|
295
|
+
}
|
|
296
|
+
split.snapshotBase += num(row.baseAmountCents);
|
|
297
|
+
addResidual(split.residual, row.currency, num(row.residualAmountCents));
|
|
298
|
+
}
|
|
299
|
+
const unattributed = [];
|
|
300
|
+
const unattributedBase = newBaseSplit();
|
|
301
|
+
for (const row of unattributedRows) {
|
|
302
|
+
const amountCents = num(row.amountCents);
|
|
303
|
+
if (amountCents !== 0)
|
|
304
|
+
unattributed.push({ currency: row.currency, amountCents });
|
|
305
|
+
unattributedBase.snapshotBase += num(row.baseAmountCents);
|
|
306
|
+
addResidual(unattributedBase.residual, row.currency, num(row.residualAmountCents));
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
baseCurrency: base,
|
|
310
|
+
departures,
|
|
311
|
+
productActualCost,
|
|
312
|
+
productActualCostBase,
|
|
313
|
+
costByServiceType,
|
|
314
|
+
costByServiceTypeBase,
|
|
315
|
+
unattributed,
|
|
316
|
+
unattributedBase,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function withinDateRange(date, from, to) {
|
|
320
|
+
if (!from && !to)
|
|
321
|
+
return true;
|
|
322
|
+
if (!date)
|
|
323
|
+
return false;
|
|
324
|
+
if (from && date < from)
|
|
325
|
+
return false;
|
|
326
|
+
if (to && date > to)
|
|
327
|
+
return false;
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
export async function getDepartureProfitability(db, query, options = {}) {
|
|
331
|
+
const baseCurrency = (await resolveInvoiceFxSettingsOrDefault(db, options)).baseCurrency;
|
|
332
|
+
const loaded = await loadDepartureAccumulators(db, baseCurrency);
|
|
333
|
+
const { departures, costByServiceType, costByServiceTypeBase, unattributed, unattributedBase } = loaded;
|
|
334
|
+
const rows = [];
|
|
335
|
+
const filtered = [];
|
|
336
|
+
for (const acc of departures.values()) {
|
|
337
|
+
if (query.departureId && acc.departureId !== query.departureId)
|
|
338
|
+
continue;
|
|
339
|
+
if (query.productId && acc.productId !== query.productId)
|
|
340
|
+
continue;
|
|
341
|
+
if (!withinDateRange(acc.departureDate, query.from, query.to))
|
|
342
|
+
continue;
|
|
343
|
+
filtered.push(acc);
|
|
344
|
+
for (const [currency, totals] of acc.byCurrency) {
|
|
345
|
+
if (query.currency && currency !== query.currency)
|
|
346
|
+
continue;
|
|
347
|
+
const revenueCents = Math.round(totals.revenue);
|
|
348
|
+
const actualCostCents = Math.round(totals.actual);
|
|
349
|
+
const plannedCostCents = Math.round(totals.planned);
|
|
350
|
+
const profitCents = revenueCents - actualCostCents;
|
|
351
|
+
rows.push({
|
|
352
|
+
departureId: acc.departureId,
|
|
353
|
+
departureLabel: acc.departureLabel,
|
|
354
|
+
productId: acc.productId,
|
|
355
|
+
productName: acc.productName,
|
|
356
|
+
departureDate: acc.departureDate,
|
|
357
|
+
currency,
|
|
358
|
+
revenueCents,
|
|
359
|
+
actualCostCents,
|
|
360
|
+
plannedCostCents,
|
|
361
|
+
profitCents,
|
|
362
|
+
marginPercent: margin(profitCents, revenueCents),
|
|
363
|
+
varianceCents: plannedCostCents - actualCostCents,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
rows.sort((a, b) => (a.departureDate ?? "").localeCompare(b.departureDate ?? "") ||
|
|
368
|
+
a.departureId.localeCompare(b.departureId) ||
|
|
369
|
+
a.currency.localeCompare(b.currency));
|
|
370
|
+
// Accounting-base rollup. Always present and computed in the operator base
|
|
371
|
+
// currency: snapshots are summed verbatim, only legacy residuals are converted.
|
|
372
|
+
const residualCurrencies = collectResidualCurrencies(filtered.flatMap((acc) => [...acc.residual.keys()]), costByServiceTypeBase, unattributedBase);
|
|
373
|
+
const { rates, unconvertible } = await buildBaseRates(db, residualCurrencies, baseCurrency, options);
|
|
374
|
+
const baseRows = filtered
|
|
375
|
+
.map((acc) => {
|
|
376
|
+
const totals = baseFromAcc(acc.base, acc.residual, rates);
|
|
377
|
+
const revenueCents = Math.round(totals.revenue);
|
|
378
|
+
const actualCostCents = Math.round(totals.actual);
|
|
379
|
+
const plannedCostCents = Math.round(totals.planned);
|
|
380
|
+
const profitCents = revenueCents - actualCostCents;
|
|
381
|
+
return {
|
|
382
|
+
departureId: acc.departureId,
|
|
383
|
+
departureLabel: acc.departureLabel,
|
|
384
|
+
productId: acc.productId,
|
|
385
|
+
productName: acc.productName,
|
|
386
|
+
departureDate: acc.departureDate,
|
|
387
|
+
currency: baseCurrency,
|
|
388
|
+
revenueCents,
|
|
389
|
+
actualCostCents,
|
|
390
|
+
plannedCostCents,
|
|
391
|
+
profitCents,
|
|
392
|
+
marginPercent: margin(profitCents, revenueCents),
|
|
393
|
+
varianceCents: plannedCostCents - actualCostCents,
|
|
394
|
+
};
|
|
395
|
+
})
|
|
396
|
+
.sort((a, b) => (a.departureDate ?? "").localeCompare(b.departureDate ?? "") ||
|
|
397
|
+
a.departureId.localeCompare(b.departureId));
|
|
398
|
+
return {
|
|
399
|
+
rows,
|
|
400
|
+
costByServiceType: filterCostByCurrency(costByServiceType, query.currency),
|
|
401
|
+
unattributed: filterCostByCurrency(unattributed, query.currency),
|
|
402
|
+
base: {
|
|
403
|
+
currency: baseCurrency,
|
|
404
|
+
rows: baseRows,
|
|
405
|
+
costByServiceType: baseCostByServiceType(costByServiceTypeBase, rates, baseCurrency),
|
|
406
|
+
unattributedCents: Math.round(resolveBaseSplit(unattributedBase, rates, new Set())),
|
|
407
|
+
unconvertibleCurrencies: unconvertible,
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
export async function getProductProfitability(db, query, options = {}) {
|
|
412
|
+
const baseCurrency = (await resolveInvoiceFxSettingsOrDefault(db, options)).baseCurrency;
|
|
413
|
+
const loaded = await loadDepartureAccumulators(db, baseCurrency);
|
|
414
|
+
const { departures, productActualCost, productActualCostBase, costByServiceType, costByServiceTypeBase, unattributed, unattributedBase, } = loaded;
|
|
415
|
+
const products = new Map();
|
|
416
|
+
const ensureProduct = (productId) => {
|
|
417
|
+
let acc = products.get(productId);
|
|
418
|
+
if (!acc) {
|
|
419
|
+
acc = {
|
|
420
|
+
productId,
|
|
421
|
+
productName: null,
|
|
422
|
+
byCurrency: new Map(),
|
|
423
|
+
base: { revenue: 0, actual: 0, planned: 0 },
|
|
424
|
+
residual: new Map(),
|
|
425
|
+
baseDepartures: new Set(),
|
|
426
|
+
};
|
|
427
|
+
products.set(productId, acc);
|
|
428
|
+
}
|
|
429
|
+
return acc;
|
|
430
|
+
};
|
|
431
|
+
const productBucket = (acc, currency) => {
|
|
432
|
+
let entry = acc.byCurrency.get(currency);
|
|
433
|
+
if (!entry) {
|
|
434
|
+
entry = { revenue: 0, actual: 0, planned: 0, departures: new Set() };
|
|
435
|
+
acc.byCurrency.set(currency, entry);
|
|
436
|
+
}
|
|
437
|
+
return entry;
|
|
438
|
+
};
|
|
439
|
+
const productResidual = (acc, currency) => {
|
|
440
|
+
let entry = acc.residual.get(currency);
|
|
441
|
+
if (!entry) {
|
|
442
|
+
entry = { revenue: 0, actual: 0, planned: 0 };
|
|
443
|
+
acc.residual.set(currency, entry);
|
|
444
|
+
}
|
|
445
|
+
return entry;
|
|
446
|
+
};
|
|
447
|
+
for (const dep of departures.values()) {
|
|
448
|
+
if (!dep.productId)
|
|
449
|
+
continue;
|
|
450
|
+
if (!withinDateRange(dep.departureDate, query.from, query.to))
|
|
451
|
+
continue;
|
|
452
|
+
const acc = ensureProduct(dep.productId);
|
|
453
|
+
acc.productName ??= dep.productName;
|
|
454
|
+
for (const [currency, totals] of dep.byCurrency) {
|
|
455
|
+
if (query.currency && currency !== query.currency)
|
|
456
|
+
continue;
|
|
457
|
+
const entry = productBucket(acc, currency);
|
|
458
|
+
entry.revenue += totals.revenue;
|
|
459
|
+
entry.actual += totals.actual;
|
|
460
|
+
entry.planned += totals.planned;
|
|
461
|
+
entry.departures.add(dep.departureId);
|
|
462
|
+
}
|
|
463
|
+
// Base rollup aggregates across currencies (no currency filter).
|
|
464
|
+
acc.base.revenue += dep.base.revenue;
|
|
465
|
+
acc.base.actual += dep.base.actual;
|
|
466
|
+
acc.base.planned += dep.base.planned;
|
|
467
|
+
acc.baseDepartures.add(dep.departureId);
|
|
468
|
+
for (const [currency, res] of dep.residual) {
|
|
469
|
+
const entry = productResidual(acc, currency);
|
|
470
|
+
entry.revenue += res.revenue;
|
|
471
|
+
entry.actual += res.actual;
|
|
472
|
+
entry.planned += res.planned;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// Product-level allocations (cost attributed to a product, not a departure).
|
|
476
|
+
for (const row of productActualCost) {
|
|
477
|
+
if (query.currency && row.currency !== query.currency)
|
|
478
|
+
continue;
|
|
479
|
+
productBucket(ensureProduct(row.productId), row.currency).actual += row.amountCents;
|
|
480
|
+
}
|
|
481
|
+
for (const [productId, split] of productActualCostBase) {
|
|
482
|
+
const acc = ensureProduct(productId);
|
|
483
|
+
acc.base.actual += split.snapshotBase;
|
|
484
|
+
for (const [currency, amount] of split.residual) {
|
|
485
|
+
productResidual(acc, currency).actual += amount;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const rows = [];
|
|
489
|
+
for (const acc of products.values()) {
|
|
490
|
+
for (const [currency, totals] of acc.byCurrency) {
|
|
491
|
+
const revenueCents = Math.round(totals.revenue);
|
|
492
|
+
const actualCostCents = Math.round(totals.actual);
|
|
493
|
+
const plannedCostCents = Math.round(totals.planned);
|
|
494
|
+
const profitCents = revenueCents - actualCostCents;
|
|
495
|
+
rows.push({
|
|
496
|
+
productId: acc.productId,
|
|
497
|
+
productName: acc.productName,
|
|
498
|
+
currency,
|
|
499
|
+
departureCount: totals.departures.size,
|
|
500
|
+
revenueCents,
|
|
501
|
+
actualCostCents,
|
|
502
|
+
plannedCostCents,
|
|
503
|
+
profitCents,
|
|
504
|
+
marginPercent: margin(profitCents, revenueCents),
|
|
505
|
+
varianceCents: plannedCostCents - actualCostCents,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
rows.sort((a, b) => a.productId.localeCompare(b.productId) || a.currency.localeCompare(b.currency));
|
|
510
|
+
const residualCurrencies = collectResidualCurrencies([...products.values()].flatMap((acc) => [...acc.residual.keys()]), costByServiceTypeBase, unattributedBase);
|
|
511
|
+
const { rates, unconvertible } = await buildBaseRates(db, residualCurrencies, baseCurrency, options);
|
|
512
|
+
const baseRows = [...products.values()]
|
|
513
|
+
.map((acc) => {
|
|
514
|
+
const totals = baseFromAcc(acc.base, acc.residual, rates);
|
|
515
|
+
const revenueCents = Math.round(totals.revenue);
|
|
516
|
+
const actualCostCents = Math.round(totals.actual);
|
|
517
|
+
const plannedCostCents = Math.round(totals.planned);
|
|
518
|
+
const profitCents = revenueCents - actualCostCents;
|
|
519
|
+
return {
|
|
520
|
+
productId: acc.productId,
|
|
521
|
+
productName: acc.productName,
|
|
522
|
+
currency: baseCurrency,
|
|
523
|
+
departureCount: acc.baseDepartures.size,
|
|
524
|
+
revenueCents,
|
|
525
|
+
actualCostCents,
|
|
526
|
+
plannedCostCents,
|
|
527
|
+
profitCents,
|
|
528
|
+
marginPercent: margin(profitCents, revenueCents),
|
|
529
|
+
varianceCents: plannedCostCents - actualCostCents,
|
|
530
|
+
};
|
|
531
|
+
})
|
|
532
|
+
.sort((a, b) => a.productId.localeCompare(b.productId));
|
|
533
|
+
return {
|
|
534
|
+
rows,
|
|
535
|
+
costByServiceType: filterCostByCurrency(costByServiceType, query.currency),
|
|
536
|
+
unattributed: filterCostByCurrency(unattributed, query.currency),
|
|
537
|
+
base: {
|
|
538
|
+
currency: baseCurrency,
|
|
539
|
+
rows: baseRows,
|
|
540
|
+
costByServiceType: baseCostByServiceType(costByServiceTypeBase, rates, baseCurrency),
|
|
541
|
+
unattributedCents: Math.round(resolveBaseSplit(unattributedBase, rates, new Set())),
|
|
542
|
+
unconvertibleCurrencies: unconvertible,
|
|
543
|
+
},
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
function filterCostByCurrency(rows, currency) {
|
|
547
|
+
return currency ? rows.filter((row) => row.currency === currency) : rows;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Derive per-traveller P&L for one departure in a single currency. Revenue is a
|
|
551
|
+
* booking's departure-attributed invoiced AR split equally across that booking's
|
|
552
|
+
* travellers; planned cost likewise; actual departure cost is split equally
|
|
553
|
+
* across all the departure's travellers (`equal` method — `per_pax` parity).
|
|
554
|
+
*/
|
|
555
|
+
export async function getTravelerProfitability(db, query) {
|
|
556
|
+
const departureId = query.departureId;
|
|
557
|
+
const currency = query.currency.toUpperCase();
|
|
558
|
+
const empty = { departureId, currency, travelerCount: 0, rows: [] };
|
|
559
|
+
const depItemRows = await db
|
|
560
|
+
.select({
|
|
561
|
+
bookingId: bookingItems.bookingId,
|
|
562
|
+
depSell: sql `coalesce(sum(${bookingItems.totalSellAmountCents}), 0)::bigint`,
|
|
563
|
+
depPlanned: sql `coalesce(sum(case when ${bookingItems.costCurrency} = ${currency} then ${bookingItems.totalCostAmountCents} else 0 end), 0)::bigint`,
|
|
564
|
+
})
|
|
565
|
+
.from(bookingItems)
|
|
566
|
+
.where(eq(bookingItems.availabilitySlotId, departureId))
|
|
567
|
+
.groupBy(bookingItems.bookingId);
|
|
568
|
+
const bookingIds = depItemRows.map((r) => r.bookingId);
|
|
569
|
+
if (bookingIds.length === 0)
|
|
570
|
+
return empty;
|
|
571
|
+
const [totalSellRows, invoiceRows, actualRows, travelerRows] = await Promise.all([
|
|
572
|
+
db
|
|
573
|
+
.select({
|
|
574
|
+
bookingId: bookingItems.bookingId,
|
|
575
|
+
totalSell: sql `coalesce(sum(${bookingItems.totalSellAmountCents}), 0)::bigint`,
|
|
576
|
+
})
|
|
577
|
+
.from(bookingItems)
|
|
578
|
+
.where(inArray(bookingItems.bookingId, bookingIds))
|
|
579
|
+
.groupBy(bookingItems.bookingId),
|
|
580
|
+
db
|
|
581
|
+
.select({
|
|
582
|
+
bookingId: invoices.bookingId,
|
|
583
|
+
total: sql `coalesce(sum(case when ${invoices.invoiceType} = 'credit_note' then -${invoices.totalCents} else ${invoices.totalCents} end), 0)::bigint`,
|
|
584
|
+
})
|
|
585
|
+
.from(invoices)
|
|
586
|
+
.where(and(inArray(invoices.bookingId, bookingIds), eq(invoices.currency, currency), ne(invoices.status, "void"), ne(invoices.status, "draft"), ne(invoices.invoiceType, "proforma")))
|
|
587
|
+
.groupBy(invoices.bookingId),
|
|
588
|
+
db
|
|
589
|
+
.select({
|
|
590
|
+
amount: sql `coalesce(sum(${supplierCostAllocations.amountCents}), 0)::bigint`,
|
|
591
|
+
})
|
|
592
|
+
.from(supplierCostAllocations)
|
|
593
|
+
.innerJoin(supplierInvoices, eq(supplierCostAllocations.supplierInvoiceId, supplierInvoices.id))
|
|
594
|
+
.where(and(eq(supplierCostAllocations.targetType, "departure"), eq(supplierCostAllocations.departureId, departureId), eq(supplierInvoices.currency, currency), ne(supplierInvoices.status, "void"), isNull(supplierInvoices.deletedAt))),
|
|
595
|
+
db
|
|
596
|
+
.select({
|
|
597
|
+
id: bookingTravelers.id,
|
|
598
|
+
bookingId: bookingTravelers.bookingId,
|
|
599
|
+
firstName: bookingTravelers.firstName,
|
|
600
|
+
lastName: bookingTravelers.lastName,
|
|
601
|
+
})
|
|
602
|
+
.from(bookingTravelers)
|
|
603
|
+
.where(and(inArray(bookingTravelers.bookingId, bookingIds), eq(bookingTravelers.participantType, "traveler"))),
|
|
604
|
+
]);
|
|
605
|
+
const totalSellByBooking = new Map(totalSellRows.map((r) => [r.bookingId, num(r.totalSell)]));
|
|
606
|
+
const invoicedByBooking = new Map(invoiceRows.map((r) => [r.bookingId, num(r.total)]));
|
|
607
|
+
const depByBooking = new Map(depItemRows.map((r) => [
|
|
608
|
+
r.bookingId,
|
|
609
|
+
{ depSell: num(r.depSell), depPlanned: num(r.depPlanned) },
|
|
610
|
+
]));
|
|
611
|
+
const travelersByBooking = new Map();
|
|
612
|
+
for (const t of travelerRows) {
|
|
613
|
+
const list = travelersByBooking.get(t.bookingId) ?? [];
|
|
614
|
+
list.push({ id: t.id, name: `${t.firstName} ${t.lastName}`.trim() });
|
|
615
|
+
travelersByBooking.set(t.bookingId, list);
|
|
616
|
+
}
|
|
617
|
+
const travelerCount = travelerRows.length;
|
|
618
|
+
if (travelerCount === 0)
|
|
619
|
+
return empty;
|
|
620
|
+
const actualPerTraveler = num(actualRows[0]?.amount) / travelerCount;
|
|
621
|
+
const rows = [];
|
|
622
|
+
for (const bookingId of bookingIds) {
|
|
623
|
+
const travelers = travelersByBooking.get(bookingId);
|
|
624
|
+
if (!travelers || travelers.length === 0)
|
|
625
|
+
continue;
|
|
626
|
+
const dep = depByBooking.get(bookingId) ?? { depSell: 0, depPlanned: 0 };
|
|
627
|
+
const totalSell = totalSellByBooking.get(bookingId) ?? 0;
|
|
628
|
+
const ratio = totalSell > 0 ? dep.depSell / totalSell : 1;
|
|
629
|
+
const revenuePerTraveler = ((invoicedByBooking.get(bookingId) ?? 0) * ratio) / travelers.length;
|
|
630
|
+
const plannedPerTraveler = dep.depPlanned / travelers.length;
|
|
631
|
+
for (const traveler of travelers) {
|
|
632
|
+
const revenueCents = Math.round(revenuePerTraveler);
|
|
633
|
+
const actualCostCents = Math.round(actualPerTraveler);
|
|
634
|
+
const plannedCostCents = Math.round(plannedPerTraveler);
|
|
635
|
+
const profitCents = revenueCents - actualCostCents;
|
|
636
|
+
rows.push({
|
|
637
|
+
travelerId: traveler.id,
|
|
638
|
+
travelerName: traveler.name,
|
|
639
|
+
bookingId,
|
|
640
|
+
currency,
|
|
641
|
+
revenueCents,
|
|
642
|
+
actualCostCents,
|
|
643
|
+
plannedCostCents,
|
|
644
|
+
profitCents,
|
|
645
|
+
marginPercent: margin(profitCents, revenueCents),
|
|
646
|
+
varianceCents: plannedCostCents - actualCostCents,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
rows.sort((a, b) => a.bookingId.localeCompare(b.bookingId) || a.travelerName.localeCompare(b.travelerName));
|
|
651
|
+
return { departureId, currency, travelerCount, rows };
|
|
652
|
+
}
|
|
653
|
+
// ---------- base-currency rollup (FX) ----------
|
|
654
|
+
/** Distinct residual currencies that need a fallback conversion rate. */
|
|
655
|
+
function collectResidualCurrencies(perRow, ...splits) {
|
|
656
|
+
const set = new Set(perRow);
|
|
657
|
+
for (const split of splits) {
|
|
658
|
+
if (split instanceof Map) {
|
|
659
|
+
for (const s of split.values())
|
|
660
|
+
for (const c of s.residual.keys())
|
|
661
|
+
set.add(c);
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
for (const c of split.residual.keys())
|
|
665
|
+
set.add(c);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return set;
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Resolve a fallback conversion rate for each residual currency → base. Used ONLY
|
|
672
|
+
* for legacy rows that predate the base-amount snapshot (forward-only): persisted
|
|
673
|
+
* FX rate first (then the runtime resolver). No date is passed, so these use the
|
|
674
|
+
* latest available rate. Snapshotted rows never reach here — they are summed at
|
|
675
|
+
* their own issue-date rate. Currencies with no rate are reported as unconvertible
|
|
676
|
+
* and excluded from the rollup rather than guessed.
|
|
677
|
+
*/
|
|
678
|
+
async function buildBaseRates(db, currencies, base, options = {}) {
|
|
679
|
+
const rates = new Map();
|
|
680
|
+
const unconvertible = [];
|
|
681
|
+
for (const currency of new Set(currencies)) {
|
|
682
|
+
if (currency === base) {
|
|
683
|
+
rates.set(currency, 1);
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
const probeInput = { amountCents: 1_000_000, currency };
|
|
687
|
+
const probe = await resolveFxMoneyBaseAmount(db, probeInput, {
|
|
688
|
+
...options,
|
|
689
|
+
targetBaseCurrency: base,
|
|
690
|
+
});
|
|
691
|
+
if (probe.baseAmountCents != null && probe.baseCurrency === base) {
|
|
692
|
+
rates.set(currency, probe.baseAmountCents / 1_000_000);
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
unconvertible.push(currency);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return { rates, unconvertible };
|
|
699
|
+
}
|
|
700
|
+
/** Snapshot base + converted residuals → a single accounting-base figure set. */
|
|
701
|
+
function baseFromAcc(snapshot, residual, rates) {
|
|
702
|
+
const out = {
|
|
703
|
+
revenue: snapshot.revenue,
|
|
704
|
+
actual: snapshot.actual,
|
|
705
|
+
planned: snapshot.planned,
|
|
706
|
+
};
|
|
707
|
+
for (const [currency, res] of residual) {
|
|
708
|
+
const rate = rates.get(currency);
|
|
709
|
+
if (rate == null)
|
|
710
|
+
continue;
|
|
711
|
+
out.revenue += res.revenue * rate;
|
|
712
|
+
out.actual += res.actual * rate;
|
|
713
|
+
out.planned += res.planned * rate;
|
|
714
|
+
}
|
|
715
|
+
return out;
|
|
716
|
+
}
|
|
717
|
+
/** Resolve the cost-by-category breakdown into the accounting base currency. */
|
|
718
|
+
function baseCostByServiceType(splits, rates, base) {
|
|
719
|
+
const out = [];
|
|
720
|
+
for (const [serviceType, split] of splits) {
|
|
721
|
+
const amountCents = Math.round(resolveBaseSplit(split, rates, new Set()));
|
|
722
|
+
if (amountCents !== 0)
|
|
723
|
+
out.push({ serviceType, currency: base, amountCents });
|
|
724
|
+
}
|
|
725
|
+
return out;
|
|
726
|
+
}
|
|
727
|
+
// ---------- CSV export (accountant sharing) ----------
|
|
728
|
+
const csvField = (value) => {
|
|
729
|
+
const str = value == null ? "" : String(value);
|
|
730
|
+
return /[",\n\r]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str;
|
|
731
|
+
};
|
|
732
|
+
const csvRow = (cells) => cells.map(csvField).join(",");
|
|
733
|
+
// BOM + CRLF so Excel opens UTF-8 correctly (mirrors availability exports).
|
|
734
|
+
const csvDocument = (rows) => `${rows.join("\r\n")}\r\n`;
|
|
735
|
+
const major = (cents) => (cents / 100).toFixed(2);
|
|
736
|
+
const marginCell = (value) => (value == null ? "" : value.toFixed(1));
|
|
737
|
+
export function buildDepartureProfitabilityCsv(report) {
|
|
738
|
+
const header = [
|
|
739
|
+
"departure_id",
|
|
740
|
+
"departure",
|
|
741
|
+
"product_id",
|
|
742
|
+
"product",
|
|
743
|
+
"date",
|
|
744
|
+
"currency",
|
|
745
|
+
"revenue",
|
|
746
|
+
"actual_cost",
|
|
747
|
+
"planned_cost",
|
|
748
|
+
"profit",
|
|
749
|
+
"margin_percent",
|
|
750
|
+
"variance",
|
|
751
|
+
];
|
|
752
|
+
const rows = report.rows.map((r) => csvRow([
|
|
753
|
+
r.departureId,
|
|
754
|
+
r.departureLabel,
|
|
755
|
+
r.productId,
|
|
756
|
+
r.productName,
|
|
757
|
+
r.departureDate,
|
|
758
|
+
r.currency,
|
|
759
|
+
major(r.revenueCents),
|
|
760
|
+
major(r.actualCostCents),
|
|
761
|
+
major(r.plannedCostCents),
|
|
762
|
+
major(r.profitCents),
|
|
763
|
+
marginCell(r.marginPercent),
|
|
764
|
+
major(r.varianceCents),
|
|
765
|
+
]));
|
|
766
|
+
return csvDocument([csvRow(header), ...rows]);
|
|
767
|
+
}
|
|
768
|
+
export function buildProductProfitabilityCsv(report) {
|
|
769
|
+
const header = [
|
|
770
|
+
"product_id",
|
|
771
|
+
"product",
|
|
772
|
+
"currency",
|
|
773
|
+
"departures",
|
|
774
|
+
"revenue",
|
|
775
|
+
"actual_cost",
|
|
776
|
+
"planned_cost",
|
|
777
|
+
"profit",
|
|
778
|
+
"margin_percent",
|
|
779
|
+
"variance",
|
|
780
|
+
];
|
|
781
|
+
const rows = report.rows.map((r) => csvRow([
|
|
782
|
+
r.productId,
|
|
783
|
+
r.productName,
|
|
784
|
+
r.currency,
|
|
785
|
+
r.departureCount,
|
|
786
|
+
major(r.revenueCents),
|
|
787
|
+
major(r.actualCostCents),
|
|
788
|
+
major(r.plannedCostCents),
|
|
789
|
+
major(r.profitCents),
|
|
790
|
+
marginCell(r.marginPercent),
|
|
791
|
+
major(r.varianceCents),
|
|
792
|
+
]));
|
|
793
|
+
return csvDocument([csvRow(header), ...rows]);
|
|
794
|
+
}
|