import { createResolver, t } from "@tailor-platform/sdk"; import { getDB } from "@/generated/kysely-tailordb"; /** * BFF resolver: given a business document's human-readable number, return ALL * journal entries that document produced — as flat rows grouped by * `journalEntryId`, the same shape as `journalLinesGroupedByEntryId` so the same * table/modal UI can render them. * * A journal entry only ever references its *direct* accounting driver via * (`sourceDocumentType`, `sourceDocumentId`). Business documents reach the GL by * different paths, so this resolver resolves the number to that driver set: * * - ACCOUNT_PAYABLE_DOCUMENT: direct — the posting writes a journal * with sourceDocumentType ACCOUNT_PAYABLE_DOCUMENT, sourceDocumentId = the * invoice id. * - INBOUND/OUTBOUND_SHIPMENT, TRANSFER_ORDER: one hop — the * movement posts InventoryLedger rows (sourceType/sourceId), and the costing * journal references the ledger (sourceDocumentType INVENTORY_LEDGER). * - PURCHASE_ORDER: a PO itself has no journal (ordering is not an * accounting event). Its journals are the union of the AP journals of the * invoices billed from its lines and the costing journals of the receipts * raised against its lines. * * - ACCOUNT_RECEIVABLE_DOCUMENT: direct — posting a customer invoice writes a * journal with sourceDocumentType SALES_INVOICE, sourceDocumentId = the AR * document id (AR has no inventory integration, so no cost-adjustment hop). * * STOCK_ADJUSTMENT and the payment documents (INCOMING_PAYMENT / OUTGOING_PAYMENT) * are intentionally unsupported: they have no human-readable document number to * look up. * * TailorDB's SQL layer has no DISTINCT / correlated subqueries, so every hop is * a plain two-step `id IN (...)` lookup (the same shape used across erp-kit). */ type MainDbHandle = ReturnType>; const DOCUMENT_TYPES = [ "ACCOUNT_PAYABLE_DOCUMENT", "ACCOUNT_RECEIVABLE_DOCUMENT", "PURCHASE_ORDER", "INBOUND_SHIPMENT", "OUTBOUND_SHIPMENT", "TRANSFER_ORDER", ] as const; type DocumentType = (typeof DOCUMENT_TYPES)[number]; type JournalSourceType = | "ACCOUNT_PAYABLE_DOCUMENT" | "SALES_INVOICE" | "INVENTORY_LEDGER" | "ACQUISITION_COST_ADJUSTMENT"; type InventoryMovementType = "INBOUND_SHIPMENT" | "OUTBOUND_SHIPMENT" | "TRANSFER_ORDER"; interface JournalLineRow { lineId: string; journalEntryId: string; accountId: string; accountCode: string; accountName: string; debitAmount: string | null; creditAmount: string | null; lineDescription: string | null; entryDate: string; status: string; accountingPeriodId: string; entryDescription: string | null; sourceDocumentType: string | null; sourceDocumentId: string | null; postedAt: string | null; } const uniq = (ids: string[]): string[] => [...new Set(ids)]; const toIso = (value: unknown): string | null => { if (value == null) return null; if (value instanceof Date) return value.toISOString(); return typeof value === "string" ? value : null; }; // Journal entries whose direct source is (type, id ∈ ids), company-scoped. async function journalEntryIdsBySource( db: MainDbHandle, companyId: string, sourceDocumentType: JournalSourceType, sourceDocumentIds: string[], ): Promise { const ids = uniq(sourceDocumentIds); if (ids.length === 0) return []; const rows = await db .selectFrom("JournalEntry") .select("id") .where("companyId", "=", companyId) .where("sourceDocumentType", "=", sourceDocumentType) .where("sourceDocumentId", "in", ids) .execute(); return rows.map((r) => r.id); } // Costing journals for an inventory movement: movement doc ids -> ledger rows -> // INVENTORY_LEDGER journals. async function inventoryLedgerJournalIds( db: MainDbHandle, companyId: string, ledgerSourceType: InventoryMovementType, movementDocIds: string[], ): Promise { const docIds = uniq(movementDocIds); if (docIds.length === 0) return []; const ledgerRows = await db .selectFrom("InventoryLedger") .select("id") .where("sourceType", "=", ledgerSourceType) .where("sourceId", "in", docIds) .execute(); const ledgerIds = ledgerRows.map((r) => r.id); return journalEntryIdsBySource(db, companyId, "INVENTORY_LEDGER", ledgerIds); } // Acquisition cost adjustment journals for account-payable documents (erp-kit // 0.50). Posting an invoice trues up the receipt cost from the provisional // order price to the invoiced price. That truing-up is a SEPARATE journal from // the AP posting: AcquisitionCostAdjustment rows reference the invoice // (sourceType ACCOUNT_PAYABLE_DOCUMENT, sourceId = invoice id), and the costing // journal references the adjustment (sourceDocumentType // ACQUISITION_COST_ADJUSTMENT). Two hops: invoice ids -> adjustment ids -> // journals. Only produced when the invoiced price differs from the order price. async function acquisitionCostAdjustmentJournalIds( db: MainDbHandle, companyId: string, accountPayableDocumentIds: string[], ): Promise { const docIds = uniq(accountPayableDocumentIds); if (docIds.length === 0) return []; const adjustmentRows = await db .selectFrom("AcquisitionCostAdjustment") .select("id") .where("sourceType", "=", "ACCOUNT_PAYABLE_DOCUMENT") .where("sourceId", "in", docIds) .execute(); return journalEntryIdsBySource( db, companyId, "ACQUISITION_COST_ADJUSTMENT", adjustmentRows.map((r) => r.id), ); } async function resolveEntryIds( db: MainDbHandle, companyId: string, documentType: DocumentType, documentNumber: string, ): Promise { switch (documentType) { case "ACCOUNT_PAYABLE_DOCUMENT": { const doc = await db .selectFrom("AccountPayableDocument") .select("id") .where("companyId", "=", companyId) .where("documentNumber", "=", documentNumber) .executeTakeFirst(); if (!doc) return []; // (a) the AP posting journal, and (b) the acquisition cost adjustment // journal(s) this invoice booked when its price differed from the order. const apEntryIds = await journalEntryIdsBySource(db, companyId, "ACCOUNT_PAYABLE_DOCUMENT", [ doc.id, ]); const adjustmentEntryIds = await acquisitionCostAdjustmentJournalIds(db, companyId, [doc.id]); return uniq([...apEntryIds, ...adjustmentEntryIds]); } case "ACCOUNT_RECEIVABLE_DOCUMENT": { // Direct — posting a customer invoice writes a journal with // sourceDocumentType SALES_INVOICE, sourceDocumentId = the AR document id. // AR has no inventory integration, so there is no cost-adjustment hop. const doc = await db .selectFrom("AccountReceivableDocument") .select("id") .where("companyId", "=", companyId) .where("documentNumber", "=", documentNumber) .executeTakeFirst(); if (!doc) return []; return journalEntryIdsBySource(db, companyId, "SALES_INVOICE", [doc.id]); } case "INBOUND_SHIPMENT": { const doc = await db .selectFrom("InboundShipment") .select("id") .where("docNumber", "=", documentNumber) .executeTakeFirst(); if (!doc) return []; return inventoryLedgerJournalIds(db, companyId, "INBOUND_SHIPMENT", [doc.id]); } case "OUTBOUND_SHIPMENT": { const doc = await db .selectFrom("OutboundShipment") .select("id") .where("docNumber", "=", documentNumber) .executeTakeFirst(); if (!doc) return []; return inventoryLedgerJournalIds(db, companyId, "OUTBOUND_SHIPMENT", [doc.id]); } case "TRANSFER_ORDER": { const doc = await db .selectFrom("TransferOrder") .select("id") .where("docNumber", "=", documentNumber) .executeTakeFirst(); if (!doc) return []; return inventoryLedgerJournalIds(db, companyId, "TRANSFER_ORDER", [doc.id]); } case "PURCHASE_ORDER": { const po = await db .selectFrom("PurchaseOrder") .select("id") .where("companyId", "=", companyId) .where("docNumber", "=", documentNumber) .executeTakeFirst(); if (!po) return []; const poLineRows = await db .selectFrom("PurchaseOrderLine") .select("id") .where("purchaseOrderId", "=", po.id) .execute(); const poLineIds = poLineRows.map((r) => r.id); if (poLineIds.length === 0) return []; // (a) AP journals of the invoices billed from these PO lines. const apLineRows = await db .selectFrom("AccountPayableDocumentLine") .select("accountPayableDocumentId") .where("purchaseOrderLineId", "in", poLineIds) .execute(); const apDocIds = apLineRows.map((r) => r.accountPayableDocumentId); const apEntryIds = await journalEntryIdsBySource( db, companyId, "ACCOUNT_PAYABLE_DOCUMENT", apDocIds, ); // Acquisition cost adjustment journals booked by those invoices (0.50). const adjustmentEntryIds = await acquisitionCostAdjustmentJournalIds(db, companyId, apDocIds); // (b) Costing journals of the receipts raised against these PO lines. const ishLineRows = await db .selectFrom("InboundShipmentLine") .select("inboundShipmentId") .where("sourceDocumentId", "in", poLineIds) .execute(); const receiptEntryIds = await inventoryLedgerJournalIds( db, companyId, "INBOUND_SHIPMENT", ishLineRows.map((r) => r.inboundShipmentId), ); return uniq([...apEntryIds, ...adjustmentEntryIds, ...receiptEntryIds]); } } } async function loadRowsForEntryIds(db: MainDbHandle, entryIds: string[]) { const ids = uniq(entryIds); if (ids.length === 0) { return { hasNextPage: false, entryCount: 0, total: 0, rows: [] as JournalLineRow[] }; } // Order entries newest-first so the newest journal shows on top. const ordered = await db .selectFrom("JournalEntry") .select("id") .where("id", "in", ids) .orderBy("entryDate", "desc") .orderBy("id", "desc") .execute(); const orderedIds = ordered.map((r) => r.id); const rawRows = await db .selectFrom("JournalLine") .innerJoin("JournalEntry", "JournalEntry.id", "JournalLine.journalEntryId") .innerJoin("Account", "Account.id", "JournalLine.accountId") .where("JournalLine.journalEntryId", "in", orderedIds) .select([ "JournalLine.id as lineId", "JournalLine.journalEntryId as journalEntryId", "JournalLine.accountId as accountId", "Account.code as accountCode", "Account.name as accountName", "JournalLine.debitAmount as debitAmount", "JournalLine.creditAmount as creditAmount", "JournalLine.description as lineDescription", "JournalEntry.entryDate as entryDate", "JournalEntry.status as status", "JournalEntry.accountingPeriodId as accountingPeriodId", "JournalEntry.description as entryDescription", "JournalEntry.sourceDocumentType as sourceDocumentType", "JournalEntry.sourceDocumentId as sourceDocumentId", "JournalEntry.postedAt as postedAt", ]) .orderBy("JournalLine.createdAt", "asc") .orderBy("JournalLine.id", "asc") .execute(); // A SQL `IN` does not preserve order, so re-group in JS to the entry order. const entryOrder = new Map(orderedIds.map((id, index) => [id, index])); rawRows.sort( (a, b) => (entryOrder.get(a.journalEntryId) ?? 0) - (entryOrder.get(b.journalEntryId) ?? 0), ); const rows: JournalLineRow[] = rawRows.map((r) => ({ lineId: r.lineId, journalEntryId: r.journalEntryId, accountId: r.accountId, accountCode: r.accountCode, accountName: r.accountName, debitAmount: r.debitAmount ?? null, creditAmount: r.creditAmount ?? null, lineDescription: r.lineDescription ?? null, entryDate: toIso(r.entryDate) ?? "", status: r.status, accountingPeriodId: r.accountingPeriodId, entryDescription: r.entryDescription ?? null, sourceDocumentType: r.sourceDocumentType ?? null, sourceDocumentId: r.sourceDocumentId ?? null, postedAt: toIso(r.postedAt), })); return { hasNextPage: false, entryCount: orderedIds.length, total: orderedIds.length, rows }; } export default createResolver({ name: "journalEntriesForDocument", operation: "query", input: { companyId: t.string().description("Company whose journal to search"), documentType: t.string().description(`Source document type: ${DOCUMENT_TYPES.join(" | ")}`), documentNumber: t .string() .description("The document's human-readable number (documentNumber / docNumber)"), }, body: async (context) => { const db = getDB("main-db"); const documentType = context.input.documentType; if (!DOCUMENT_TYPES.includes(documentType as DocumentType)) { throw new Error( `Invalid documentType: ${documentType}. Expected one of ${DOCUMENT_TYPES.join(", ")}.`, ); } const entryIds = await resolveEntryIds( db, context.input.companyId, documentType as DocumentType, context.input.documentNumber, ); return loadRowsForEntryIds(db, entryIds); }, output: t .object({ hasNextPage: t.bool().description("Always false — every related entry is returned"), entryCount: t.int().description("Number of related journal entries"), total: t.int().description("Number of related journal entries (same as entryCount)"), rows: t .object( { lineId: t.string().description("Journal line ID"), journalEntryId: t.string().description("Owning journal entry ID (group key)"), accountId: t.string().description("GL account ID"), accountCode: t.string().description("GL account code"), accountName: t.string().description("GL account name"), debitAmount: t.string({ optional: true }).description("Debit amount (decimal string)"), creditAmount: t .string({ optional: true }) .description("Credit amount (decimal string)"), lineDescription: t.string({ optional: true }).description("Line description"), entryDate: t.string().description("Entry date (ISO 8601)"), status: t.string().description("Entry status: DRAFT | CANCELLED | POSTED"), accountingPeriodId: t.string().description("Accounting period ID"), entryDescription: t.string({ optional: true }).description("Entry description"), sourceDocumentType: t .string({ optional: true }) .description( "Entry source document type (ACCOUNT_PAYABLE_DOCUMENT | INVENTORY_LEDGER | ACQUISITION_COST_ADJUSTMENT)", ), sourceDocumentId: t.string({ optional: true }).description("Entry source document ID"), postedAt: t.string({ optional: true }).description("When the entry was posted (ISO)"), }, { array: true }, ) .description("Flat journal-line rows, contiguous per journalEntryId (grouped by entry)"), }) .description("All journal entries produced by a business document, grouped by entry"), });