import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import { accountPayableDocumentLifecycle } from "../db/accountPayableDocument.lifecycle.generated"; import type { Transaction } from "../generated/kysely-tailordb"; import { postAcquisitionCostAdjustment } from "../internal/acquisitionCostAdjustment"; import { AccountInactiveError, AccountNotFoundError, ApAccountingPeriodNotFoundError, ApDocumentHasActiveHoldError, ApDocumentNotFoundError, ApInvalidDocumentStatusError, ApLineTotalMismatchError, ApMinimumLinesNotMetError, ApJournalEntryCreateFailedError, ApJournalEntryPostFailedError, ApPostingCurrencyMismatchError, CompanyNotFoundError, } from "../lib/errors.generated"; import type { CoaManagementQueries, FinancialAccountingCommands, FinancialAccountingQueries, InventoryCommands, OrganizationQueries, PurchaseQueries, } from "../module"; export interface PostAccountPayableDocumentInput { id: string; } 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: PostAccountPayableDocumentInput, ctx: CommandContext, organizationQueries: Pick, financialAccountingCommands: Pick< FinancialAccountingCommands, "createJournalEntry" | "postJournalEntry" >, financialAccountingQueries: Pick, coaManagementQueries: Pick, purchaseQueries: Pick, inventoryCommands: Pick, ) { const document = await db .selectFrom("AccountPayableDocument") .selectAll() .where("id", "=", input.id) .forUpdate() .executeTakeFirst(); if (!document) return err(new ApDocumentNotFoundError(input.id)); const nextStatus = accountPayableDocumentLifecycle.tryTransition(document.status, "post"); if (!nextStatus) return err(new ApInvalidDocumentStatusError(input.id)); const { company } = (await organizationQueries.getCompany(db, { id: document.companyId }, ctx)) .value; if (!company) return err(new CompanyNotFoundError(document.companyId)); if (document.currencyId !== company.baseCurrencyId) { return err(new ApPostingCurrencyMismatchError(input.id)); } const documentLines = await db .selectFrom("AccountPayableDocumentLine") .selectAll() .where("accountPayableDocumentId", "=", input.id) .orderBy("createdAt", "asc") .execute(); const lineTotalResult = validateLineTotal(input.id, document.totalAmount, documentLines); if (!lineTotalResult.ok) return lineTotalResult; const activeHolds = await db .selectFrom("AccountPayableDocumentHold") .selectAll() .where("accountPayableDocumentId", "=", input.id) .where("releasedAt", "is", null) .execute(); if (activeHolds.length > 0) return err(new ApDocumentHasActiveHoldError(input.id)); const distributions = await db .selectFrom("AccountPayableDistributionLine as distribution") .innerJoin( "AccountPayableDocumentLine as line", "line.id", "distribution.accountPayableDocumentLineId", ) .selectAll("distribution") .where("line.accountPayableDocumentId", "=", input.id) .orderBy("line.createdAt", "asc") .orderBy("distribution.createdAt", "asc") .execute(); const distributionTotalResult = validateDistributionTotal( input.id, document.totalAmount, distributions, ); if (!distributionTotalResult.ok) return distributionTotalResult; const distributionsByLineId = new Map(); for (const distribution of distributions) { const lineDistributions = distributionsByLineId.get(distribution.accountPayableDocumentLineId); if (lineDistributions) lineDistributions.push(distribution); else distributionsByLineId.set(distribution.accountPayableDocumentLineId, [distribution]); } for (const line of documentLines) { const lineDistributionTotalResult = validateDistributionTotal( line.id, line.grossAmount, distributionsByLineId.get(line.id) ?? [], ); if (!lineDistributionTotalResult.ok) return lineDistributionTotalResult; } const accountIds = [ ...new Set([ document.payableControlAccountId, ...distributions.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 { accountingPeriod } = ( await financialAccountingQueries.getPeriodByDate( db, { companyId: document.companyId, date: document.postingDate }, ctx, ) ).value; if (!accountingPeriod) { return err( new ApAccountingPeriodNotFoundError( `${document.companyId}:${document.postingDate.toISOString()}`, ), ); } const isInvoice = document.documentType === "INVOICE"; const createResult = await financialAccountingCommands.createJournalEntry( db, { header: { companyId: document.companyId, accountingPeriodId: accountingPeriod.id, entryDate: document.postingDate, description: `${document.documentType} ${document.documentNumber}`, sourceDocumentType: "ACCOUNT_PAYABLE_DOCUMENT", sourceDocumentId: document.id, }, lines: [ // A negative derived amount (invoice price variance billed below the // order price) flips to the opposite side. ...distributions.map((distribution) => { const amount = new Decimal(distribution.amount); const debit = isInvoice === !amount.isNegative(); return { accountId: distribution.accountId, debitAmount: debit ? amount.abs().toString() : null, creditAmount: debit ? null : amount.abs().toString(), description: distribution.description, }; }), { accountId: document.payableControlAccountId, debitAmount: isInvoice ? null : document.totalAmount, creditAmount: isInvoice ? document.totalAmount : null, description: "Accounts payable control", }, ], }, ctx, ); if (!createResult.ok) return err(new ApJournalEntryCreateFailedError(input.id)); const postResult = await financialAccountingCommands.postJournalEntry( db, { id: createResult.value.journalEntry.id }, ctx, ); if (!postResult.ok) return err(new ApJournalEntryPostFailedError(input.id)); const adjustmentResult = await postAcquisitionCostAdjustment( db, { document, lines: documentLines }, ctx, purchaseQueries, inventoryCommands, ); if (!adjustmentResult.ok) return adjustmentResult; const now = new Date(); const postedDocument = await db .updateTable("AccountPayableDocument") .set({ status: nextStatus, postedAt: now, }) .where("id", "=", input.id) .returningAll() .executeTakeFirstOrThrow(); return ok({ accountPayableDocument: postedDocument, journalEntry: postResult.value.journalEntry, }); }