From 865ae5c072e2b1ac0eb56816e41aca9ad6008ba5 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 2 Jun 2026 12:09:49 +0200 Subject: [PATCH] =?UTF-8?q?fix(audit):=20H2/H3=20=E2=80=94=20client=20merg?= =?UTF-8?q?e=20re-points=20payments,=20memberships,=20yacht=20&=20invoice?= =?UTF-8?q?=20ownership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge now re-points the loser's payments, company memberships (deduped against unique_cm_exact), polymorphic yacht ownership, and polymorphic invoice billing-entity to the winner inside the same transaction, before archiving the loser. H2: the winner no longer silently loses those rows. H3: because payments (notNull onDelete:cascade) are moved off the loser, a later hard-delete of the archived loser can no longer cascade-delete the winner's financial history. Counts wired into the merge result + audit row. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/services/client-merge.service.ts | 100 +++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/src/lib/services/client-merge.service.ts b/src/lib/services/client-merge.service.ts index 7e060d5c..09f6bf52 100644 --- a/src/lib/services/client-merge.service.ts +++ b/src/lib/services/client-merge.service.ts @@ -32,6 +32,10 @@ import { } from '@/lib/db/schema/clients'; import { interests } from '@/lib/db/schema/interests'; import { berthTenancies } from '@/lib/db/schema/tenancies'; +import { payments } from '@/lib/db/schema/pipeline'; +import { companyMemberships } from '@/lib/db/schema/companies'; +import { yachts } from '@/lib/db/schema/yachts'; +import { invoices } from '@/lib/db/schema/financial'; import { auditLogs } from '@/lib/db/schema/system'; import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; @@ -81,6 +85,10 @@ export interface MergeResult { tags: number; relationships: number; tenancies: number; + payments: number; + companyMemberships: number; + yachts: number; + invoices: number; }; } @@ -301,6 +309,90 @@ export async function mergeClients(opts: MergeOptions): Promise { .returning({ id: clientRelationships.id }) ).length; + // Payments: re-point every payment row from the loser to the winner. + // Critical: payments.clientId is notNull onDelete:'cascade', so leaving + // them on the archived loser would let a later hard-delete cascade away + // the winner's financial history. Scoped to the port defensively. + const movedPayments = ( + await tx + .update(payments) + .set({ clientId: opts.winnerId }) + .where(and(eq(payments.clientId, opts.loserId), eq(payments.portId, winnerRow.portId))) + .returning({ id: payments.id }) + ).length; + + // Company memberships: re-point, but dedup against the unique constraint + // `unique_cm_exact` (companyId, clientId, role, startDate). If the winner + // already has an equivalent membership, drop the loser's row instead of + // re-pointing so we don't violate the unique index. + const winnerMemberships = await tx + .select({ + companyId: companyMemberships.companyId, + role: companyMemberships.role, + startDate: companyMemberships.startDate, + }) + .from(companyMemberships) + .where(eq(companyMemberships.clientId, opts.winnerId)); + const winnerMembershipKeys = new Set( + winnerMemberships.map( + (m) => `${m.companyId}::${m.role}::${m.startDate ? m.startDate.toISOString() : ''}`, + ), + ); + const loserMemberships = await tx + .select({ + id: companyMemberships.id, + companyId: companyMemberships.companyId, + role: companyMemberships.role, + startDate: companyMemberships.startDate, + }) + .from(companyMemberships) + .where(eq(companyMemberships.clientId, opts.loserId)); + let movedCompanyMemberships = 0; + for (const m of loserMemberships) { + const key = `${m.companyId}::${m.role}::${m.startDate ? m.startDate.toISOString() : ''}`; + if (winnerMembershipKeys.has(key)) { + // Winner already has an equivalent membership - drop the loser's row. + await tx.delete(companyMemberships).where(eq(companyMemberships.id, m.id)); + continue; + } + await tx + .update(companyMemberships) + .set({ clientId: opts.winnerId, updatedAt: new Date() }) + .where(eq(companyMemberships.id, m.id)); + movedCompanyMemberships += 1; + } + + // Yachts: re-point polymorphic ownership on the denormalized owner + // columns only. yacht_ownership_history is handled separately. + const movedYachts = ( + await tx + .update(yachts) + .set({ currentOwnerId: opts.winnerId, updatedAt: new Date() }) + .where( + and( + eq(yachts.currentOwnerType, 'client'), + eq(yachts.currentOwnerId, opts.loserId), + eq(yachts.portId, winnerRow.portId), + ), + ) + .returning({ id: yachts.id }) + ).length; + + // Invoices: re-point polymorphic billing entity from loser to winner. + const movedInvoices = ( + await tx + .update(invoices) + .set({ billingEntityId: opts.winnerId, updatedAt: new Date() }) + .where( + and( + eq(invoices.billingEntityType, 'client'), + eq(invoices.billingEntityId, opts.loserId), + eq(invoices.portId, winnerRow.portId), + ), + ) + .returning({ id: invoices.id }) + ).length; + // ── Archive the loser. Row stays in DB for the undo window; // `mergedIntoClientId` is the redirect pointer for any stragglers // (links / direct queries / saved views). ────────────────────────── @@ -360,6 +452,10 @@ export async function mergeClients(opts: MergeOptions): Promise { movedTenancies, movedContacts, movedAddresses, + movedPayments, + movedCompanyMemberships, + movedYachts, + movedInvoices, }, }); @@ -373,6 +469,10 @@ export async function mergeClients(opts: MergeOptions): Promise { tags: movedTags, relationships: movedRelationships, tenancies: movedTenancies, + payments: movedPayments, + companyMemberships: movedCompanyMemberships, + yachts: movedYachts, + invoices: movedInvoices, }, }; });