Files
edr-platform/apps/finance-api/src/modules/revenue/revenue-posting.service.ts
2026-08-25 00:11:39 +03:00

382 lines
14 KiB
TypeScript

import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import type { PaymentEvent } from "@edr/types";
import type { ActorContext } from "../../common/current-actor.util";
import { LEDGER_CURRENCY, roundMoney } from "../../common/money";
import { AccountsService } from "../accounts/accounts.service";
import { JournalsService } from "../journals/journals.service";
import { RevenueMappingsRepository } from "./revenue.repository";
import { RevenueProjectionService } from "./revenue-projection.service";
import { CutoverService } from "../cutover/cutover.service";
import { UNCLASSIFIED_ACCOUNT_CODE } from "../../seed/revenue-mappings.seed";
import type { MappingSource } from "./entities/revenue-mapping.entity";
/**
* Accounts the automated postings resolve BY CODE.
*
* They are marked `is_system` in the chart precisely so they cannot be deleted
* or deactivated out from under this map. Changing a code here means changing
* the seed too.
*/
const ACCOUNTS = {
/** Money in from a payment provider, before bank reconciliation. */
gatewayClearing: "1114",
cashOnHand: "1111",
receivableFreight: "1121",
receivablePassenger: "1122",
unclassifiedRevenue: UNCLASSIFIED_ACCOUNT_CODE,
} as const;
export type PaymentPostingResult =
| { outcome: "POSTED"; journalEntryId: string; entryNumber: string }
| { outcome: "SKIPPED"; reason: string }
| { outcome: "FAILED"; reason: string };
export type RecognitionResult = {
posted: boolean;
entryNumber?: string;
journalEntryId?: string;
/** Revenue this run deliberately did not post, and why. */
excluded: { revenueKey: string; currency: string; amount: number; reason: string }[];
lines: { accountCode: string; revenueKey: string; amount: number }[];
total: number;
};
@Injectable()
export class RevenuePostingService {
private readonly logger = new Logger(RevenuePostingService.name);
constructor(
private readonly mappings: RevenueMappingsRepository,
private readonly accounts: AccountsService,
private readonly journals: JournalsService,
private readonly projection: RevenueProjectionService,
private readonly cutover: CutoverService,
) {}
/**
* Turns one settled payment into a cash-receipt journal entry.
*
* Dr 1114 Payment Gateway Clearing (money arrived)
* Cr 112x Trade Receivables (what was owed is now settled)
*
* Note what this deliberately does NOT do: it does not credit revenue. A
* payment is not revenue — the revenue was earned when the booking or invoice
* was raised, and posting both here would count the same sale twice. Revenue
* recognition is `recognizeRevenue` below, and the two halves meet at the
* receivable control account.
*
* `event.amountMinor` carries MAJOR units despite the name — verified against
* the publisher and both existing consumers. See `common/money.ts`.
*/
async postPaymentReceipt(
actor: ActorContext,
event: PaymentEvent,
): Promise<PaymentPostingResult> {
if (event.eventType !== "payment.succeeded") {
// A failed payment moves no money. Recording it as a journal entry would
// put a zero-value transaction in the ledger for every abandoned
// checkout, which is noise, not accounting.
return {
outcome: "SKIPPED",
reason: `${event.eventType} moves no money — nothing to post`,
};
}
// The date this entry would carry, decided before anything else is judged
// about it, because whether it is in scope at all depends on WHEN.
const paidAt =
"paidAt" in event && event.paidAt
? String(event.paidAt).slice(0, 10)
: String(event.occurredAt).slice(0, 10);
// ── The cutover boundary ───────────────────────────────────────────────
//
// A payment settled before go-live is ALREADY in the ledger: it is inside
// the receivable and cash figures the opening balances brought over. Posting
// it again would count the same money twice, and the trial balance would
// still balance while being wrong — the worst kind of error this system can
// make.
//
// This matters most on a REPLAY. The broker is at-least-once and the outbox
// can be re-driven, so a backlog of historical events reaching a freshly
// migrated ledger is an ordinary operational event, not a freak one.
//
// SKIPPED, not FAILED: nothing went wrong. The event is still recorded in
// `inbound_events` with this reason, so it stays auditable and can be
// replayed if the cutover date is later moved back.
//
// With no cutover date set there is no boundary and everything posts. That
// is deliberate — an organization mid-setup should not have its ledger
// silently frozen; the readiness check is what reports the missing date.
const cutoverDate = actor.organizationId
? await this.cutover.cutoverDateFor(actor.organizationId)
: null;
if (cutoverDate && paidAt < cutoverDate) {
return {
outcome: "SKIPPED",
reason:
`Settled ${paidAt}, before the cutover on ${cutoverDate} — already carried by the opening balances, so posting it would count the money twice`,
};
}
const currency = (event.currency ?? LEDGER_CURRENCY).toUpperCase();
if (currency !== LEDGER_CURRENCY) {
// The ledger is single-currency. Guessing a rate would silently invent a
// number, so this is recorded as FAILED and waits for a human decision.
return {
outcome: "FAILED",
reason:
`Payment is in ${currency}; the ledger carries ${LEDGER_CURRENCY} only. ` +
`Post it manually with an explicit conversion rate.`,
};
}
const amount = roundMoney(Number(event.amountMinor ?? 0));
if (!(amount > 0)) {
return {
outcome: "FAILED",
reason: `Payment amount is ${event.amountMinor} — cannot post a non-positive receipt`,
};
}
const receivableCode =
event.service === "FREIGHT"
? ACCOUNTS.receivableFreight
: ACCOUNTS.receivablePassenger;
const [clearing, receivable] = await Promise.all([
this.requireAccount(actor, ACCOUNTS.gatewayClearing),
this.requireAccount(actor, receivableCode),
]);
try {
const entry = await this.journals.createPosted(actor, {
entryDate: paidAt,
journalType: "CASH_RECEIPT",
memo:
`${event.provider} payment for ${event.referenceType} ${event.referenceId}` +
(event.merchantOrderId ? ` (${event.merchantOrderId})` : ""),
reference: event.merchantOrderId ?? event.intentId,
// The idempotency key for automated posting. The partial unique index
// on (org, source_module, source_id) means a redelivery that somehow
// gets past the inbound-event guard still cannot post twice.
sourceModule: "payment",
sourceId: event.eventId,
lines: [
{
accountId: clearing.id,
debit: amount,
description: `${event.provider} ${event.providerTxnId ?? event.intentId}`,
},
{
accountId: receivable.id,
credit: amount,
description: `${event.referenceType} ${event.referenceId}`,
},
],
});
return {
outcome: "POSTED",
journalEntryId: entry.id,
entryNumber: entry.entryNumber,
};
} catch (error) {
// A closed period, a deactivated account, an unbalanced entry — all of
// these are recoverable once a human acts, so the event is kept as FAILED
// with the reason rather than discarded.
return {
outcome: "FAILED",
reason: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Recognizes a period's revenue as ONE summarized journal entry.
*
* Dr 112x Trade Receivables (total)
* Cr 4xxx revenue accounts (one line per mapped charge type)
*
* Summarized on purpose. The dev replica has 33,063 confirmed bookings in a
* single month; posting one entry each would make the ledger unreadable and
* the trial balance unusable, and no accountant would ask for it. The detail
* stays where it already lives — in the source system — and the projection
* endpoints show it.
*
* NON-ETB revenue is excluded and reported, never converted at a guessed
* rate: on the dev replica that is 6.18M DJF and 133k USD, and folding those
* into the ETB total would overstate revenue by 6.3M.
*/
async recognizeRevenue(
actor: ActorContext,
params: { sourceModule: MappingSource; period: string },
): Promise<RecognitionResult> {
const organizationId = this.requireOrganization(actor);
const { dateFrom, dateTo } = this.monthBounds(params.period);
const buckets = (
await this.projection.revenueByKey(dateFrom, dateTo)
).filter((bucket) => bucket.sourceModule === params.sourceModule);
const excluded: RecognitionResult["excluded"] = [];
const postable = buckets.filter((bucket) => {
if (bucket.currency !== LEDGER_CURRENCY) {
excluded.push({
revenueKey: bucket.revenueKey,
currency: bucket.currency,
amount: bucket.amount,
reason: `${bucket.currency} needs an explicit conversion rate — the ledger carries ${LEDGER_CURRENCY} only`,
});
return false;
}
if (!(bucket.amount > 0)) {
excluded.push({
revenueKey: bucket.revenueKey,
currency: bucket.currency,
amount: bucket.amount,
reason: "Nothing to recognize",
});
return false;
}
return true;
});
if (postable.length === 0) {
return { posted: false, excluded, lines: [], total: 0 };
}
// Resolve each revenue key to an account. An unmapped key is NOT an error —
// it lands in 4900 Unclassified Revenue, a real visible account, so the
// balance itself becomes the prompt to add a mapping.
const unclassified = await this.requireAccount(
actor,
ACCOUNTS.unclassifiedRevenue,
);
const byAccount = new Map<
string,
{ accountId: string; accountCode: string; revenueKeys: string[]; amount: number }
>();
for (const bucket of postable) {
const mapping = await this.mappings.findMatch(
organizationId,
params.sourceModule,
bucket.revenueKey,
);
let accountId = unclassified.id;
let accountCode = unclassified.code;
if (mapping) {
const account = await this.accounts.findOne(actor, mapping.accountId);
accountId = account.id;
accountCode = account.code;
} else {
this.logger.warn(
`No revenue mapping for ${params.sourceModule}:${bucket.revenueKey} — posting to ${unclassified.code}`,
);
}
const existing = byAccount.get(accountId);
if (existing) {
existing.amount = roundMoney(existing.amount + bucket.amount);
existing.revenueKeys.push(bucket.revenueKey);
} else {
byAccount.set(accountId, {
accountId,
accountCode,
revenueKeys: [bucket.revenueKey],
amount: bucket.amount,
});
}
}
const revenueLines = [...byAccount.values()];
const total = roundMoney(
revenueLines.reduce((sum, line) => sum + line.amount, 0),
);
const receivable = await this.requireAccount(
actor,
params.sourceModule === "freight"
? ACCOUNTS.receivableFreight
: ACCOUNTS.receivablePassenger,
);
const entry = await this.journals.createPosted(actor, {
// Dated the LAST day of the period, which is when a period's revenue is
// recognized — not today, or a re-run in a later month would land the
// same revenue in the wrong period.
entryDate: dateTo,
journalType: "SALES",
memo: `${params.sourceModule} revenue recognition — ${params.period}`,
reference: params.period,
sourceModule: `${params.sourceModule}-revenue`,
sourceId: params.period,
lines: [
{
accountId: receivable.id,
debit: total,
description: `${params.sourceModule} revenue ${params.period}`,
},
...revenueLines.map((line) => ({
accountId: line.accountId,
credit: line.amount,
description: line.revenueKeys.join(", "),
})),
],
});
return {
posted: true,
entryNumber: entry.entryNumber,
journalEntryId: entry.id,
excluded,
lines: revenueLines.map((line) => ({
accountCode: line.accountCode,
revenueKey: line.revenueKeys.join(", "),
amount: line.amount,
})),
total,
};
}
private async requireAccount(actor: ActorContext, code: string) {
const organizationId = this.requireOrganization(actor);
const account = await this.accounts.findByCode(organizationId, code);
if (!account) {
throw new BadRequestException(
`Account ${code} is missing from this organization's chart. Seed the chart of accounts before posting.`,
);
}
return account;
}
private requireOrganization(actor: ActorContext): string {
if (!actor.organizationId) {
throw new BadRequestException(
"This account has no organization context, so it cannot post finance records",
);
}
return actor.organizationId;
}
/** "2026-08" → the first and last day of that month. */
private monthBounds(period: string): { dateFrom: string; dateTo: string } {
if (!/^\d{4}-\d{2}$/.test(period)) {
throw new BadRequestException("period must be YYYY-MM");
}
const [year, month] = period.split("-").map(Number);
const from = new Date(Date.UTC(year, month - 1, 1));
// Day 0 of the NEXT month is the last day of this one — leap years and
// 31-day months included, without a table.
const to = new Date(Date.UTC(year, month, 0));
return {
dateFrom: from.toISOString().slice(0, 10),
dateTo: to.toISOString().slice(0, 10),
};
}
}