import { err, ok } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import { mergeCustomFields, type CustomFields } from "../../../shared/domain"; import { salesOrderLifecycle } from "../db/salesOrder.lifecycle.generated"; import type { SalesOrderBillingStatus, SalesOrderFieldChangeChangeKind, SalesOrderFieldChangeRecordType, SalesOrderFulfillmentStatus, SalesOrderOrderStatus, } from "../generated/enums"; import { CancellationBlockedError, CompanyNotFoundError, CrossCompanyReferenceError, CurrencyMismatchError, CustomerNotActiveError, CustomerNotFoundError, EmptyAmendmentChangesError, EmptyOrderNotAllowedError, EmptySalesOrderLinesError, InvalidOrderLineError, InvalidOrderStatusError, InvalidQuantityError, InvalidUnitPriceError, ItemNotActiveError, ItemNotFoundError, ItemNotSellableError, LineNotFoundError, MissingFinalPriceError, ModifyQuantityBelowBilledError, ModifyQuantityBelowFulfilledError, NegativeBilledQuantityError, NegativeFulfilledQuantityError, OrderNotClosableError, OrderNotEditableError, OverFulfillmentError, RejectionReasonRequiredError, RemoveLineHasBillsError, RemoveLineHasFulfillmentsError, SalesOrderNotConfirmedError, ShippingAddressRequiredError, } from "../lib/errors.generated"; // ===== Aggregate state ===== export type SalesOrderHeader = { readonly companyId: string; readonly customerAccountId: string; readonly currencyId: string; readonly orderStatus: SalesOrderOrderStatus; readonly fulfillmentStatus: SalesOrderFulfillmentStatus | null; readonly billingStatus: SalesOrderBillingStatus | null; readonly orderDate: Date; readonly shippingAddress: string | null; readonly billingAddress: string | null; readonly rejectionReason: string | null; readonly closeReason: string | null; readonly customFields: CustomFields; }; export type SalesOrderLine = { readonly id: string; readonly itemId: string; readonly description: string | null; readonly quantity: string; readonly unitPrice: string; readonly unitId: string | null; readonly requiresPhysicalFulfillment: boolean; readonly fulfilledQuantity: string; readonly billedQuantity: string; readonly customFields: CustomFields; }; export type SalesOrderFieldChange = { readonly recordType: SalesOrderFieldChangeRecordType; readonly recordId: string; readonly fieldName: string; readonly changeKind: SalesOrderFieldChangeChangeKind; readonly oldValue: string | null; readonly newValue: string | null; }; /** An immutable amendment audit fact; appended by amendConfirmed, never edited. */ export type SalesOrderRevision = { readonly id: string; readonly revisionNumber: number; readonly reason: string | null; readonly amendedByUserId: string | null; readonly fieldChanges: readonly SalesOrderFieldChange[]; }; declare const invariantsSatisfied: unique symbol; export type SalesOrder = { readonly id: string; readonly header: SalesOrderHeader; readonly lines: readonly SalesOrderLine[]; readonly revisions: readonly SalesOrderRevision[]; readonly [invariantsSatisfied]: true; }; export type UncheckedSalesOrder = Omit; // Verifies nothing. The caller is responsible for the invariants. export function markInvariantsSatisfied(order: UncheckedSalesOrder): SalesOrder { return order as SalesOrder; } // ===== Referenced master-data rules ===== declare const customerValidated: unique symbol; declare const itemValidated: unique symbol; export type OrderCustomer = { readonly customerAccountId: string; readonly [customerValidated]: true; }; /** Everything a line freezes from master data the moment the item is chosen. */ export type ItemSnapshot = { readonly itemId: string; readonly requiresPhysicalFulfillment: boolean; readonly [itemValidated]: true; }; export function validateCustomer( customerAccountId: string, companyId: string, account: { accountStatus: string; companyId: string } | null, ) { if (!account) return err(new CustomerNotFoundError(customerAccountId)); if (account.accountStatus !== "ACTIVE") return err(new CustomerNotActiveError(customerAccountId)); if (account.companyId !== companyId) return err(new CrossCompanyReferenceError(customerAccountId)); return ok({ customerAccountId } as OrderCustomer); } // The selling record is what makes an item sellable at all, and it owns the // fulfillment expectation the line freezes — so both are resolved here, together. export function validateOrderItem( itemId: string, companyId: string, refs: { item: { status: string; companyId?: string } | null; salesItem: { requiresPhysicalFulfillment: boolean } | null; }, ) { if (!refs.item) { return err(new ItemNotFoundError(itemId)); } if (refs.item.status !== "ACTIVE") { return err(new ItemNotActiveError(itemId)); } if (refs.item.companyId && refs.item.companyId !== companyId) { return err(new CrossCompanyReferenceError(itemId)); } if (!refs.salesItem) { return err(new ItemNotSellableError(itemId)); } return ok({ itemId, requiresPhysicalFulfillment: refs.salesItem.requiresPhysicalFulfillment, } as ItemSnapshot); } // ===== Progress statuses ===== export function deriveFulfillmentStatus( lines: readonly Pick< SalesOrderLine, "quantity" | "requiresPhysicalFulfillment" | "fulfilledQuantity" >[], ): SalesOrderFulfillmentStatus { const physicalLines = lines.filter((line) => line.requiresPhysicalFulfillment); const totalOrdered = physicalLines.reduce( (total, line) => total.plus(line.quantity), new Decimal(0), ); const totalFulfilled = physicalLines.reduce( (total, line) => total.plus(line.fulfilledQuantity), new Decimal(0), ); if (totalOrdered.isZero() || totalFulfilled.lte(0)) return "NOT_FULFILLED"; if (totalFulfilled.gte(totalOrdered)) return "FULFILLED"; return "PARTIALLY_FULFILLED"; } export function deriveBillingStatus( lines: readonly Pick[], ): SalesOrderBillingStatus { const totalOrdered = lines.reduce((total, line) => total.plus(line.quantity), new Decimal(0)); const totalBilled = lines.reduce( (total, line) => total.plus(line.billedQuantity), new Decimal(0), ); if (totalOrdered.isZero() || totalBilled.lte(0)) return "NOT_BILLED"; if (totalBilled.gte(totalOrdered)) return "BILLED"; return "PARTIALLY_BILLED"; } // ===== Creation ===== export interface NewSalesOrderLine { item: ItemSnapshot; description?: string | null; quantity: string; unitPrice: string; unitId?: string | null; customFields: CustomFields; } export interface NewSalesOrder { companyId: string; customer: OrderCustomer; currencyId: string; orderDate?: Date; shippingAddress?: string | null; billingAddress?: string | null; customFields: CustomFields; lines: NewSalesOrderLine[]; } export interface CreateDraftRefs { company: { id: string; baseCurrencyId: string | null } | null; } // The kit does not handle FX, so a non-base currency is rejected rather than converted. function checkCompanyCurrency(order: UncheckedSalesOrder, refs: CreateDraftRefs) { const { companyId, currencyId } = order.header; if (!refs.company || refs.company.id !== companyId) { return new CompanyNotFoundError(companyId); } if (currencyId !== refs.company.baseCurrencyId) { return new CurrencyMismatchError(currencyId); } return null; } function toSalesOrderLine(line: NewSalesOrderLine): SalesOrderLine { return { id: crypto.randomUUID(), itemId: line.item.itemId, description: line.description ?? null, quantity: line.quantity, unitPrice: line.unitPrice, unitId: line.unitId ?? null, requiresPhysicalFulfillment: line.item.requiresPhysicalFulfillment, fulfilledQuantity: "0", billedQuantity: "0", customFields: line.customFields, }; } function checkFieldInvariants(order: UncheckedSalesOrder) { if (order.lines.length === 0) { return new EmptyOrderNotAllowedError(order.id); } for (const line of order.lines) { if (!line.itemId || new Decimal(line.quantity).lte(0) || new Decimal(line.unitPrice).lt(0)) { return new InvalidOrderLineError(line.itemId || order.id); } } return null; } export function createDraft(params: NewSalesOrder, refs: CreateDraftRefs) { const header: SalesOrderHeader = { companyId: params.companyId, customerAccountId: params.customer.customerAccountId, currencyId: params.currencyId, orderStatus: "DRAFT", fulfillmentStatus: null, billingStatus: null, orderDate: params.orderDate ?? new Date(), shippingAddress: params.shippingAddress ?? null, billingAddress: params.billingAddress ?? null, rejectionReason: null, closeReason: null, customFields: params.customFields, }; const order: UncheckedSalesOrder = { id: crypto.randomUUID(), header, lines: params.lines.map(toSalesOrderLine), revisions: [], }; const violation = checkCompanyCurrency(order, refs) ?? checkFieldInvariants(order); if (violation) { return err(violation); } return ok(markInvariantsSatisfied(order)); } // ===== Draft update ===== export interface SalesOrderHeaderPatch { customerAccountId?: string; shippingAddress?: string | null; billingAddress?: string | null; customFields?: CustomFields; } export interface SalesOrderLinePatch { item?: ItemSnapshot; description?: string | null; quantity?: string; unitPrice?: string; unitId?: string | null; customFields?: CustomFields; } export function updateDraft( order: SalesOrder, changes: { headerPatch: SalesOrderHeaderPatch; addLines: NewSalesOrderLine[]; updateLines: { lineId: string; linePatch: SalesOrderLinePatch }[]; removeLineIds: string[]; }, ) { const { headerPatch, addLines, updateLines, removeLineIds } = changes; if (order.header.orderStatus !== "DRAFT") { return err(new OrderNotEditableError(order.id)); } const currentLineIds = new Set(order.lines.map((line) => line.id)); const linePatchById = new Map(updateLines.map((edit) => [edit.lineId, edit.linePatch])); for (const lineId of [...linePatchById.keys(), ...removeLineIds]) { if (!currentLineIds.has(lineId)) { return err(new LineNotFoundError(lineId)); } } const mergedLines: SalesOrderLine[] = []; for (const line of order.lines) { if (removeLineIds.includes(line.id)) { continue; } const linePatch = linePatchById.get(line.id); if (!linePatch) { mergedLines.push(line); continue; } mergedLines.push({ ...line, itemId: linePatch.item === undefined ? line.itemId : linePatch.item.itemId, description: linePatch.description === undefined ? line.description : linePatch.description, quantity: linePatch.quantity === undefined ? line.quantity : linePatch.quantity, unitPrice: linePatch.unitPrice === undefined ? line.unitPrice : linePatch.unitPrice, unitId: linePatch.unitId === undefined ? line.unitId : linePatch.unitId, // Re-resolved with the item, since the fulfillment expectation belongs to it. requiresPhysicalFulfillment: linePatch.item === undefined ? line.requiresPhysicalFulfillment : linePatch.item.requiresPhysicalFulfillment, customFields: mergeCustomFields(line.customFields, linePatch.customFields), }); } mergedLines.push(...addLines.map(toSalesOrderLine)); const mergedHeader: SalesOrderHeader = { ...order.header, customerAccountId: headerPatch.customerAccountId === undefined ? order.header.customerAccountId : headerPatch.customerAccountId, shippingAddress: headerPatch.shippingAddress === undefined ? order.header.shippingAddress : headerPatch.shippingAddress, billingAddress: headerPatch.billingAddress === undefined ? order.header.billingAddress : headerPatch.billingAddress, customFields: mergeCustomFields(order.header.customFields, headerPatch.customFields), }; const updated: UncheckedSalesOrder = { id: order.id, header: mergedHeader, lines: mergedLines, revisions: order.revisions, }; const violation = checkFieldInvariants(updated); if (violation) { return err(violation); } return ok(markInvariantsSatisfied(updated)); } // ===== Submission ===== export function submit(order: SalesOrder) { const nextStatus = salesOrderLifecycle.tryTransition(order.header.orderStatus, "submit"); if (!nextStatus) { return err(new InvalidOrderStatusError(order.id)); } if (order.lines.length === 0) { return err(new EmptyOrderNotAllowedError(order.id)); } if ( order.lines.some((line) => line.requiresPhysicalFulfillment) && !order.header.shippingAddress ) { return err(new ShippingAddressRequiredError(order.id)); } return ok( markInvariantsSatisfied({ ...order, header: { ...order.header, orderStatus: nextStatus } }), ); } // ===== Confirmation ===== // Confirmation asserts the order is ready to fulfill and bill, so the customer // is revalidated and every line must carry a final price. export function confirm( order: SalesOrder, refs: { customer: { accountStatus: string; companyId: string } | null }, ) { const nextStatus = salesOrderLifecycle.tryTransition(order.header.orderStatus, "confirm"); if (!nextStatus) { return err(new InvalidOrderStatusError(order.id)); } if (refs.customer?.accountStatus !== "ACTIVE") { return err(new CustomerNotActiveError(order.header.customerAccountId)); } if (refs.customer.companyId !== order.header.companyId) { return err(new CrossCompanyReferenceError(order.header.customerAccountId)); } if (order.lines.some((line) => new Decimal(line.unitPrice).lte(0))) { return err(new MissingFinalPriceError(order.id)); } return ok( markInvariantsSatisfied({ ...order, header: { ...order.header, orderStatus: nextStatus, fulfillmentStatus: deriveFulfillmentStatus(order.lines), billingStatus: deriveBillingStatus(order.lines), }, }), ); } // ===== Rejection ===== export function reject(order: SalesOrder, rejectionReason: string) { const nextStatus = salesOrderLifecycle.tryTransition(order.header.orderStatus, "reject"); if (!nextStatus) { return err(new InvalidOrderStatusError(order.id)); } if (!rejectionReason.trim()) { return err(new RejectionReasonRequiredError(order.id)); } return ok( markInvariantsSatisfied({ ...order, header: { ...order.header, orderStatus: nextStatus, rejectionReason }, }), ); } // ===== Cancellation ===== export function cancel(order: SalesOrder) { const nextStatus = salesOrderLifecycle.tryTransition(order.header.orderStatus, "cancel"); if (!nextStatus) { return err(new InvalidOrderStatusError(order.id)); } if ( order.lines.some( (line) => new Decimal(line.fulfilledQuantity).gt(0) || new Decimal(line.billedQuantity).gt(0), ) ) { return err(new CancellationBlockedError(order.id)); } return ok( markInvariantsSatisfied({ ...order, header: { ...order.header, orderStatus: nextStatus } }), ); } // ===== Closure ===== export function close(order: SalesOrder, closeReason?: string) { const nextStatus = salesOrderLifecycle.tryTransition(order.header.orderStatus, "close"); if (!nextStatus) { return err(new InvalidOrderStatusError(order.id)); } if ( order.lines.some((line) => { const fulfillmentOpen = line.requiresPhysicalFulfillment && new Decimal(line.fulfilledQuantity).lt(line.quantity); const billingOpen = new Decimal(line.billedQuantity).lt(line.quantity); return fulfillmentOpen || billingOpen; }) ) { return err(new OrderNotClosableError(order.id)); } return ok( markInvariantsSatisfied({ ...order, header: { ...order.header, orderStatus: nextStatus, closeReason: closeReason ?? null }, }), ); } // ===== Amendment ===== export interface AmendedSalesOrderHeaderPatch { orderDate?: Date; shippingAddress?: string | null; billingAddress?: string | null; customFields?: CustomFields; } // The item and unit cannot change on a confirmed line, and neither can the // fulfillment expectation the item froze onto it; remove + add instead. export interface AmendedSalesOrderLinePatch { quantity?: string; unitPrice?: string; description?: string | null; customFields?: CustomFields; } function serializeAuditValue(value: unknown): string | null { if (value === null || value === undefined) return null; if (value instanceof Date) return value.toISOString(); if (typeof value === "string") return value; if (typeof value === "boolean") return value ? "true" : "false"; if (typeof value === "number" || typeof value === "bigint") return String(value); return JSON.stringify(value) ?? null; } // Statuses and derived projections are never amended, so they stay out of the audit. // Custom fields first so domain-owned fields always win. function headerAuditFields(header: SalesOrderHeader): Record { return { ...header.customFields, orderDate: header.orderDate, shippingAddress: header.shippingAddress, billingAddress: header.billingAddress, }; } function lineAuditFields(line: SalesOrderLine): Record { return { ...line.customFields, itemId: line.itemId, description: line.description, quantity: line.quantity, unitPrice: line.unitPrice, unitId: line.unitId, requiresPhysicalFulfillment: line.requiresPhysicalFulfillment, fulfilledQuantity: line.fulfilledQuantity, billedQuantity: line.billedQuantity, }; } function diffAuditFields( recordType: SalesOrderFieldChangeRecordType, recordId: string, before: Record, after: Record, ): SalesOrderFieldChange[] { const changes: SalesOrderFieldChange[] = []; for (const fieldName of new Set([...Object.keys(before), ...Object.keys(after)])) { const oldValue = serializeAuditValue(before[fieldName]); const newValue = serializeAuditValue(after[fieldName]); if (oldValue !== newValue) { changes.push({ recordType, recordId, fieldName, changeKind: "MODIFIED", oldValue, newValue }); } } return changes; } // Snapshot each non-null field so an added or removed line's state survives. function snapshotAuditFields( recordId: string, fields: Record, changeKind: "ADDED" | "REMOVED", ): SalesOrderFieldChange[] { const changes: SalesOrderFieldChange[] = []; for (const [fieldName, raw] of Object.entries(fields)) { const value = serializeAuditValue(raw); if (value === null) { continue; } changes.push({ recordType: "LINE", recordId, fieldName, changeKind, oldValue: changeKind === "REMOVED" ? value : null, newValue: changeKind === "ADDED" ? value : null, }); } return changes; } function deriveAmendmentFieldChanges( before: UncheckedSalesOrder, after: UncheckedSalesOrder, ): SalesOrderFieldChange[] { const changes = diffAuditFields( "HEADER", after.id, headerAuditFields(before.header), headerAuditFields(after.header), ); const afterLineById = new Map(after.lines.map((line) => [line.id, line])); for (const beforeLine of before.lines) { const afterLine = afterLineById.get(beforeLine.id); if (afterLine) { changes.push( ...diffAuditFields( "LINE", beforeLine.id, lineAuditFields(beforeLine), lineAuditFields(afterLine), ), ); } else { changes.push(...snapshotAuditFields(beforeLine.id, lineAuditFields(beforeLine), "REMOVED")); } } const beforeLineIds = new Set(before.lines.map((line) => line.id)); for (const afterLine of after.lines) { if (!beforeLineIds.has(afterLine.id)) { changes.push(...snapshotAuditFields(afterLine.id, lineAuditFields(afterLine), "ADDED")); } } return changes; } // The amendment invariants carry their own error family, distinct from the // draft-editing invariants. function checkAmendedLineInvariants(order: UncheckedSalesOrder) { if (order.lines.length === 0) { return new EmptySalesOrderLinesError(order.id); } for (const line of order.lines) { if (new Decimal(line.quantity).lte(0)) { return new InvalidQuantityError(line.itemId); } if (new Decimal(line.unitPrice).lt(0)) { return new InvalidUnitPriceError(line.itemId); } } return null; } export function amendConfirmed( order: SalesOrder, amendment: { headerPatch: AmendedSalesOrderHeaderPatch; addLines: NewSalesOrderLine[]; updateLines: { lineId: string; patch: AmendedSalesOrderLinePatch }[]; removeLineIds: string[]; reason?: string; amendedByUserId: string | null; }, ) { const { headerPatch, addLines, updateLines, removeLineIds } = amendment; if (order.header.orderStatus !== "CONFIRMED") { return err(new SalesOrderNotConfirmedError(order.id)); } const currentLineById = new Map(order.lines.map((line) => [line.id, line])); const patchByLineId = new Map(updateLines.map((edit) => [edit.lineId, edit.patch])); for (const lineId of [...patchByLineId.keys(), ...removeLineIds]) { if (!currentLineById.has(lineId)) { return err(new LineNotFoundError(lineId)); } } // Every referenced lineId was checked above, so this lookup never misses. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const lineFor = (lineId: string) => currentLineById.get(lineId)!; for (const { lineId, patch } of updateLines) { if (patch.quantity === undefined) { continue; } if (new Decimal(patch.quantity).lt(lineFor(lineId).fulfilledQuantity)) { return err(new ModifyQuantityBelowFulfilledError(lineId)); } if (new Decimal(patch.quantity).lt(lineFor(lineId).billedQuantity)) { return err(new ModifyQuantityBelowBilledError(lineId)); } } for (const lineId of removeLineIds) { if (new Decimal(lineFor(lineId).fulfilledQuantity).gt(0)) { return err(new RemoveLineHasFulfillmentsError(lineId)); } if (new Decimal(lineFor(lineId).billedQuantity).gt(0)) { return err(new RemoveLineHasBillsError(lineId)); } } const mergedLines: SalesOrderLine[] = []; for (const line of order.lines) { if (removeLineIds.includes(line.id)) { continue; } const patch = patchByLineId.get(line.id); if (!patch) { mergedLines.push(line); continue; } mergedLines.push({ ...line, quantity: patch.quantity === undefined ? line.quantity : patch.quantity, unitPrice: patch.unitPrice === undefined ? line.unitPrice : patch.unitPrice, description: patch.description === undefined ? line.description : patch.description, customFields: mergeCustomFields(line.customFields, patch.customFields), }); } mergedLines.push(...addLines.map(toSalesOrderLine)); const mergedHeader: SalesOrderHeader = { ...order.header, orderDate: headerPatch.orderDate === undefined ? order.header.orderDate : headerPatch.orderDate, shippingAddress: headerPatch.shippingAddress === undefined ? order.header.shippingAddress : headerPatch.shippingAddress, billingAddress: headerPatch.billingAddress === undefined ? order.header.billingAddress : headerPatch.billingAddress, fulfillmentStatus: deriveFulfillmentStatus(mergedLines), billingStatus: deriveBillingStatus(mergedLines), customFields: mergeCustomFields(order.header.customFields, headerPatch.customFields), }; const amended: UncheckedSalesOrder = { id: order.id, header: mergedHeader, lines: mergedLines, revisions: order.revisions, }; const violation = checkAmendedLineInvariants(amended); if (violation) { return err(violation); } // Every amendment carries its own audit revision; one that changes nothing // must not open a revision envelope. const fieldChanges = deriveAmendmentFieldChanges(order, amended); if (fieldChanges.length === 0) { return err(new EmptyAmendmentChangesError(order.id)); } const revision: SalesOrderRevision = { id: crypto.randomUUID(), revisionNumber: order.revisions.reduce((max, prior) => Math.max(max, prior.revisionNumber), 0) + 1, reason: amendment.reason ?? null, amendedByUserId: amendment.amendedByUserId, fieldChanges, }; return ok({ order: markInvariantsSatisfied({ ...amended, revisions: [...order.revisions, revision] }), revisionId: revision.id, }); } // ===== Fulfillment recording ===== // Fulfillments post incrementally from outbound shipments, so each posting // carries a delta. Entries for other orders' lines are left to their own // aggregate. export function recordFulfillments( order: SalesOrder, lineFulfillments: { salesOrderLineId: string; fulfilledQuantityDelta: string }[], ) { if (order.header.orderStatus !== "CONFIRMED") { return err(new InvalidOrderStatusError(order.id)); } const currentLineIds = new Set(order.lines.map((line) => line.id)); const deltaByLineId = new Map(); for (const fulfillment of lineFulfillments) { if (!currentLineIds.has(fulfillment.salesOrderLineId)) { continue; } const delta = new Decimal(fulfillment.fulfilledQuantityDelta); if (delta.lt(0)) { return err(new NegativeFulfilledQuantityError(fulfillment.salesOrderLineId)); } deltaByLineId.set( fulfillment.salesOrderLineId, delta.plus(deltaByLineId.get(fulfillment.salesOrderLineId) ?? 0), ); } const lines: SalesOrderLine[] = []; for (const line of order.lines) { const delta = deltaByLineId.get(line.id); if (delta === undefined) { lines.push(line); continue; } const nextFulfilledQuantity = delta.plus(line.fulfilledQuantity); if (!line.requiresPhysicalFulfillment || nextFulfilledQuantity.gt(line.quantity)) { return err(new OverFulfillmentError(line.id)); } lines.push({ ...line, fulfilledQuantity: nextFulfilledQuantity.toString() }); } return ok( markInvariantsSatisfied({ ...order, header: { ...order.header, fulfillmentStatus: deriveFulfillmentStatus(lines) }, lines, }), ); } // ===== Billing recording ===== // Billing is an absolute projection recomputed by accounts receivable, so each // sync carries totals; the last entry per line wins. export function recordBillings( order: SalesOrder, lineBillings: { salesOrderLineId: string; billedQuantity: string }[], ) { const currentLineIds = new Set(order.lines.map((line) => line.id)); const billedByLineId = new Map(); for (const billing of lineBillings) { if (!currentLineIds.has(billing.salesOrderLineId)) { continue; } const billedQuantity = new Decimal(billing.billedQuantity); if (billedQuantity.lt(0)) { return err(new NegativeBilledQuantityError(billing.salesOrderLineId)); } billedByLineId.set(billing.salesOrderLineId, billedQuantity.toString()); } const lines = order.lines.map((line) => { const billedQuantity = billedByLineId.get(line.id); if (billedQuantity === undefined) { return line; } return { ...line, billedQuantity }; }); return ok( markInvariantsSatisfied({ ...order, header: { ...order.header, billingStatus: deriveBillingStatus(lines) }, lines, }), ); }