import { err, ok } from "@tailor-platform/erp-kit/core"; import { mergeCustomFields } from "../../../shared/domain"; import { customerAccountLifecycle, type CustomerAccountStatus, } from "../db/customerAccount.lifecycle.generated"; import type { CustomerAddressUsagePurpose as GeneratedCustomerAddressUsagePurpose, CustomerBankAccountUsagePurpose as GeneratedCustomerBankAccountUsagePurpose, } from "../generated/enums"; import { AddressUsageNotFoundError, BankAccountUsageNotFoundError, DuplicateAddressUsageError, DuplicateBankAccountUsageError, DuplicateDefaultUsageError, InvalidAccountCodeError, InvalidNameError, InvalidStateTransitionError, } from "../lib/errors.generated"; /** App-extension columns the domain carries through untouched. */ export type CustomFields = Readonly>; export type CustomerAccountHeader = { readonly partnerId: string; readonly companyId: string; readonly code: string; readonly name: string; readonly preferredCurrencyId: string | null; readonly status: CustomerAccountStatus; readonly customFields: CustomFields; }; export type CustomerAddressUsagePurpose = GeneratedCustomerAddressUsagePurpose; export type CustomerAddressUsage = { readonly id: string; readonly addressId: string; readonly purpose: CustomerAddressUsagePurpose; readonly isDefault: boolean; readonly customFields: CustomFields; }; export type CustomerBankAccountUsagePurpose = GeneratedCustomerBankAccountUsagePurpose; export type CustomerBankAccountUsage = { readonly id: string; readonly bankAccountId: string; readonly purpose: CustomerBankAccountUsagePurpose; readonly isDefault: boolean; readonly customFields: CustomFields; }; declare const invariantsSatisfied: unique symbol; export type CustomerAccount = { readonly id: string; readonly header: CustomerAccountHeader; readonly addressUsages: readonly CustomerAddressUsage[]; readonly bankAccountUsages: readonly CustomerBankAccountUsage[]; readonly [invariantsSatisfied]: true; }; export type UncheckedCustomerAccount = Omit; /** Verifies nothing. The caller is responsible for the invariants. */ export function markInvariantsSatisfied(account: UncheckedCustomerAccount): CustomerAccount { return account as CustomerAccount; } export type NewCustomerAddressUsage = Omit; export type NewCustomerBankAccountUsage = Omit; export type CustomerAccountHeaderPatch = { name?: string; preferredCurrencyId?: string | null; customFields?: CustomFields; }; export type CustomerAddressUsagePatch = { isDefault?: boolean; customFields?: CustomFields; }; export type CustomerBankAccountUsagePatch = { isDefault?: boolean; customFields?: CustomFields; }; export type NewCustomerAccount = { partnerId: string; companyId: string; code: string; name: string; preferredCurrencyId?: string | null; customFields: CustomFields; addressUsages: readonly NewCustomerAddressUsage[]; bankAccountUsages: readonly NewCustomerBankAccountUsage[]; }; /** 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 seen = new Set(); for (const usage of usages) { if (!usage.isDefault) continue; if (seen.has(usage.purpose)) return usage.purpose; seen.add(usage.purpose); } return null; } export function createActive(params: NewCustomerAccount) { if (typeof params.name !== "string" || !params.name.trim()) { return err(new InvalidNameError(params.partnerId)); } if (!params.code.trim()) { return err(new InvalidAccountCodeError(params.code)); } const duplicateUsage = validateUsageUniqueness(params.addressUsages, params.bankAccountUsages); if (duplicateUsage) return err(duplicateUsage); const duplicateAddressPurpose = duplicateDefaultPurpose(params.addressUsages); if (duplicateAddressPurpose) { return err(new DuplicateDefaultUsageError(duplicateAddressPurpose)); } const duplicateBankPurpose = duplicateDefaultPurpose(params.bankAccountUsages); if (duplicateBankPurpose) { return err(new DuplicateDefaultUsageError(duplicateBankPurpose)); } return ok( markInvariantsSatisfied({ id: crypto.randomUUID(), header: { partnerId: params.partnerId, companyId: params.companyId, code: params.code, name: params.name, preferredCurrencyId: params.preferredCurrencyId ?? 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: CustomerAccount, changes: { headerPatch: CustomerAccountHeaderPatch; addAddressUsages: readonly NewCustomerAddressUsage[]; updateAddressUsages: readonly { usageId: string; patch: CustomerAddressUsagePatch; }[]; removeAddressUsageIds: readonly string[]; addBankAccountUsages: readonly NewCustomerBankAccountUsage[]; updateBankAccountUsages: readonly { usageId: string; patch: CustomerBankAccountUsagePatch; }[]; 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): CustomerAddressUsage => { 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): CustomerBankAccountUsage => { 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, customFields: mergeCustomFields( account.header.customFields, changes.headerPatch.customFields, ), }, addressUsages, bankAccountUsages, }), ); } function transition(account: CustomerAccount, operation: "deactivate" | "reactivate") { const status = customerAccountLifecycle.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: CustomerAccount) => transition(account, "deactivate"); export const reactivate = (account: CustomerAccount) => transition(account, "reactivate");