fix(audit): H2/H3 — client merge re-points payments, memberships, yacht & invoice ownership

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:09:49 +02:00
parent 7a7fd76081
commit 865ae5c072

View File

@@ -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<MergeResult> {
.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<MergeResult> {
movedTenancies,
movedContacts,
movedAddresses,
movedPayments,
movedCompanyMemberships,
movedYachts,
movedInvoices,
},
});
@@ -373,6 +469,10 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
tags: movedTags,
relationships: movedRelationships,
tenancies: movedTenancies,
payments: movedPayments,
companyMemberships: movedCompanyMemberships,
yachts: movedYachts,
invoices: movedInvoices,
},
};
});