import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import type { Transaction, Updateable } from "../generated/kysely-tailordb"; import { deriveReceiptDistributions } from "../internal/accrualDistribution"; import { AccountCompanyMismatchError, AccountInactiveError, AccountNotFoundError, ApCurrencyNotFoundError, ApDerivedDistributionImmutableError, ApDocumentLineNotFoundError, ApDocumentNotFoundError, ApSupplierAccountNotFoundError, ApInvalidSupplierAccountError, ApInvalidAmountError, ApInvalidDocumentStatusError, ApLineEditConflictError, ApLineTotalMismatchError, ApMinimumLinesNotMetError, } from "../lib/errors.generated"; import type { BusinessPartnerQueries, CoaManagementQueries, InventoryQueries, PrimitivesQueries, PurchaseQueries, } from "../module"; import { type AccountPayableDistributionLineInput, type AccountPayableDocumentLineInput, type AccountPayableDocumentType, type AccountPayableDueScheduleLineInput, validateDueSchedule, validateLineSource, } from "./createAccountPayableDocument"; export type AccountPayableDocumentHeaderPatch = { supplierAccountId?: string; currencyId?: string; payableControlAccountId?: string; documentType?: AccountPayableDocumentType; externalDocumentNumber?: string | null; documentDate?: Date; postingDate?: Date; totalAmount?: string; description?: string | null; }; export type AccountPayableDocumentLinePatch = Partial< Omit >; export type AccountPayableDistributionLinePatch = Partial; export interface AccountPayableDocumentLineEdit { lineId: string; patch?: AccountPayableDocumentLinePatch; addDistributions?: AccountPayableDistributionLineInput[]; updateDistributions?: AccountPayableDistributionLineEdit[]; removeDistributionLineIds?: string[]; } export interface AccountPayableDistributionLineEdit { distributionLineId: string; patch: AccountPayableDistributionLinePatch; } export interface UpdateAccountPayableDocumentInput { id: string; headerPatch?: AccountPayableDocumentHeaderPatch; addLines?: AccountPayableDocumentLineInput[]; updateLines?: AccountPayableDocumentLineEdit[]; removeLineIds?: string[]; dueSchedule?: AccountPayableDueScheduleLineInput[]; } function validatePositiveAmount(identifier: string, amount: string) { if (new Decimal(amount).lte(0)) return err(new ApInvalidAmountError(identifier)); return ok({}); } function validateLineTotal( identifier: string, totalAmount: string, lines: { grossAmount: string }[], ) { if (lines.length === 0) return err(new ApMinimumLinesNotMetError(identifier)); 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 async function run( db: Transaction, input: Omit & { headerPatch?: AccountPayableDocumentHeaderPatch & Partial; addLines?: (AccountPayableDocumentLineInput & LCF)[]; updateLines?: (Omit & { patch?: AccountPayableDocumentLinePatch & Partial; })[]; }, ctx: CommandContext, primitivesQueries: Pick, coaManagementQueries: Pick, purchaseQueries: Pick, inventoryQueries: Pick, supplierAccountQueries: Pick, ) { const { id, headerPatch, addLines = [], updateLines = [], removeLineIds = [] } = input; const document = await db .selectFrom("AccountPayableDocument") .selectAll() .where("id", "=", id) .forUpdate() .executeTakeFirst(); if (!document) return err(new ApDocumentNotFoundError(id)); if (document.status !== "DRAFT") return err(new ApInvalidDocumentStatusError(id)); if (headerPatch?.supplierAccountId !== undefined) { const { account } = ( await supplierAccountQueries.getSupplierAccount( db, { supplierAccountId: headerPatch.supplierAccountId }, ctx, ) ).value; if (!account) return err(new ApSupplierAccountNotFoundError(headerPatch.supplierAccountId)); if (account.companyId !== document.companyId || account.accountStatus !== "ACTIVE") return err(new ApInvalidSupplierAccountError(account.id)); } if (headerPatch?.currencyId !== undefined) { const { currency } = ( await primitivesQueries.getCurrency(db, { id: headerPatch.currencyId }, ctx) ).value; if (!currency) return err(new ApCurrencyNotFoundError(headerPatch.currencyId)); } if (headerPatch?.payableControlAccountId !== undefined) { const account = ( await coaManagementQueries.getAccount(db, { id: headerPatch.payableControlAccountId }, ctx) ).value.account; if (!account) { return err(new AccountNotFoundError(headerPatch.payableControlAccountId)); } if (account.companyId !== document.companyId) { return err(new AccountCompanyMismatchError(headerPatch.payableControlAccountId)); } if (account.status !== "ACTIVE") { return err(new AccountInactiveError(headerPatch.payableControlAccountId)); } } const currentLines = await db .selectFrom("AccountPayableDocumentLine") .selectAll() .where("accountPayableDocumentId", "=", id) .execute(); const currentLineById = new Map(currentLines.map((line) => [line.id, line])); for (const { lineId } of updateLines) { if (!currentLineById.has(lineId)) return err(new ApDocumentLineNotFoundError(lineId)); } for (const lineId of removeLineIds) { if (!currentLineById.has(lineId)) return err(new ApDocumentLineNotFoundError(lineId)); } const currentDistributions = await db .selectFrom("AccountPayableDistributionLine as distribution") .innerJoin( "AccountPayableDocumentLine as line", "line.id", "distribution.accountPayableDocumentLineId", ) .selectAll("distribution") .where("line.accountPayableDocumentId", "=", id) .execute(); const currentDistributionById = new Map( currentDistributions.map((distribution) => [distribution.id, distribution]), ); const distributionUpdates: AccountPayableDistributionLineEdit[] = []; const distributionIdsToRemove: string[] = []; const distributionsToAddToExistingLines: (AccountPayableDistributionLineInput & { accountPayableDocumentLineId: string; distributionType?: "ACCRUAL" | "INVOICE_PRICE_VARIANCE"; })[] = []; for (const edit of updateLines) { for (const distributionUpdate of edit.updateDistributions ?? []) { const distribution = currentDistributionById.get(distributionUpdate.distributionLineId); if (!distribution || distribution.accountPayableDocumentLineId !== edit.lineId) { return err(new ApDocumentLineNotFoundError(distributionUpdate.distributionLineId)); } // Derived distributions are recomputed from their line, never edited. if (distribution.distributionType !== "MANUAL") { return err(new ApDerivedDistributionImmutableError(distribution.id)); } distributionUpdates.push(distributionUpdate); } for (const distributionLineId of edit.removeDistributionLineIds ?? []) { const distribution = currentDistributionById.get(distributionLineId); if (!distribution || distribution.accountPayableDocumentLineId !== edit.lineId) { return err(new ApDocumentLineNotFoundError(distributionLineId)); } if (distribution.distributionType !== "MANUAL") { return err(new ApDerivedDistributionImmutableError(distribution.id)); } distributionIdsToRemove.push(distributionLineId); } distributionsToAddToExistingLines.push( ...(edit.addDistributions ?? []).map((distribution) => ({ ...distribution, accountPayableDocumentLineId: edit.lineId, })), ); } const removeLineIdSet = new Set(removeLineIds); for (const { lineId } of updateLines) { if (removeLineIdSet.has(lineId)) return err(new ApLineEditConflictError(lineId)); } const updateLineById = new Map(updateLines.map((edit) => [edit.lineId, edit.patch])); const retainedLines = currentLines .filter((line) => !removeLineIdSet.has(line.id)) .map((line) => { const patch = updateLineById.get(line.id); return patch ? { ...line, ...patch } : line; }); const effectiveLines = [...retainedLines, ...addLines]; const effectiveTotal = headerPatch?.totalAmount ?? document.totalAmount; const totalResult = validatePositiveAmount(id, effectiveTotal); if (!totalResult.ok) return totalResult; for (const [index, line] of effectiveLines.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(id, effectiveTotal, effectiveLines); if (!lineTotalResult.ok) return lineTotalResult; // A patch touching a derivation input invalidates the line's derived // accrual distribution: its stale rows drop out here and the line // re-derives below. const rederiveLineIds = new Set( updateLines .filter( ({ patch }) => patch !== undefined && ("netAmount" in patch || "sourceType" in patch || "purchaseOrderLineId" in patch || "quantity" in patch || "unitPrice" in patch), ) .map(({ lineId }) => lineId), ); for (const distribution of currentDistributions) { if ( distribution.distributionType !== "MANUAL" && rederiveLineIds.has(distribution.accountPayableDocumentLineId) ) { distributionIdsToRemove.push(distribution.id); } } const removeDistributionLineIdSet = new Set(distributionIdsToRemove); const updateDistributionById = new Map( distributionUpdates.map((edit) => [edit.distributionLineId, edit.patch]), ); const effectiveDistributions = currentDistributions .filter( (distribution) => !removeLineIdSet.has(distribution.accountPayableDocumentLineId) && !removeDistributionLineIdSet.has(distribution.id), ) .map((distribution) => { const patch = updateDistributionById.get(distribution.id); return patch ? { ...distribution, ...patch } : distribution; }); // Patched lines re-derive alongside added lines; distributions the caller // adds to an existing line need no derivation of their own. const rederiveLines = retainedLines.filter((line) => rederiveLineIds.has(line.id)); const derivedResult = await deriveReceiptDistributions( db, { companyId: document.companyId, supplierAccountId: headerPatch?.supplierAccountId ?? document.supplierAccountId, lines: [...rederiveLines, ...addLines], validationLines: effectiveLines, }, ctx, purchaseQueries, inventoryQueries, ); if (!derivedResult.ok) return derivedResult; const derivedByIndex = derivedResult.value; for (const [index, line] of rederiveLines.entries()) { for (const derived of derivedByIndex.get(index) ?? []) { distributionsToAddToExistingLines.push({ ...derived, accountPayableDocumentLineId: line.id, }); } } const addLineDistributions: (AccountPayableDistributionLineInput & { distributionType?: "ACCRUAL" | "INVOICE_PRICE_VARIANCE"; })[][] = addLines.map((line, index) => [ ...(derivedByIndex.get(rederiveLines.length + index) ?? []), ...line.distributions, ]); const addedDistributions = [...addLineDistributions.flat(), ...distributionsToAddToExistingLines]; for (const distribution of [...effectiveDistributions, ...addedDistributions]) { // The derived invoice price variance row is signed. if (distribution.distributionType !== undefined && distribution.distributionType !== "MANUAL") { continue; } const amountResult = validatePositiveAmount(distribution.accountId, distribution.amount); if (!amountResult.ok) return amountResult; } const accountIds = [ ...new Set( [...effectiveDistributions, ...addedDistributions].map( (distribution) => distribution.accountId, ), ), ]; const accounts = ( await coaManagementQueries.listAccounts( db, { companyId: document.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)); } const distributionTotalResult = validateDistributionTotal(id, effectiveTotal, [ ...effectiveDistributions, ...addedDistributions, ]); if (!distributionTotalResult.ok) return distributionTotalResult; const dueScheduleResult = validateDueSchedule(id, effectiveTotal, input.dueSchedule ?? [], { requireLines: false, }); if (!dueScheduleResult.ok) return dueScheduleResult; const effectiveDistributionsByLineId = new Map(); for (const distribution of [...effectiveDistributions, ...distributionsToAddToExistingLines]) { const distributions = effectiveDistributionsByLineId.get( distribution.accountPayableDocumentLineId, ); if (distributions) distributions.push(distribution); else effectiveDistributionsByLineId.set(distribution.accountPayableDocumentLineId, [distribution]); } // Aligned with effectiveLines, which is retainedLines followed by addLines. const effectiveLineDistributions = [ ...retainedLines.map((line) => effectiveDistributionsByLineId.get(line.id) ?? []), ...addLineDistributions, ]; for (const [index, line] of effectiveLines.entries()) { const lineDistributionTotalResult = validateDistributionTotal( String(index + 1), line.grossAmount, effectiveLineDistributions[index] ?? [], ); if (!lineDistributionTotalResult.ok) return lineDistributionTotalResult; } const { supplierAccountId, currencyId, payableControlAccountId, documentType, externalDocumentNumber, documentDate, postingDate, totalAmount, description, ...headerCustomFields } = headerPatch ?? {}; const updateData: Updateable<"AccountPayableDocument"> = { ...(headerCustomFields as Updateable<"AccountPayableDocument">), }; if (supplierAccountId !== undefined) updateData.supplierAccountId = supplierAccountId; if (currencyId !== undefined) updateData.currencyId = currencyId; if (payableControlAccountId !== undefined) { updateData.payableControlAccountId = payableControlAccountId; } if (documentType !== undefined) updateData.documentType = documentType; if (externalDocumentNumber !== undefined) { updateData.externalDocumentNumber = externalDocumentNumber; } if (documentDate !== undefined) updateData.documentDate = documentDate; if (postingDate !== undefined) updateData.postingDate = postingDate; if (totalAmount !== undefined) updateData.totalAmount = totalAmount; if (description !== undefined) updateData.description = description; const updatedDocument = Object.keys(updateData).length === 0 ? document : await db .updateTable("AccountPayableDocument") .set(updateData) .where("id", "=", id) .returningAll() .executeTakeFirstOrThrow(); for (const { lineId, patch } of updateLines) { if (!patch || Object.keys(patch).length === 0) continue; await db .updateTable("AccountPayableDocumentLine") .set(patch as Updateable<"AccountPayableDocumentLine">) .where("id", "=", lineId) .execute(); } if (removeLineIds.length > 0) { await db .deleteFrom("AccountPayableDistributionLine") .where("accountPayableDocumentLineId", "in", removeLineIds) .execute(); await db.deleteFrom("AccountPayableDocumentLine").where("id", "in", removeLineIds).execute(); } let insertedLines: typeof currentLines = []; if (addLines.length > 0) { insertedLines = await db .insertInto("AccountPayableDocumentLine") .values( addLines.map((line) => { const { netAmount, taxAmount, grossAmount, distributions: _distributions, description, sourceType, purchaseOrderLineId, quantity, unitPrice, unitId, ...lineCustomFields } = line; return { ...(lineCustomFields as Record), accountPayableDocumentId: 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(); } for (const { distributionLineId, patch } of distributionUpdates) { if (Object.keys(patch).length === 0) continue; const updateValues: Updateable<"AccountPayableDistributionLine"> = { ...(patch as Updateable<"AccountPayableDistributionLine">), }; await db .updateTable("AccountPayableDistributionLine") .set(updateValues) .where("id", "=", distributionLineId) .execute(); } if (distributionIdsToRemove.length > 0) { await db .deleteFrom("AccountPayableDistributionLine") .where("id", "in", distributionIdsToRemove) .execute(); } const distributionsToInsert = [ ...insertedLines.flatMap((line, index) => (addLineDistributions[index] ?? []).map((distribution) => ({ accountPayableDocumentLineId: line.id, accountId: distribution.accountId, amount: distribution.amount, distributionType: distribution.distributionType ?? ("MANUAL" as const), correctionOfDistributionLineId: null, description: distribution.description ?? null, })), ), ...distributionsToAddToExistingLines.map((distribution) => ({ accountPayableDocumentLineId: distribution.accountPayableDocumentLineId, accountId: distribution.accountId, amount: distribution.amount, distributionType: distribution.distributionType ?? ("MANUAL" as const), correctionOfDistributionLineId: null, description: distribution.description ?? null, })), ]; if (distributionsToInsert.length > 0) { await db.insertInto("AccountPayableDistributionLine").values(distributionsToInsert).execute(); } if (input.dueSchedule !== undefined) { await db .deleteFrom("AccountPayableDueScheduleLine") .where("accountPayableDocumentId", "=", id) .execute(); if (input.dueSchedule.length > 0) { await db .insertInto("AccountPayableDueScheduleLine") .values( input.dueSchedule.map((line) => ({ accountPayableDocumentId: id, dueDate: line.dueDate, amount: line.amount, })), ) .execute(); } } return ok({ accountPayableDocument: updatedDocument }); }