import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import type { Transaction } from "../generated/kysely-tailordb"; import { deriveReceiptDistributions } from "../internal/accrualDistribution"; import { AccountInactiveError, AccountNotFoundError, ApSupplierAccountNotFoundError, ApInvalidSupplierAccountError, ApCurrencyNotFoundError, ApDocumentLineNotFoundError, ApDueScheduleInvalidError, ApInvalidAmountError, ApLineAmountMismatchError, ApLineSourceInconsistentError, ApLineTotalMismatchError, ApMinimumLinesNotMetError, CompanyNotFoundError, } from "../lib/errors.generated"; import type { BusinessPartnerQueries, CoaManagementQueries, InventoryQueries, OrganizationQueries, PrimitivesQueries, PurchaseQueries, } from "../module"; export type AccountPayableDocumentType = "INVOICE" | "CREDIT_MEMO"; export type AccountPayableLineSourceType = "PURCHASE_ORDER"; export interface AccountPayableDocumentLineInput { netAmount: string; taxAmount?: string | null; grossAmount: string; distributions: AccountPayableDistributionLineInput[]; description?: string | null; sourceType?: AccountPayableLineSourceType | null; purchaseOrderLineId?: string | null; quantity?: string | null; unitPrice?: string | null; unitId?: string | null; } export interface AccountPayableDistributionLineInput { accountId: string; amount: string; description?: string | null; } export interface AccountPayableDueScheduleLineInput { dueDate: Date; amount: string; } export interface CreateAccountPayableDocumentHeaderInput { companyId: string; supplierAccountId: string; currencyId: string; payableControlAccountId: string; documentType: AccountPayableDocumentType; externalDocumentNumber?: string | null; documentDate: Date; postingDate: Date; totalAmount: string; description?: string | null; } export interface CreateAccountPayableDocumentInput { header: CreateAccountPayableDocumentHeaderInput; lines: AccountPayableDocumentLineInput[]; dueSchedule?: AccountPayableDueScheduleLineInput[]; } function validatePositiveAmount(identifier: string, amount: string) { if (new Decimal(amount).lte(0)) return err(new ApInvalidAmountError(identifier)); return ok({}); } export interface AccountPayableLineSourceShape { netAmount: string; sourceType?: string | null; purchaseOrderLineId?: string | null; quantity?: string | null; unitPrice?: string | null; unitId?: string | null; } export function validateLineSource(identifier: string, line: AccountPayableLineSourceShape) { const isPurchaseOrder = line.sourceType === "PURCHASE_ORDER"; const hasPurchaseOrderLine = line.purchaseOrderLineId != null; const hasQuantity = line.quantity != null; const hasUnitPrice = line.unitPrice != null; const hasUnit = line.unitId != null; if (!isPurchaseOrder) { if (hasPurchaseOrderLine || hasQuantity || hasUnitPrice || hasUnit) { return err(new ApLineSourceInconsistentError(identifier)); } return ok({}); } if (!hasPurchaseOrderLine || !hasQuantity || !hasUnitPrice || !hasUnit) { return err(new ApLineSourceInconsistentError(identifier)); } const quantity = new Decimal(line.quantity as string); const unitPrice = new Decimal(line.unitPrice as string); if (quantity.lte(0) || unitPrice.lte(0)) return err(new ApInvalidAmountError(identifier)); if (!new Decimal(line.netAmount).eq(quantity.times(unitPrice))) { return err(new ApLineAmountMismatchError(identifier)); } return ok({}); } function validateLineTotal( identifier: string, totalAmount: string, lines: { grossAmount: string }[], ) { const lineTotal = lines.reduce((sum, line) => sum.plus(line.grossAmount), new Decimal(0)); if (!lineTotal.eq(new Decimal(totalAmount))) { return err(new ApLineTotalMismatchError(identifier)); } return ok({}); } function validateDistributionTotal( identifier: string, totalAmount: string, distributions: { amount: string }[], ) { if (distributions.length === 0) return err(new ApMinimumLinesNotMetError(identifier)); const distributionTotal = distributions.reduce( (sum, distribution) => sum.plus(distribution.amount), new Decimal(0), ); if (!distributionTotal.eq(new Decimal(totalAmount))) { return err(new ApLineTotalMismatchError(identifier)); } return ok({}); } export function validateDueSchedule( identifier: string, totalAmount: string, dueSchedule: { amount: string }[], options: { requireLines: boolean }, ) { if (dueSchedule.length === 0) { return options.requireLines ? err(new ApDueScheduleInvalidError(identifier)) : ok({}); } const dueTotal = dueSchedule.reduce((sum, line) => { const amount = new Decimal(line.amount); if (amount.lte(0)) return sum.plus(NaN); return sum.plus(amount); }, new Decimal(0)); if (!dueTotal.isFinite() || !dueTotal.eq(new Decimal(totalAmount))) { return err(new ApDueScheduleInvalidError(identifier)); } return ok({}); } export async function run( db: Transaction, input: Omit & { header: CreateAccountPayableDocumentHeaderInput & HCF; lines: (AccountPayableDocumentLineInput & LCF)[]; }, ctx: CommandContext, organizationQueries: Pick, supplierAccountQueries: Pick, primitivesQueries: Pick, coaManagementQueries: Pick, purchaseQueries: Pick, inventoryQueries: Pick, ) { const { companyId, supplierAccountId, currencyId, payableControlAccountId, documentType, externalDocumentNumber, documentDate, postingDate, totalAmount, description, ...headerCustomFields } = input.header; const { company } = (await organizationQueries.getCompany(db, { id: companyId }, ctx)).value; if (!company) return err(new CompanyNotFoundError(companyId)); const { account: supplierAccount } = ( await supplierAccountQueries.getSupplierAccount(db, { supplierAccountId }, ctx) ).value; if (!supplierAccount) return err(new ApSupplierAccountNotFoundError(supplierAccountId)); if (supplierAccount.companyId !== companyId || supplierAccount.accountStatus !== "ACTIVE") return err(new ApInvalidSupplierAccountError(supplierAccountId)); const { currency } = (await primitivesQueries.getCurrency(db, { id: currencyId }, ctx)).value; if (!currency) return err(new ApCurrencyNotFoundError(currencyId)); if (input.lines.length === 0) return err(new ApMinimumLinesNotMetError("AP document")); const totalResult = validatePositiveAmount("AP document", totalAmount); if (!totalResult.ok) return totalResult; for (const [index, line] of input.lines.entries()) { const identifier = String(index + 1); const netAmountResult = validatePositiveAmount(identifier, line.netAmount); if (!netAmountResult.ok) return netAmountResult; const grossAmountResult = validatePositiveAmount(identifier, line.grossAmount); if (!grossAmountResult.ok) return grossAmountResult; const lineSourceResult = validateLineSource(identifier, line); if (!lineSourceResult.ok) return lineSourceResult; } const lineTotalResult = validateLineTotal("AP document", totalAmount, input.lines); if (!lineTotalResult.ok) return lineTotalResult; const dueScheduleResult = validateDueSchedule( "AP document", totalAmount, input.dueSchedule ?? [], { requireLines: false }, ); if (!dueScheduleResult.ok) return dueScheduleResult; const derivedResult = await deriveReceiptDistributions( db, { companyId, supplierAccountId, lines: input.lines }, ctx, purchaseQueries, inventoryQueries, ); if (!derivedResult.ok) return derivedResult; const derivedByIndex = derivedResult.value; const effectiveDistributions: (AccountPayableDistributionLineInput & { distributionType?: "ACCRUAL" | "INVOICE_PRICE_VARIANCE"; })[][] = input.lines.map((line, index) => [ ...(derivedByIndex.get(index) ?? []), ...line.distributions, ]); const accountIds = [ ...new Set([ payableControlAccountId, ...effectiveDistributions.flatMap((distributions) => distributions.map((distribution) => distribution.accountId), ), ]), ]; const accounts = ( await coaManagementQueries.listAccounts( db, { companyId, accountIds, limit: accountIds.length }, ctx, ) ).value.items; const accountById = new Map(accounts.map((account) => [account.id, account])); for (const accountId of accountIds) { const account = accountById.get(accountId); if (!account) return err(new AccountNotFoundError(accountId)); if (account.status !== "ACTIVE") return err(new AccountInactiveError(accountId)); } for (const [index, line] of input.lines.entries()) { const distributions = effectiveDistributions[index] ?? []; const distributionTotalResult = validateDistributionTotal( String(index + 1), line.grossAmount, distributions, ); if (!distributionTotalResult.ok) return distributionTotalResult; for (const distribution of distributions) { // The derived invoice price variance row is signed. if (distribution.distributionType !== undefined) continue; const amountResult = validatePositiveAmount(distribution.accountId, distribution.amount); if (!amountResult.ok) return amountResult; } } const document = await db .insertInto("AccountPayableDocument") .values({ ...(headerCustomFields as Record), companyId, supplierAccountId, currencyId, payableControlAccountId, documentType, correctionOfId: null, externalDocumentNumber: externalDocumentNumber ?? null, documentDate, postingDate, totalAmount, description: description ?? null, status: "DRAFT", registeredAt: null, postedAt: null, cancelledAt: null, }) .returningAll() .executeTakeFirstOrThrow(); const lines = await db .insertInto("AccountPayableDocumentLine") .values( input.lines.map((line) => { const { netAmount, taxAmount, grossAmount, distributions: _distributions, description, sourceType, purchaseOrderLineId, quantity, unitPrice, unitId, ...lineCustomFields } = line; return { ...(lineCustomFields as Record), accountPayableDocumentId: document.id, netAmount, taxAmount: taxAmount ?? null, grossAmount, correctionOfLineId: null, correctionType: null, description: description ?? null, sourceType: sourceType ?? null, purchaseOrderLineId: purchaseOrderLineId ?? null, quantity: quantity ?? null, unitPrice: unitPrice ?? null, unitId: unitId ?? null, }; }), ) .returningAll() .execute(); const distributions = []; for (const index of input.lines.keys()) { const insertedLine = lines[index]; if (!insertedLine) return err(new ApDocumentLineNotFoundError(String(index + 1))); distributions.push( ...(effectiveDistributions[index] ?? []).map((distribution) => ({ accountPayableDocumentLineId: insertedLine.id, accountId: distribution.accountId, amount: distribution.amount, distributionType: distribution.distributionType ?? ("MANUAL" as const), correctionOfDistributionLineId: null, description: distribution.description ?? null, })), ); } await db.insertInto("AccountPayableDistributionLine").values(distributions).execute(); if (input.dueSchedule && input.dueSchedule.length > 0) { await db .insertInto("AccountPayableDueScheduleLine") .values( input.dueSchedule.map((line) => ({ accountPayableDocumentId: document.id, dueDate: line.dueDate, amount: line.amount, })), ) .execute(); } return ok({ accountPayableDocument: document }); }