mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 21:50:57 +00:00
382 lines
14 KiB
TypeScript
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),
|
|
};
|
|
}
|
|
}
|