import { err, ok } from "@tailor-platform/erp-kit/core"; import { mergeCustomFields } from "../../../shared/domain"; import { supplierAccountLifecycle, type SupplierAccountStatus, } from "../db/supplierAccount.lifecycle.generated"; import type { SupplierAddressUsagePurpose, SupplierBankAccountUsagePurpose, } from "../generated/enums"; import { AddressUsageNotFoundError, BankAccountUsageNotFoundError, DuplicateAddressUsageError, DuplicateBankAccountUsageError, DuplicateDefaultUsageError, DefaultExpenseAccountNotFoundError, InvalidAccountCodeError, InvalidDefaultExpenseAccountError, InvalidNameError, InvalidStateTransitionError, } from "../lib/errors.generated"; /** App-extension columns the domain carries through untouched. */ export type CustomFields = Readonly>; export type SupplierAddressPurpose = SupplierAddressUsagePurpose; export type SupplierBankAccountPurpose = SupplierBankAccountUsagePurpose; export type SupplierAccountHeader = { readonly partnerId: string; readonly companyId: string; readonly code: string; readonly name: string; readonly preferredCurrencyId: string | null; readonly defaultExpenseAccountId: string | null; readonly status: SupplierAccountStatus; readonly customFields: CustomFields; }; export type SupplierAddressUsage = { readonly id: string; readonly addressId: string; readonly purpose: SupplierAddressPurpose; readonly isDefault: boolean; readonly customFields: CustomFields; }; export type SupplierBankAccountUsage = { readonly id: string; readonly bankAccountId: string; readonly purpose: SupplierBankAccountPurpose; readonly isDefault: boolean; readonly customFields: CustomFields; }; declare const invariantsSatisfied: unique symbol; export type SupplierAccount = { readonly id: string; readonly header: SupplierAccountHeader; readonly addressUsages: readonly SupplierAddressUsage[]; readonly bankAccountUsages: readonly SupplierBankAccountUsage[]; readonly [invariantsSatisfied]: true; }; export type UncheckedSupplierAccount = Omit; // Verifies nothing. Repository mapping is responsible for loading valid persisted state. export function markInvariantsSatisfied(account: UncheckedSupplierAccount): SupplierAccount { return account as SupplierAccount; } export type NewSupplierAddressUsage = Omit; export type NewSupplierBankAccountUsage = Omit; export type SupplierAccountHeaderPatch = { name?: string; preferredCurrencyId?: string | null; defaultExpenseAccountId?: string | null; customFields?: CustomFields; }; export type SupplierAddressUsagePatch = { isDefault?: boolean; customFields?: CustomFields; }; export type SupplierBankAccountUsagePatch = { isDefault?: boolean; customFields?: CustomFields; }; export interface NewSupplierAccount { partnerId: string; companyId: string; code: string; name: string; preferredCurrencyId?: string | null; defaultExpenseAccountId?: string | null; customFields: CustomFields; addressUsages: readonly NewSupplierAddressUsage[]; bankAccountUsages: readonly NewSupplierBankAccountUsage[]; } // ===== Referenced master-data rules ===== export function validateDefaultExpenseAccount( accountId: string, companyId: string, account: { companyId: string; accountType: string; status: string; } | null, ) { if (!account) return err(new DefaultExpenseAccountNotFoundError(accountId)); if ( account.companyId !== companyId || account.accountType !== "EXPENSE" || account.status !== "ACTIVE" ) { return err(new InvalidDefaultExpenseAccountError(accountId)); } return ok({ accountId }); } /** Validate the final collections so remove-and-add replacement remains valid. */ function validateUsageUniqueness( addressUsages: readonly { addressId: string; purpose: string }[], bankAccountUsages: readonly { bankAccountId: string; purpose: string }[], ) { const addressKeys = new Set(); for (const usage of addressUsages) { const key = JSON.stringify([usage.addressId, usage.purpose]); if (addressKeys.has(key)) { return new DuplicateAddressUsageError(key); } addressKeys.add(key); } const bankAccountKeys = new Set(); for (const usage of bankAccountUsages) { const key = JSON.stringify([usage.bankAccountId, usage.purpose]); if (bankAccountKeys.has(key)) { return new DuplicateBankAccountUsageError(key); } bankAccountKeys.add(key); } return null; } function duplicateDefaultPurpose( usages: readonly T[], ) { const defaults = new Set(); for (const usage of usages) { if (!usage.isDefault) continue; if (defaults.has(usage.purpose)) return usage.purpose; defaults.add(usage.purpose); } return null; } export function createActive(params: NewSupplierAccount) { if (typeof params.name !== "string" || !params.name.trim()) { return err(new InvalidNameError(params.partnerId)); } if (params.code.trim().length === 0) { return err(new InvalidAccountCodeError(params.code)); } const duplicateUsage = validateUsageUniqueness(params.addressUsages, params.bankAccountUsages); if (duplicateUsage) return err(duplicateUsage); const duplicatePurpose = duplicateDefaultPurpose(params.addressUsages) ?? duplicateDefaultPurpose(params.bankAccountUsages); if (duplicatePurpose) { return err(new DuplicateDefaultUsageError(duplicatePurpose)); } return ok( markInvariantsSatisfied({ id: crypto.randomUUID(), header: { partnerId: params.partnerId, companyId: params.companyId, code: params.code, name: params.name, preferredCurrencyId: params.preferredCurrencyId ?? null, defaultExpenseAccountId: params.defaultExpenseAccountId ?? null, status: "ACTIVE", customFields: params.customFields, }, addressUsages: params.addressUsages.map((usage) => ({ ...usage, id: crypto.randomUUID(), })), bankAccountUsages: params.bankAccountUsages.map((usage) => ({ ...usage, id: crypto.randomUUID(), })), }), ); } export function update( account: SupplierAccount, changes: { headerPatch: SupplierAccountHeaderPatch; addAddressUsages: readonly NewSupplierAddressUsage[]; updateAddressUsages: readonly { usageId: string; patch: SupplierAddressUsagePatch }[]; removeAddressUsageIds: readonly string[]; addBankAccountUsages: readonly NewSupplierBankAccountUsage[]; updateBankAccountUsages: readonly { usageId: string; patch: SupplierBankAccountUsagePatch }[]; removeBankAccountUsageIds: readonly string[]; }, ) { if ( changes.headerPatch.name !== undefined && (typeof changes.headerPatch.name !== "string" || !changes.headerPatch.name.trim()) ) { return err(new InvalidNameError(account.id)); } const addressUsageIds = new Set(account.addressUsages.map((usage) => usage.id)); for (const usageId of [ ...changes.updateAddressUsages.map((edit) => edit.usageId), ...changes.removeAddressUsageIds, ]) { if (!addressUsageIds.has(usageId)) return err(new AddressUsageNotFoundError(usageId)); } const bankAccountUsageIds = new Set(account.bankAccountUsages.map((usage) => usage.id)); for (const usageId of [ ...changes.updateBankAccountUsages.map((edit) => edit.usageId), ...changes.removeBankAccountUsageIds, ]) { if (!bankAccountUsageIds.has(usageId)) return err(new BankAccountUsageNotFoundError(usageId)); } const removedAddressUsageIds = new Set(changes.removeAddressUsageIds); const addressPatchById = new Map( changes.updateAddressUsages.map((edit) => [edit.usageId, edit.patch]), ); const removedBankAccountUsageIds = new Set(changes.removeBankAccountUsageIds); const bankPatchById = new Map( changes.updateBankAccountUsages.map((edit) => [edit.usageId, edit.patch]), ); const requestedAddressDefaultPurposes = [ ...[...addressPatchById].flatMap(([usageId, patch]) => { if (patch.isDefault !== true || removedAddressUsageIds.has(usageId)) return []; const usage = account.addressUsages.find((candidate) => candidate.id === usageId); return usage ? [usage.purpose] : []; }), ...changes.addAddressUsages.filter((usage) => usage.isDefault).map((usage) => usage.purpose), ]; const duplicateRequestedAddressDefault = duplicateDefaultPurpose( requestedAddressDefaultPurposes.map((purpose) => ({ purpose, isDefault: true })), ); if (duplicateRequestedAddressDefault) { return err(new DuplicateDefaultUsageError(duplicateRequestedAddressDefault)); } const addressDefaultPurposeSet = new Set(requestedAddressDefaultPurposes); const addressUsages = account.addressUsages .filter((usage) => !removedAddressUsageIds.has(usage.id)) .map((usage): SupplierAddressUsage => { const patch = addressPatchById.get(usage.id); const explicitlyMadeDefault = patch?.isDefault === true; return { ...usage, isDefault: explicitlyMadeDefault ? true : addressDefaultPurposeSet.has(usage.purpose) ? false : (patch?.isDefault ?? usage.isDefault), customFields: mergeCustomFields(usage.customFields, patch?.customFields), }; }); addressUsages.push( ...changes.addAddressUsages.map((usage) => ({ ...usage, id: crypto.randomUUID() })), ); const requestedBankDefaultPurposes = [ ...[...bankPatchById].flatMap(([usageId, patch]) => { if (patch.isDefault !== true || removedBankAccountUsageIds.has(usageId)) return []; const usage = account.bankAccountUsages.find((candidate) => candidate.id === usageId); return usage ? [usage.purpose] : []; }), ...changes.addBankAccountUsages .filter((usage) => usage.isDefault) .map((usage) => usage.purpose), ]; const duplicateRequestedBankDefault = duplicateDefaultPurpose( requestedBankDefaultPurposes.map((purpose) => ({ purpose, isDefault: true })), ); if (duplicateRequestedBankDefault) { return err(new DuplicateDefaultUsageError(duplicateRequestedBankDefault)); } const bankDefaultPurposeSet = new Set(requestedBankDefaultPurposes); const bankAccountUsages = account.bankAccountUsages .filter((usage) => !removedBankAccountUsageIds.has(usage.id)) .map((usage): SupplierBankAccountUsage => { const patch = bankPatchById.get(usage.id); const explicitlyMadeDefault = patch?.isDefault === true; return { ...usage, isDefault: explicitlyMadeDefault ? true : bankDefaultPurposeSet.has(usage.purpose) ? false : (patch?.isDefault ?? usage.isDefault), customFields: mergeCustomFields(usage.customFields, patch?.customFields), }; }); bankAccountUsages.push( ...changes.addBankAccountUsages.map((usage) => ({ ...usage, id: crypto.randomUUID() })), ); const duplicateUsage = validateUsageUniqueness(addressUsages, bankAccountUsages); if (duplicateUsage) return err(duplicateUsage); const duplicatePurpose = duplicateDefaultPurpose(addressUsages) ?? duplicateDefaultPurpose(bankAccountUsages); if (duplicatePurpose) return err(new DuplicateDefaultUsageError(duplicatePurpose)); return ok( markInvariantsSatisfied({ ...account, header: { ...account.header, name: changes.headerPatch.name === undefined ? account.header.name : changes.headerPatch.name, preferredCurrencyId: changes.headerPatch.preferredCurrencyId === undefined ? account.header.preferredCurrencyId : changes.headerPatch.preferredCurrencyId, defaultExpenseAccountId: changes.headerPatch.defaultExpenseAccountId === undefined ? account.header.defaultExpenseAccountId : changes.headerPatch.defaultExpenseAccountId, customFields: mergeCustomFields( account.header.customFields, changes.headerPatch.customFields, ), }, addressUsages, bankAccountUsages, }), ); } function transition(account: SupplierAccount, operation: "deactivate" | "reactivate") { const status = supplierAccountLifecycle.tryTransition(account.header.status, operation); if (!status) return err(new InvalidStateTransitionError(account.id)); return ok( markInvariantsSatisfied({ ...account, header: { ...account.header, status }, }), ); } export const deactivate = (account: SupplierAccount) => transition(account, "deactivate"); export const reactivate = (account: SupplierAccount) => transition(account, "reactivate");