import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Decimal } from "decimal.js"; import type { InventoryLedgerAction, InventoryLedgerSourceType } from "../../../generated/enums"; import type { Transaction } from "../../../generated/kysely-tailordb"; import { CostingEventNotSupportedError, DefaultValuationPolicyNotFoundError, SiteNotFoundError, } from "../../../lib/errors.generated"; import type { FinancialAccountingCommands, FinancialAccountingQueries, OrganizationQueries, } from "../../../module"; import { createCostingJournalEntry, type CostingRole } from "../journalEntry"; import { priceAverageIssue, priceAverageReceipt } from "./average"; import { applyToCostLayers } from "./costLayer"; import { priceFifoIssue, priceFifoReceipt } from "./fifo"; import { priceStandardIssue, priceStandardReceipt } from "./standard"; // "none" marks internal relocations that move stock without accounting. type CostingTreatment = | { kind: "receipt" | "issue"; counterpartRole: CostingRole } | { kind: "none" }; function resolveCostingTreatment( sourceType: InventoryLedgerSourceType, action: InventoryLedgerAction, direction: "IN" | "OUT", ): CostingTreatment | undefined { if (action === "TRANSFER" || action === "STOCK_TYPE_CHANGE") { return { kind: "none" }; } // QUANTITY_CHANGE: net on-hand moves, so direction selects receipt vs issue if (direction === "IN") { if (sourceType === "INBOUND_SHIPMENT") { return { kind: "receipt", counterpartRole: "ACCRUAL" }; } if (sourceType === "STOCK_ADJUSTMENT") { return { kind: "receipt", counterpartRole: "ADJUSTMENT" }; } } else { if (sourceType === "OUTBOUND_SHIPMENT") { return { kind: "issue", counterpartRole: "COGS" }; } if (sourceType === "STOCK_ADJUSTMENT") { return { kind: "issue", counterpartRole: "ADJUSTMENT" }; } } return undefined; } /** * Function: applyCosting * * Costs a stock movement and records it as a balanced posted journal entry in * financial-accounting. The treatment is determined by (sourceType, action, * direction); items without an ItemValuation record fall back to the movement * company's default policy. */ export async function applyCosting( db: Transaction, args: { itemId: string; sourceType: InventoryLedgerSourceType; action: InventoryLedgerAction; direction: "IN" | "OUT"; quantity: Decimal; unitCost?: Decimal; effectiveDate: Date; /** Site of the moved stock; its company receives the journal entry. */ siteId: string; /** Cause document recorded in the journal entry description. */ sourceId: string; /** InventoryLedger row the journal entry references. */ ledgerEntryId: string; }, ctx: CommandContext, organizationQueries: Pick, financialAccountingCommands: Pick< FinancialAccountingCommands, "createJournalEntry" | "postJournalEntry" >, financialAccountingQueries: Pick, ) { const { itemId, sourceType, action, direction, quantity, unitCost, effectiveDate } = args; const treatment = resolveCostingTreatment(sourceType, action, direction); if (!treatment) { return err(new CostingEventNotSupportedError(`${sourceType}:${action}:${direction}`)); } if (treatment.kind === "none") { return ok(undefined); } const { site } = (await organizationQueries.getSite(db, { id: args.siteId }, ctx)).value; if (!site) { return err(new SiteNotFoundError(args.siteId)); } // Lock anchor: all costing for the item in this company serializes on this // row. const itemValuation = await db .selectFrom("ItemValuation") .selectAll() .where("itemId", "=", itemId) .where("companyId", "=", site.companyId) .forUpdate() .executeTakeFirst(); let policy = itemValuation ? await db .selectFrom("ValuationPolicy") .selectAll() .where("id", "=", itemValuation.valuationPolicyId) .executeTakeFirst() : undefined; if (!policy) { policy = await db .selectFrom("ValuationPolicy") .selectAll() .where("defaultCompanyId", "=", site.companyId) .executeTakeFirst(); if (!policy) { return err(new DefaultValuationPolicyNotFoundError(itemId)); } await db .insertInto("ItemValuation") .values({ itemId, companyId: site.companyId, valuationPolicyId: policy.id, }) .returningAll() .executeTakeFirst(); } // Quantity first, price second: every method maintains the layers // identically; only how the moved quantity is valued differs. const applied = await applyToCostLayers(db, { kind: treatment.kind, itemId, companyId: site.companyId, ledgerEntryId: args.ledgerEntryId, effectiveDate, quantity, }); if (!applied.ok) return applied; const layerChange = applied.value; let result; switch (layerChange.kind) { case "receipt": switch (policy.costingMethod) { case "STANDARD": result = await priceStandardReceipt(db, { itemId, quantity, unitCost, counterpartRole: treatment.counterpartRole, }); break; case "FIFO": result = await priceFifoReceipt(db, { itemId, layerId: layerChange.layerId, quantity, unitCost, counterpartRole: treatment.counterpartRole, }); break; case "AVERAGE": result = await priceAverageReceipt(db, { itemId, companyId: site.companyId, quantity, unitCost, counterpartRole: treatment.counterpartRole, }); break; default: return policy.costingMethod satisfies never; } break; case "issue": switch (policy.costingMethod) { case "STANDARD": result = await priceStandardIssue(db, { itemId, quantity, counterpartRole: treatment.counterpartRole, }); break; case "FIFO": result = await priceFifoIssue(db, { slices: layerChange.slices, counterpartRole: treatment.counterpartRole, }); break; case "AVERAGE": result = await priceAverageIssue(db, { itemId, companyId: site.companyId, quantity, counterpartRole: treatment.counterpartRole, }); break; default: return policy.costingMethod satisfies never; } break; default: return layerChange satisfies never; } if (!result.ok) return result; // All facts can cancel to zero (e.g. a zero-standard item); nothing to record if (result.value.length === 0) { return ok(undefined); } return createCostingJournalEntry( db, { entryDate: effectiveDate, description: `Inventory costing: ${sourceType} ${args.sourceId}`, sourceDocumentType: "INVENTORY_LEDGER", sourceDocumentId: args.ledgerEntryId, facts: result.value, policy, }, ctx, financialAccountingCommands, financialAccountingQueries, ); }