import { err, ok } from "@tailor-platform/erp-kit/core"; import { businessPartnerLifecycle, type BusinessPartnerStatus, } from "../db/businessPartner.lifecycle.generated"; import type { BusinessPartnerType } from "../generated/enums"; import { AddressNotFoundError, BankAccountNotFoundError, InvalidAccountHolderNameError, InvalidBankNameError, InvalidCountryCodeError, InvalidNameError, InvalidStateTransitionError, MissingAccountIdentifierError, MissingRequiredAddressFieldError, PartnerInactiveError, } from "../lib/errors.generated"; export type CustomFields = Readonly>; export interface BusinessPartnerHeader { readonly name: string; readonly type: BusinessPartnerType; readonly status: BusinessPartnerStatus; readonly customFields: CustomFields; } export interface PartnerAddress { readonly id: string; readonly active: boolean; readonly line1: string; readonly line2: string | null; readonly city: string; readonly state: string | null; readonly postalCode: string; readonly country: string; readonly createdAt: Date; readonly updatedAt: Date; readonly customFields: CustomFields; } export interface PartnerBankAccount { readonly id: string; readonly active: boolean; readonly bankName: string; readonly accountHolderName: string; readonly accountNumber: string; readonly routingNumber: string | null; readonly currencyId: string; readonly createdAt: Date; readonly updatedAt: Date; readonly customFields: CustomFields; } declare const invariantsSatisfied: unique symbol; export interface BusinessPartner { readonly id: string; readonly header: BusinessPartnerHeader; readonly addresses: readonly PartnerAddress[]; readonly bankAccounts: readonly PartnerBankAccount[]; readonly [invariantsSatisfied]: true; } export type UncheckedBusinessPartner = Omit; export function markInvariantsSatisfied(partner: UncheckedBusinessPartner): BusinessPartner { return partner as BusinessPartner; } function validateName(name: string, identifier: string) { return name.trim() ? null : new InvalidNameError(identifier); } export function createActive(params: { name: string; type: BusinessPartnerType; customFields: CustomFields; }) { const violation = validateName(params.name, String(params.name)); if (violation) return err(violation); return ok( markInvariantsSatisfied({ id: crypto.randomUUID(), header: { name: params.name, type: params.type, status: "ACTIVE", customFields: params.customFields, }, addresses: [], bankAccounts: [], }), ); } export function updateDetails( partner: BusinessPartner, patch: { name?: string; customFields?: CustomFields }, ) { const name = patch.name ?? partner.header.name; const violation = validateName(name, partner.id); if (violation) return err(violation); return ok( markInvariantsSatisfied({ ...partner, header: { ...partner.header, name, customFields: patch.customFields ?? partner.header.customFields, }, }), ); } function transition(partner: BusinessPartner, operation: "deactivate" | "reactivate") { const status = businessPartnerLifecycle.tryTransition(partner.header.status, operation); if (!status) return err(new InvalidStateTransitionError(partner.id)); return ok( markInvariantsSatisfied({ ...partner, header: { ...partner.header, status }, }), ); } export const deactivate = (partner: BusinessPartner) => transition(partner, "deactivate"); export const reactivate = (partner: BusinessPartner) => transition(partner, "reactivate"); export interface NewPartnerAddress { line1: string; line2?: string | null; city: string; state?: string | null; postalCode: string; country: string; customFields: CustomFields; } export type PartnerAddressPatch = Partial< Pick > & { customFields?: CustomFields }; function validateAddress( address: Pick, ) { if (!address.line1.trim()) return new MissingRequiredAddressFieldError("line1"); if (!address.city.trim()) return new MissingRequiredAddressFieldError("city"); if (!address.postalCode.trim()) return new MissingRequiredAddressFieldError("postalCode"); if (!/^[A-Z]{2}$/.test(address.country)) { return new InvalidCountryCodeError(address.country); } return null; } export function addAddress(partner: BusinessPartner, params: NewPartnerAddress) { if (partner.header.status !== "ACTIVE") return err(new PartnerInactiveError(partner.id)); const violation = validateAddress(params); if (violation) return err(violation); const now = new Date(); const address: PartnerAddress = { id: crypto.randomUUID(), active: true, line1: params.line1, line2: params.line2 ?? null, city: params.city, state: params.state ?? null, postalCode: params.postalCode, country: params.country, createdAt: now, updatedAt: now, customFields: params.customFields, }; const updatedPartner = markInvariantsSatisfied({ ...partner, addresses: [...partner.addresses, address], }); return ok({ partner: updatedPartner, address }); } export function updateAddress( partner: BusinessPartner, addressId: string, patch: PartnerAddressPatch, ) { const existing = partner.addresses.find((address) => address.id === addressId); if (!existing) return err(new AddressNotFoundError(addressId)); const updated: PartnerAddress = { ...existing, line1: patch.line1 ?? existing.line1, line2: patch.line2 === undefined ? existing.line2 : patch.line2, city: patch.city ?? existing.city, state: patch.state === undefined ? existing.state : patch.state, postalCode: patch.postalCode ?? existing.postalCode, country: patch.country ?? existing.country, customFields: patch.customFields ? { ...existing.customFields, ...patch.customFields } : existing.customFields, updatedAt: new Date(), }; const violation = validateAddress(updated); if (violation) return err(violation); const updatedPartner = markInvariantsSatisfied({ ...partner, addresses: partner.addresses.map((address) => (address.id === addressId ? updated : address)), }); return ok({ partner: updatedPartner, address: updated }); } function setAddressActive(partner: BusinessPartner, addressId: string, active: boolean) { const existing = partner.addresses.find((address) => address.id === addressId); if (!existing) return err(new AddressNotFoundError(addressId)); if (existing.active === active) return err(new InvalidStateTransitionError(addressId)); const address = { ...existing, active, updatedAt: new Date() }; const updatedPartner = markInvariantsSatisfied({ ...partner, addresses: partner.addresses.map((item) => (item.id === addressId ? address : item)), }); return ok({ partner: updatedPartner, address }); } export const deactivateAddress = (partner: BusinessPartner, addressId: string) => setAddressActive(partner, addressId, false); export const reactivateAddress = (partner: BusinessPartner, addressId: string) => setAddressActive(partner, addressId, true); export interface NewPartnerBankAccount { bankName: string; accountHolderName: string; accountNumber: string; routingNumber?: string | null; currencyId: string; customFields: CustomFields; } export type PartnerBankAccountPatch = Partial< Pick< PartnerBankAccount, "bankName" | "accountHolderName" | "accountNumber" | "routingNumber" | "currencyId" > > & { customFields?: CustomFields }; function validateBankAccount( account: Pick, identifier: string, ) { if (!account.bankName.trim()) return new InvalidBankNameError(account.bankName); if (!account.accountHolderName.trim()) { return new InvalidAccountHolderNameError(account.accountHolderName); } if (!account.accountNumber?.trim()) { return new MissingAccountIdentifierError(identifier); } return null; } export function addBankAccount(partner: BusinessPartner, params: NewPartnerBankAccount) { if (partner.header.status !== "ACTIVE") return err(new PartnerInactiveError(partner.id)); const violation = validateBankAccount(params, partner.id); if (violation) return err(violation); const now = new Date(); const bankAccount: PartnerBankAccount = { id: crypto.randomUUID(), active: true, bankName: params.bankName, accountHolderName: params.accountHolderName, accountNumber: params.accountNumber, routingNumber: params.routingNumber ?? null, currencyId: params.currencyId, createdAt: now, updatedAt: now, customFields: params.customFields, }; const updatedPartner = markInvariantsSatisfied({ ...partner, bankAccounts: [...partner.bankAccounts, bankAccount], }); return ok({ partner: updatedPartner, bankAccount }); } export function updateBankAccount( partner: BusinessPartner, bankAccountId: string, patch: PartnerBankAccountPatch, ) { const existing = partner.bankAccounts.find((account) => account.id === bankAccountId); if (!existing) return err(new BankAccountNotFoundError(bankAccountId)); const updated: PartnerBankAccount = { ...existing, bankName: patch.bankName ?? existing.bankName, accountHolderName: patch.accountHolderName ?? existing.accountHolderName, accountNumber: patch.accountNumber === undefined ? existing.accountNumber : patch.accountNumber, routingNumber: patch.routingNumber === undefined ? existing.routingNumber : patch.routingNumber, currencyId: patch.currencyId ?? existing.currencyId, customFields: patch.customFields ? { ...existing.customFields, ...patch.customFields } : existing.customFields, updatedAt: new Date(), }; const violation = validateBankAccount(updated, bankAccountId); if (violation) return err(violation); const updatedPartner = markInvariantsSatisfied({ ...partner, bankAccounts: partner.bankAccounts.map((account) => account.id === bankAccountId ? updated : account, ), }); return ok({ partner: updatedPartner, bankAccount: updated }); } function setBankAccountActive(partner: BusinessPartner, bankAccountId: string, active: boolean) { const existing = partner.bankAccounts.find((account) => account.id === bankAccountId); if (!existing) return err(new BankAccountNotFoundError(bankAccountId)); if (existing.active === active) return err(new InvalidStateTransitionError(bankAccountId)); const bankAccount = { ...existing, active, updatedAt: new Date() }; const updatedPartner = markInvariantsSatisfied({ ...partner, bankAccounts: partner.bankAccounts.map((item) => item.id === bankAccountId ? bankAccount : item, ), }); return ok({ partner: updatedPartner, bankAccount }); } export const deactivateBankAccount = (partner: BusinessPartner, bankAccountId: string) => setBankAccountActive(partner, bankAccountId, false); export const reactivateBankAccount = (partner: BusinessPartner, bankAccountId: string) => setBankAccountActive(partner, bankAccountId, true);