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 { 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 { 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), }; } }