import { createResolver, t } from "@tailor-platform/sdk"; import { Decimal } from "decimal.js"; import { getDB } from "@/generated/kysely-tailordb"; type MainDbHandle = ReturnType>; /** * BFF (backend-for-frontend) resolver: the open (settleable) AR items for the * receipt-create screen. Returns the due-schedule lines of POSTED customer * INVOICEs whose open balance is still positive, so the frontend renders the * settlement list without re-deriving the open amount itself. * * Open balance = due amount − effective settled, where the effective settled * amount is the single source of truth shared with `postIncomingPayment`'s * over-settlement check: over settlements whose parent IncomingPayment is * effective (POSTED or REVERSED), an original row adds its `settledAmount` and a * reversing row (`reversalOfSettlementId` set) subtracts it — so a reversed * payment's original(+) and reversing(−) rows net to zero. Keeping this on the * backend is what stops the list and the post-time validation from drifting * (the drift that let reversed settlements be double-counted before). */ interface OpenReceivableLine { dueScheduleLineId: string; invoiceId: string; documentNumber: string; dueDate: string; openAmount: string; companyId: string; currencyId: string; customerAccountId: string; customerAccountName: string; } const toIsoDate = (value: unknown): string => value instanceof Date ? value.toISOString().slice(0, 10) : String(value).slice(0, 10); async function computeOpenReceivables( db: MainDbHandle, customerAccountId: string | undefined, ): Promise { // POSTED customer INVOICEs (optionally one customer). Non-POSTED documents // and CREDIT_MEMOs are not settleable open items on this screen. let docQuery = db .selectFrom("AccountReceivableDocument as doc") .innerJoin("CustomerAccount as account", "account.id", "doc.customerAccountId") .select([ "doc.id as invoiceId", "doc.documentNumber as documentNumber", "doc.companyId as companyId", "doc.currencyId as currencyId", "doc.customerAccountId as customerAccountId", "account.name as customerAccountName", ]) .where("doc.status", "=", "POSTED") .where("doc.documentType", "=", "INVOICE"); if (customerAccountId) { docQuery = docQuery.where("doc.customerAccountId", "=", customerAccountId); } const docs = await docQuery.execute(); if (docs.length === 0) return []; const docById = new Map(docs.map((doc) => [doc.invoiceId, doc])); const dueLines = await db .selectFrom("AccountReceivableDueScheduleLine") .select(["id", "accountReceivableDocumentId", "dueDate", "amount"]) .where( "accountReceivableDocumentId", "in", docs.map((doc) => doc.invoiceId), ) .orderBy("dueDate", "asc") .execute(); if (dueLines.length === 0) return []; // Effective settled amount per due line — mirrors postIncomingPayment: over // settlements whose parent payment is effective (POSTED or REVERSED), an // original row adds and a reversing row (reversalOfSettlementId) subtracts. const settlements = await db .selectFrom("AccountReceivableSettlement as s") .innerJoin("IncomingPayment as p", "p.id", "s.incomingPaymentId") .select([ "s.accountReceivableDueScheduleLineId as dueScheduleLineId", "s.settledAmount as settledAmount", "s.reversalOfSettlementId as reversalOfSettlementId", ]) .where( "s.accountReceivableDueScheduleLineId", "in", dueLines.map((line) => line.id), ) .where("s.sourceType", "=", "INCOMING_PAYMENT") .where("p.status", "in", ["POSTED", "REVERSED"]) .execute(); const settledByDueLine = new Map(); for (const settlement of settlements) { const current = settledByDueLine.get(settlement.dueScheduleLineId) ?? new Decimal(0); const amount = new Decimal(settlement.settledAmount); settledByDueLine.set( settlement.dueScheduleLineId, settlement.reversalOfSettlementId ? current.minus(amount) : current.plus(amount), ); } const epsilon = new Decimal("0.000001"); const items: OpenReceivableLine[] = []; for (const line of dueLines) { const settled = settledByDueLine.get(line.id) ?? new Decimal(0); const open = new Decimal(line.amount).minus(settled); if (open.lte(epsilon)) continue; const doc = docById.get(line.accountReceivableDocumentId); if (!doc) continue; items.push({ dueScheduleLineId: line.id, invoiceId: doc.invoiceId, documentNumber: String(doc.documentNumber), dueDate: toIsoDate(line.dueDate), openAmount: open.toString(), companyId: doc.companyId, currencyId: doc.currencyId, customerAccountId: doc.customerAccountId, customerAccountName: doc.customerAccountName, }); } return items; } export default createResolver({ name: "openAccountReceivables", operation: "query", input: { customerAccountId: t .string({ optional: true }) .description("Restrict to one customer account; omit for all customer accounts"), }, body: async (context) => { const db = getDB("main-db"); const items = await computeOpenReceivables(db, context.input.customerAccountId ?? undefined); return { items }; }, output: t .object({ items: t .object( { dueScheduleLineId: t .string() .description("AR due-schedule line id (the settlement target)"), invoiceId: t.string().description("AccountReceivableDocument id"), documentNumber: t.string().description("Invoice document number"), dueDate: t.string().description("Installment due date (YYYY-MM-DD)"), openAmount: t .string() .description("Remaining open amount (due amount − effective settled)"), companyId: t.string().description("Company id"), currencyId: t.string().description("Transaction currency id"), customerAccountId: t.string().description("Customer account id"), customerAccountName: t.string().description("Customer account name"), }, { array: true }, ) .description("Open (settleable) AR due-schedule lines"), }) .description("openAccountReceivables response"), });