import { createContext, DomainError } from "@tailor-platform/erp-kit/app"; import { createResolver, t } from "@tailor-platform/sdk"; import { Decimal } from "decimal.js"; import { getDB } from "@/generated/kysely-tailordb"; import { arModulesExport } from "@/modules"; /** * createAccountReceivableInvoice — app-layer "raise a customer invoice from a * sales order". * * Customer invoices are captured as native AccountReceivableDocument rows * (documentType = INVOICE). This resolver builds a DRAFT invoice from a * CONFIRMED (or CLOSED) sales order: every sales-order line becomes a * SALES_ORDER-sourced AR line (net = quantity × unitPrice, no tax, so gross = * net) whose whole amount is booked to the chosen revenue account. The header * carries the receivable control account. A one-line due schedule (whole * amount, due on the document date) is always attached because * registerAccountReceivableDocument requires a non-empty due schedule. * * Unlike the AP side there is no valuation-policy-derived distribution account: * AR has no inventory integration, so the caller supplies the revenue account * every line is booked to. This is the AR mirror of `createAccountPayableDocument`. */ export default createResolver({ name: "createAccountReceivableInvoice", operation: "mutation", input: { salesOrderId: t.string().description("Source sales order ID (must be CONFIRMED or CLOSED)"), receivableControlAccountId: t.string().description("GL receivable control account (ASSET)"), revenueAccountId: t.string().description("GL revenue account each line's amount is booked to"), documentDate: t .string({ optional: true }) .description("Invoice document date (YYYY-MM-DD); defaults to today"), externalDocumentNumber: t.string({ optional: true }).description("External invoice number"), }, body: async (context) => { const ctx = createContext(context); const db = getDB("main-db"); return db .transaction() .execute(async (trx) => { const so = await trx .selectFrom("SalesOrder") .select(["id", "companyId", "customerAccountId", "currencyId", "orderStatus"]) .where("id", "=", context.input.salesOrderId) .executeTakeFirst(); if (!so) { throw new DomainError(`Sales order not found: ${context.input.salesOrderId}`); } // A customer invoice is only meaningful once the order has been confirmed. // DRAFT/SUBMITTED aren't committed yet; CANCELLED can't be billed. if (so.orderStatus !== "CONFIRMED" && so.orderStatus !== "CLOSED") { throw new DomainError( `Sales order ${context.input.salesOrderId} is ${so.orderStatus}; only CONFIRMED (or CLOSED) orders can be invoiced.`, ); } const soLines = await trx .selectFrom("SalesOrderLine") .select(["id", "itemId", "quantity", "unitPrice", "unitId", "description"]) .where("salesOrderId", "=", context.input.salesOrderId) .execute(); if (soLines.length === 0) { throw new DomainError( `Sales order ${context.input.salesOrderId} has no lines to invoice.`, ); } // A SALES_ORDER-sourced AR line must carry a unit. Older sales-order lines // may have been saved without one, so fall back to the item's base unit. // The item name doubles as the line description when the order line has none. const lineItemIds = [...new Set(soLines.map((line) => line.itemId).filter(Boolean))]; const itemRows = lineItemIds.length ? await trx .selectFrom("Item") .select(["id", "unitId", "name"]) .where("id", "in", lineItemIds) .execute() : []; const itemUnitMap = new Map(itemRows.map((row) => [row.id, row.unitId])); const itemNameMap = new Map(itemRows.map((row) => [row.id, row.name])); let total = new Decimal(0); const lines = soLines.map((line) => { // A SALES_ORDER-sourced line must carry its unit; without it the native // command rejects the line as source-inconsistent. Prefer the line's own // unit, then the item's base unit. const unitId = line.unitId ?? itemUnitMap.get(line.itemId) ?? null; if (!unitId) { throw new DomainError( `Sales order line ${line.id} has no unit (and its item has no base unit), which this resolver requires to invoice it.`, ); } const net = new Decimal(line.quantity).times(line.unitPrice); total = total.plus(net); const gross = net.toString(); return { netAmount: gross, grossAmount: gross, sourceType: "SALES_ORDER" as const, salesOrderLineId: line.id, quantity: String(line.quantity), unitPrice: String(line.unitPrice), unitId, description: line.description ?? itemNameMap.get(line.itemId) ?? undefined, distributions: [{ accountId: context.input.revenueAccountId, amount: gross }], }; }); const documentDate = context.input.documentDate ? new Date(context.input.documentDate) : new Date(); const totalAmount = total.toString(); const result = await arModulesExport.commands.createAccountReceivableDocument( trx, { header: { companyId: so.companyId, customerAccountId: so.customerAccountId, currencyId: so.currencyId, receivableControlAccountId: context.input.receivableControlAccountId, documentType: "INVOICE", externalDocumentNumber: context.input.externalDocumentNumber ?? undefined, documentDate, postingDate: documentDate, totalAmount, }, lines, // register requires a non-empty due schedule; a single installment for // the whole amount, due on the document date, is the sane default. dueSchedule: [{ dueDate: documentDate, amount: totalAmount }], }, ctx, ); if (!result.ok) { switch (result.error.code) { case "ACCOUNT_RECEIVABLE_COMPANY_NOT_FOUND": throw new DomainError(`Company not found for the sales order.`); case "ACCOUNT_RECEIVABLE_CUSTOMER_ACCOUNT_NOT_FOUND": throw new DomainError(`Customer account not found for the sales order.`); case "ACCOUNT_RECEIVABLE_INVALID_CUSTOMER_ACCOUNT": throw new DomainError( "The customer account is not valid for this company or is inactive.", ); case "ACCOUNT_RECEIVABLE_CURRENCY_NOT_FOUND": throw new DomainError(`Currency not found: ${so.currencyId}`); case "ACCOUNT_RECEIVABLE_ACCOUNT_NOT_FOUND": throw new DomainError( `Referenced GL account not found — check the receivable control and revenue accounts.`, ); case "ACCOUNT_RECEIVABLE_ACCOUNT_INACTIVE": throw new DomainError(`Referenced GL account is not ACTIVE.`); case "ACCOUNT_RECEIVABLE_INVALID_AMOUNT": throw new DomainError(`Invalid amount: ${result.error.message}`); case "ACCOUNT_RECEIVABLE_LINE_TOTAL_MISMATCH": throw new DomainError( `Line/distribution totals do not reconcile: ${result.error.message}`, ); case "ACCOUNT_RECEIVABLE_LINE_AMOUNT_MISMATCH": throw new DomainError(`A line net amount does not equal quantity × unit price.`); case "ACCOUNT_RECEIVABLE_LINE_SOURCE_INCONSISTENT": throw new DomainError(`Line sales-order source fields are inconsistent.`); case "ACCOUNT_RECEIVABLE_DUE_SCHEDULE_INVALID": throw new DomainError(`Due schedule is invalid.`); case "ACCOUNT_RECEIVABLE_MINIMUM_LINES_NOT_MET": throw new DomainError( `The invoice must have at least one line with one distribution.`, ); case "ACCOUNT_RECEIVABLE_DOCUMENT_LINE_NOT_FOUND": throw new DomainError("A referenced document line was not found."); case "UNAUTHENTICATED": throw new DomainError("Authentication is required"); case "INSUFFICIENT_PERMISSION": throw new DomainError("You do not have permission to perform this action"); default: throw result.error satisfies never; } } return { id: result.value.accountReceivableDocument.id, status: result.value.accountReceivableDocument.status, }; }) .catch((err: unknown) => { if (err instanceof DomainError) throw err; throw new Error("Failed to create the account receivable invoice", { cause: err }); }); }, output: t .object({ id: t.string().description("Account receivable document ID"), status: t.string().description("Document status (DRAFT)"), }) .description("CreateAccountReceivableInvoice response"), });