import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import type { Transaction } from "../generated/kysely-tailordb"; import { AccountInactiveError, AccountNotFoundError, CustomerAccountNotFoundError, InvalidCustomerAccountError, CurrencyNotFoundError, DocumentLineNotFoundError, DueScheduleInvalidError, InvalidAmountError, LineAmountMismatchError, LineSourceInconsistentError, LineTotalMismatchError, MinimumLinesNotMetError, CompanyNotFoundError, } from "../lib/errors.generated"; import type { BusinessPartnerQueries, CoaManagementQueries, OrganizationQueries, PrimitivesQueries, } from "../module"; export type AccountReceivableDocumentType = "INVOICE" | "CREDIT_MEMO"; export type AccountReceivableLineSourceType = "SALES_ORDER"; export interface AccountReceivableDistributionLineInput { accountId: string; amount: string; description?: string | null; } export interface AccountReceivableDocumentLineInput { netAmount: string; taxAmount?: string | null; grossAmount: string; distributions: AccountReceivableDistributionLineInput[]; description?: string | null; sourceType?: AccountReceivableLineSourceType | null; salesOrderLineId?: string | null; quantity?: string | null; unitPrice?: string | null; unitId?: string | null; } export interface AccountReceivableDueScheduleLineInput { dueDate: Date; amount: string; } export interface CreateAccountReceivableDocumentHeaderInput { companyId: string; customerAccountId: string; currencyId: string; receivableControlAccountId: string; documentType: AccountReceivableDocumentType; externalDocumentNumber?: string | null; documentDate: Date; postingDate: Date; totalAmount: string; description?: string | null; } export interface CreateAccountReceivableDocumentInput { header: CreateAccountReceivableDocumentHeaderInput; lines: AccountReceivableDocumentLineInput[]; dueSchedule?: AccountReceivableDueScheduleLineInput[]; } function positive(identifier: string, amount: string) { return new Decimal(amount).gt(0) ? ok({}) : err(new InvalidAmountError(identifier)); } function validateDueSchedule( identifier: string, totalAmount: string, dueSchedule: { amount: string }[], requireLines: boolean, ) { if (dueSchedule.length === 0) { return requireLines ? err(new DueScheduleInvalidError(identifier)) : ok({}); } const total = dueSchedule.reduce((sum, line) => sum.plus(line.amount), new Decimal(0)); return total.eq(totalAmount) && dueSchedule.every((line) => new Decimal(line.amount).gt(0)) ? ok({}) : err(new DueScheduleInvalidError(identifier)); } export async function run( db: Transaction, input: Omit & { header: CreateAccountReceivableDocumentHeaderInput & HCF; lines: (AccountReceivableDocumentLineInput & LCF)[]; }, ctx: CommandContext, organizationQueries: Pick, customerAccountQueries: Pick, primitivesQueries: Pick, coaManagementQueries: Pick, ) { const { companyId, customerAccountId, currencyId, receivableControlAccountId, 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: customerAccount } = ( await customerAccountQueries.getCustomerAccount(db, { customerAccountId }, ctx) ).value; if (!customerAccount) return err(new CustomerAccountNotFoundError(customerAccountId)); if (customerAccount.companyId !== companyId || customerAccount.accountStatus !== "ACTIVE") return err(new InvalidCustomerAccountError(customerAccountId)); const { currency } = (await primitivesQueries.getCurrency(db, { id: currencyId }, ctx)).value; if (!currency) return err(new CurrencyNotFoundError(currencyId)); if (input.lines.length === 0) return err(new MinimumLinesNotMetError("AR document")); const totalResult = positive("AR document", totalAmount); if (!totalResult.ok) return totalResult; for (const [index, line] of input.lines.entries()) { const identifier = String(index + 1); if (!positive(identifier, line.netAmount).ok || !positive(identifier, line.grossAmount).ok) { return err(new InvalidAmountError(identifier)); } const isSalesOrder = line.sourceType === "SALES_ORDER"; const sourceFields = [line.salesOrderLineId, line.quantity, line.unitPrice, line.unitId]; if (isSalesOrder !== sourceFields.every((value) => value != null)) { return err(new LineSourceInconsistentError(identifier)); } if ( isSalesOrder && (!new Decimal(line.quantity as string).gt(0) || !new Decimal(line.unitPrice as string).gt(0) || !new Decimal(line.netAmount).eq( new Decimal(line.quantity as string).times(line.unitPrice as string), )) ) { return err(new LineAmountMismatchError(identifier)); } const distributionTotal = line.distributions.reduce( (sum, distribution) => sum.plus(distribution.amount), new Decimal(0), ); if ( line.distributions.length === 0 || !distributionTotal.eq(line.grossAmount) || line.distributions.some((distribution) => new Decimal(distribution.amount).lte(0)) ) { return err(new LineTotalMismatchError(identifier)); } } const lineTotal = input.lines.reduce((sum, line) => sum.plus(line.grossAmount), new Decimal(0)); if (!lineTotal.eq(totalAmount)) return err(new LineTotalMismatchError("AR document")); const dueResult = validateDueSchedule("AR document", totalAmount, input.dueSchedule ?? [], false); if (!dueResult.ok) return dueResult; const accountIds = [ ...new Set([ receivableControlAccountId, ...input.lines.flatMap((line) => line.distributions.map(({ accountId }) => 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)); } const document = await db .insertInto("AccountReceivableDocument") .values({ ...(headerCustomFields as Record), companyId, customerAccountId, currencyId, receivableControlAccountId, 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("AccountReceivableDocumentLine") .values( input.lines.map((line) => { const { distributions: _distributions, netAmount, taxAmount, grossAmount, description, sourceType, salesOrderLineId, quantity, unitPrice, unitId, ...customFields } = line; return { ...(customFields as Record), accountReceivableDocumentId: document.id, netAmount, taxAmount: taxAmount ?? null, grossAmount, correctionOfLineId: null, correctionType: null, description: description ?? null, sourceType: sourceType ?? null, salesOrderLineId: salesOrderLineId ?? null, quantity: quantity ?? null, unitPrice: unitPrice ?? null, unitId: unitId ?? null, }; }), ) .returningAll() .execute(); const distributions = input.lines.flatMap((line, index) => { const insertedLine = lines[index]; return insertedLine ? line.distributions.map((distribution) => ({ accountReceivableDocumentLineId: insertedLine.id, correctionOfDistributionLineId: null, ...distribution, })) : []; }); if (distributions.length === 0) return err(new DocumentLineNotFoundError("AR document")); await db.insertInto("AccountReceivableDistributionLine").values(distributions).execute(); if (input.dueSchedule?.length) { await db .insertInto("AccountReceivableDueScheduleLine") .values( input.dueSchedule.map((line) => ({ accountReceivableDocumentId: document.id, ...line, })), ) .execute(); } return ok({ accountReceivableDocument: document }); }