feat(tenancies-renew-transfer): tenure-aware renewal + transfer actions

- renewTenancy service:
  - permanent / fee_simple / strata_lot → mutate-in-place (startDate
    moves forward, endDate may extend or null out)
  - fixed_term / seasonal → end the current row at its existing endDate
    + mint a successor with previousTenancyId chain. newEndDate required.
- transferTenancy service: end-and-spawn — end current row at
  transferDate, mint fresh active row with transferredFromTenancyId
  pointing back. New client + yacht cross-validated against port +
  ownership constraint (assertClientOwnsOrRepresentsYacht).
- POST /api/v1/tenancies/[id]/renew + /transfer routes gated on
  tenancies.manage + module-enabled.
- TenancyRenewDialog (tenure-aware copy explains in-place vs successor),
  TenancyTransferDialog (ClientPicker + YachtPicker with owner-scoped
  filter). Both mounted on tenancy-detail.tsx alongside Edit + End.
- Validators: renewTenancySchema + transferTenancySchema in
  src/lib/validators/tenancies.ts.

Verified: tsc clean, 1493/1493 vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 17:13:34 +02:00
parent 911b51a669
commit d32e557e56
7 changed files with 573 additions and 5 deletions

View File

@@ -18,6 +18,8 @@ import type {
EndTenancyInput,
CancelInput,
ListTenanciesInput,
RenewTenancyInput,
TransferTenancyInput,
UpdateTenancyInput,
} from '@/lib/validators/tenancies';
@@ -404,6 +406,218 @@ export async function updateTenancy(
return updated;
}
// ─── Renew (tenure-aware) ────────────────────────────────────────────────────
const PERMANENT_TENURES = new Set(['permanent', 'fee_simple', 'strata_lot']);
/**
* Renew an active tenancy. Per docs/tenancies-design.md:
* - permanent / fee_simple / strata_lot: mutate the existing row in
* place (one record forever) — startDate moves forward, endDate may
* extend or null out, notes append.
* - fixed_term / seasonal: end the current row at its existing
* endDate (or now if missing), then mint a successor row with
* previousTenancyId pointing back. Successor inherits berth /
* client / yacht / interestId / contractFileId; rep can re-edit
* via the standard update action.
*/
export async function renewTenancy(
tenancyId: string,
portId: string,
data: RenewTenancyInput,
meta: AuditMeta,
): Promise<BerthTenancy> {
const existing = await loadScoped(tenancyId, portId);
if (existing.status !== 'active') {
throw new ValidationError(
`Cannot renew a ${existing.status} tenancy — renewals require an active record.`,
);
}
if (PERMANENT_TENURES.has(existing.tenureType)) {
// Mutate-in-place path. End date can extend (or be removed entirely).
const [updated] = await db
.update(berthTenancies)
.set({
startDate: data.newStartDate,
endDate: data.newEndDate ?? null,
notes: data.notes
? `${existing.notes ? `${existing.notes}\n` : ''}Renewal: ${data.notes}`
: existing.notes,
updatedAt: new Date(),
})
.where(and(eq(berthTenancies.id, tenancyId), eq(berthTenancies.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'berth_tenancy',
entityId: tenancyId,
oldValue: { startDate: existing.startDate, endDate: existing.endDate },
newValue: { startDate: updated!.startDate, endDate: updated!.endDate, kind: 'renew' },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'berth_tenancy:activated', {
tenancyId,
berthId: updated!.berthId,
});
return updated!;
}
// Mint-successor path (fixed_term / seasonal). End the current row
// first so the partial unique index on (berthId, status='active')
// doesn't conflict when the new row activates.
if (!data.newEndDate) {
throw new ValidationError(
`Renewals on ${existing.tenureType} tenancies require a new end date.`,
);
}
return db.transaction(async (tx) => {
const closeDate = existing.endDate ?? new Date();
await tx
.update(berthTenancies)
.set({ status: 'ended', endDate: closeDate, updatedAt: new Date() })
.where(and(eq(berthTenancies.id, tenancyId), eq(berthTenancies.portId, portId)));
const [inserted] = await tx
.insert(berthTenancies)
.values({
portId,
berthId: existing.berthId,
clientId: existing.clientId,
yachtId: existing.yachtId,
interestId: existing.interestId,
status: 'active',
startDate: data.newStartDate,
endDate: data.newEndDate,
tenureType: existing.tenureType,
contractFileId: existing.contractFileId,
previousTenancyId: tenancyId,
notes: data.notes
? `Renewal of #${tenancyId.slice(0, 8)}: ${data.notes}`
: `Renewal of #${tenancyId.slice(0, 8)}`,
createdBy: meta.userId,
})
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'berth_tenancy',
entityId: inserted!.id,
newValue: {
previousTenancyId: tenancyId,
kind: 'renew_successor',
startDate: inserted!.startDate,
endDate: inserted!.endDate,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'berth_tenancy:created', {
tenancyId: inserted!.id,
berthId: inserted!.berthId,
});
return inserted!;
});
}
// ─── Transfer (client/yacht swap) ────────────────────────────────────────────
/**
* Hand a berth's active tenancy to a new client / yacht pair. The
* current row is ended at `transferDate`, then a fresh active row is
* minted with `transferredFromTenancyId` pointing at the predecessor.
* Berth + tenure type + contractFileId carry over; interestId resets
* to null (the original deal is closed).
*/
export async function transferTenancy(
tenancyId: string,
portId: string,
data: TransferTenancyInput,
meta: AuditMeta,
): Promise<BerthTenancy> {
const existing = await loadScoped(tenancyId, portId);
if (existing.status !== 'active') {
throw new ValidationError(
`Cannot transfer a ${existing.status} tenancy — transfers require an active record.`,
);
}
// Cross-validate new client + yacht live in this port.
const client = await db.query.clients.findFirst({
where: and(eq(clients.id, data.newClientId), eq(clients.portId, portId)),
});
if (!client) throw new ValidationError('new client not found in this port');
const yacht = await db.query.yachts.findFirst({
where: and(eq(yachts.id, data.newYachtId), eq(yachts.portId, portId)),
});
if (!yacht) throw new ValidationError('new yacht not found in this port');
await assertClientOwnsOrRepresentsYacht(
{ currentOwnerType: yacht.currentOwnerType, currentOwnerId: yacht.currentOwnerId },
data.newClientId,
);
return db.transaction(async (tx) => {
await tx
.update(berthTenancies)
.set({ status: 'ended', endDate: data.transferDate, updatedAt: new Date() })
.where(and(eq(berthTenancies.id, tenancyId), eq(berthTenancies.portId, portId)));
const [inserted] = await tx
.insert(berthTenancies)
.values({
portId,
berthId: existing.berthId,
clientId: data.newClientId,
yachtId: data.newYachtId,
interestId: null,
status: 'active',
startDate: data.transferDate,
endDate: existing.endDate,
tenureType: existing.tenureType,
contractFileId: existing.contractFileId,
transferredFromTenancyId: tenancyId,
notes: data.notes
? `Transferred from #${tenancyId.slice(0, 8)}: ${data.notes}`
: `Transferred from #${tenancyId.slice(0, 8)}`,
createdBy: meta.userId,
})
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'berth_tenancy',
entityId: inserted!.id,
newValue: {
transferredFromTenancyId: tenancyId,
kind: 'transfer',
previousClientId: existing.clientId,
newClientId: data.newClientId,
previousYachtId: existing.yachtId,
newYachtId: data.newYachtId,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'berth_tenancy:created', {
tenancyId: inserted!.id,
berthId: inserted!.berthId,
});
return inserted!;
});
}
// ─── Get ─────────────────────────────────────────────────────────────────────
export async function getById(id: string, portId: string): Promise<BerthTenancy> {

View File

@@ -42,6 +42,31 @@ export const updateTenancySchema = z
.refine((d) => Object.keys(d).length > 0, { message: 'At least one field must be provided' });
export type UpdateTenancyInput = z.infer<typeof updateTenancySchema>;
/** Renewal input. For permanent-class tenancies the existing row is
* mutated in place; for fixed_term / seasonal the existing row is
* ended at `previousEndDate` and a fresh row is minted with
* previousTenancyId pointing back. */
export const renewTenancySchema = z.object({
/** New start date for the renewal window (or the mutated row when permanent). */
newStartDate: z.coerce.date(),
/** New end date — required for fixed_term / seasonal; optional when permanent. */
newEndDate: z.coerce.date().optional(),
/** Optional notes appended to the successor / mutated row. */
notes: z.string().optional(),
});
export type RenewTenancyInput = z.infer<typeof renewTenancySchema>;
/** Transfer input — hand a berth's active tenancy to a new client/yacht
* pair. The current row is ended, a fresh active row is minted with
* transferredFromTenancyId pointing at the predecessor. */
export const transferTenancySchema = z.object({
newClientId: z.string().min(1),
newYachtId: z.string().min(1),
transferDate: z.coerce.date(),
notes: z.string().optional(),
});
export type TransferTenancyInput = z.infer<typeof transferTenancySchema>;
export const listTenanciesSchema = baseListQuerySchema.extend({
status: z.enum(TENANCY_STATUSES).optional(),
berthId: z.string().optional(),