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"; /** * createManualAccountReceivableInvoice — raise a customer invoice NOT tied to a * sales order (services, fees, adjustments, spot sales, …). * * Builds amount-only AR lines — each carries a description and a positive amount * with no source, quantity, unit price, or unit. Every line's full amount is * booked to the chosen distribution (revenue) account; the header carries the * receivable control account. A one-line due schedule (whole amount, due on the * document date) is attached so the invoice can be registered. This is the AR * mirror of `createManualAccountPayableInvoice`. */ export default createResolver({ name: "createManualAccountReceivableInvoice", operation: "mutation", input: { customerAccountId: t.string().description("Customer account the invoice is owed by"), receivableControlAccountId: t.string().description("GL receivable control account (ASSET)"), distributionAccountId: 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"), lines: t .object( { description: t.string().description("Line description (what the charge is for)"), amount: t.string().description("Positive line amount"), }, { array: true }, ) .description("Amount-only invoice lines"), }, body: async (context) => { const ctx = createContext(context); const db = getDB("main-db"); return db .transaction() .execute(async (trx) => { if (context.input.lines.length === 0) { throw new DomainError("At least one line is required."); } const customer = await trx .selectFrom("CustomerAccount") .select(["id", "companyId", "preferredCurrencyId"]) .where("id", "=", context.input.customerAccountId) .executeTakeFirst(); if (!customer) { throw new DomainError(`Customer account not found: ${context.input.customerAccountId}`); } const company = await trx .selectFrom("Company") .select(["baseCurrencyId"]) .where("id", "=", customer.companyId) .executeTakeFirst(); const currencyId = customer.preferredCurrencyId ?? company?.baseCurrencyId ?? null; if (!currencyId) { throw new DomainError( "Could not resolve a currency for the invoice (customer has no preferred currency and the company has no base currency).", ); } let total = new Decimal(0); const lines = context.input.lines.map((line) => { const amount = new Decimal(line.amount); if (amount.lte(0)) { throw new DomainError( `Line amount must be positive: ${line.description || "(no description)"}`, ); } total = total.plus(amount); const gross = amount.toString(); return { netAmount: gross, grossAmount: gross, description: line.description || undefined, distributions: [{ accountId: context.input.distributionAccountId, 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: customer.companyId, customerAccountId: customer.id, currencyId, receivableControlAccountId: context.input.receivableControlAccountId, documentType: "INVOICE", externalDocumentNumber: context.input.externalDocumentNumber ?? undefined, documentDate, postingDate: documentDate, totalAmount, }, lines, 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 customer.`); case "ACCOUNT_RECEIVABLE_CUSTOMER_ACCOUNT_NOT_FOUND": throw new DomainError(`Customer account not found.`); 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: ${currencyId}`); case "ACCOUNT_RECEIVABLE_ACCOUNT_NOT_FOUND": throw new DomainError( `Referenced GL account not found — check the receivable control and distribution 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_SOURCE_INCONSISTENT": throw new DomainError(`Line 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_LINE_AMOUNT_MISMATCH": throw new DomainError(`A line net amount does not equal quantity times unit price.`); case "ACCOUNT_RECEIVABLE_DOCUMENT_LINE_NOT_FOUND": throw new DomainError(`A referenced document line does not exist.`); 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 manual account receivable invoice", { cause: err }); }); }, output: t .object({ id: t.string().description("Account receivable document ID"), status: t.string().description("Document status (DRAFT)"), }) .description("CreateManualAccountReceivableInvoice response"), });