PSA/shared/billingClients/clientContracts.ts
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

629 lines
23 KiB
TypeScript

import type { Knex } from 'knex';
import type { IClientContract } from '@alga-psa/types';
import { v4 as uuidv4 } from 'uuid';
import type { ClientContractAssignmentCreateInput } from './types';
import { assertBoardScopedTicketStatusSelection } from '../lib/boardScopedTicketStatusValidation';
import { deriveClientContractStatus } from './clientContractStatus';
type RenewalMode = NonNullable<IClientContract['renewal_mode']>;
const DEFAULT_RENEWAL_MODE: RenewalMode = 'manual';
const DEFAULT_NOTICE_PERIOD_DAYS = 30;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const MAX_ABSOLUTE_DAYS_UNTIL_DUE = 36500;
const normalizeRenewalMode = (value: unknown): RenewalMode | undefined => {
return value === 'none' || value === 'manual' || value === 'auto' ? value : undefined;
};
const normalizeNonNegativeInteger = (value: unknown): number | undefined => {
if (value === null || value === undefined) return undefined;
const numeric = typeof value === 'string' ? Number(value) : value;
if (typeof numeric !== 'number' || !Number.isFinite(numeric)) return undefined;
return Math.max(0, Math.trunc(numeric));
};
const normalizePositiveInteger = (value: unknown): number | undefined => {
const normalized = normalizeNonNegativeInteger(value);
return normalized !== undefined && normalized > 0 ? normalized : undefined;
};
const normalizeDateOnly = (value: unknown): string | undefined => {
if (value === null || value === undefined) return undefined;
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) return undefined;
return value.toISOString().slice(0, 10);
}
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return trimmed;
if (trimmed.includes('T')) {
return trimmed.slice(0, 10);
}
return undefined;
};
const findMixedCurrencyActiveAssignment = async (
knexOrTrx: Knex | Knex.Transaction,
tenant: string,
params: {
clientId: string;
targetCurrencyCode?: string | null;
}
): Promise<{ currency_code: string; contract_name: string | null } | null> => {
const targetCurrencyCode =
typeof params.targetCurrencyCode === 'string' && params.targetCurrencyCode.trim().length > 0
? params.targetCurrencyCode.trim()
: null;
if (!targetCurrencyCode) return null;
const rows = await knexOrTrx('client_contracts as cc')
.join('contracts as c', function joinContracts() {
this.on('cc.contract_id', '=', 'c.contract_id').andOn('cc.tenant', '=', 'c.tenant');
})
.where({
'cc.client_id': params.clientId,
'cc.tenant': tenant,
'cc.is_active': true,
'c.is_active': true,
})
.whereNot('c.currency_code', targetCurrencyCode)
.select('cc.start_date', 'cc.end_date', 'c.currency_code', 'c.contract_name');
const activeRow = rows.find((row: {
start_date: string | null;
end_date: string | null;
currency_code?: string | null;
}) => deriveClientContractStatus({
isActive: true,
startDate: row.start_date,
endDate: row.end_date,
}) === 'active');
if (!activeRow || typeof activeRow.currency_code !== 'string' || activeRow.currency_code.trim().length === 0) {
return null;
}
return {
currency_code: activeRow.currency_code,
contract_name: typeof activeRow.contract_name === 'string' ? activeRow.contract_name : null,
};
};
const subtractDaysFromDateOnly = (dateOnly: string, days: number): string | undefined => {
if (!Number.isInteger(days) || days < 0) return undefined;
const parsed = new Date(`${dateOnly}T00:00:00.000Z`);
if (Number.isNaN(parsed.getTime())) return undefined;
return new Date(parsed.getTime() - days * MS_PER_DAY).toISOString().slice(0, 10);
};
const createClampedUtcDate = (year: number, monthIndex: number, dayOfMonth: number): Date => {
const lastDayOfMonth = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
const clampedDay = Math.min(dayOfMonth, lastDayOfMonth);
return new Date(Date.UTC(year, monthIndex, clampedDay));
};
export const computeNextEvergreenReviewAnchorDate = (params: {
startDate: string;
now?: string | Date;
}): string | undefined => {
const normalizedStartDate = normalizeDateOnly(params.startDate);
if (!normalizedStartDate) return undefined;
const start = new Date(`${normalizedStartDate}T00:00:00.000Z`);
if (Number.isNaN(start.getTime())) return undefined;
const normalizedNow = normalizeDateOnly(params.now instanceof Date ? params.now.toISOString() : params.now);
const nowBase = normalizedNow
? new Date(`${normalizedNow}T00:00:00.000Z`)
: new Date(new Date().toISOString().slice(0, 10) + 'T00:00:00.000Z');
if (Number.isNaN(nowBase.getTime())) return undefined;
const month = start.getUTCMonth();
const day = start.getUTCDate();
const thisYearCandidate = createClampedUtcDate(nowBase.getUTCFullYear(), month, day);
const nextAnchor =
thisYearCandidate.getTime() >= nowBase.getTime()
? thisYearCandidate
: createClampedUtcDate(nowBase.getUTCFullYear() + 1, month, day);
return nextAnchor.toISOString().slice(0, 10);
};
export const computeEvergreenDecisionDueDate = (params: {
startDate: string;
noticePeriodDays: number;
now?: string | Date;
}): string | undefined => {
const anchorDate = computeNextEvergreenReviewAnchorDate({
startDate: params.startDate,
now: params.now,
});
if (!anchorDate) return undefined;
const normalizedNoticePeriodDays = normalizeNonNegativeInteger(params.noticePeriodDays);
if (normalizedNoticePeriodDays === undefined) return undefined;
return subtractDaysFromDateOnly(anchorDate, normalizedNoticePeriodDays);
};
export const computeEvergreenCycleBounds = (params: {
startDate: string;
now?: string | Date;
}): { cycleStart: string; cycleEnd: string } | undefined => {
const normalizedStartDate = normalizeDateOnly(params.startDate);
if (!normalizedStartDate) return undefined;
const start = new Date(`${normalizedStartDate}T00:00:00.000Z`);
if (Number.isNaN(start.getTime())) return undefined;
const anchorDate = computeNextEvergreenReviewAnchorDate({
startDate: normalizedStartDate,
now: params.now,
});
if (!anchorDate) return undefined;
const anchor = new Date(`${anchorDate}T00:00:00.000Z`);
if (Number.isNaN(anchor.getTime())) return undefined;
const previousAnchor = createClampedUtcDate(
anchor.getUTCFullYear() - 1,
start.getUTCMonth(),
start.getUTCDate()
);
const cycleStartDate = previousAnchor.getTime() < start.getTime() ? start : previousAnchor;
return {
cycleStart: cycleStartDate.toISOString().slice(0, 10),
cycleEnd: anchorDate,
};
};
export const computeDaysUntilDate = (params: {
targetDate: string;
now?: string | Date;
}): number | undefined => {
const normalizedTargetDate = normalizeDateOnly(params.targetDate);
if (!normalizedTargetDate) return undefined;
const target = new Date(`${normalizedTargetDate}T00:00:00.000Z`);
if (Number.isNaN(target.getTime())) return undefined;
const normalizedNow = normalizeDateOnly(params.now instanceof Date ? params.now.toISOString() : params.now);
const nowBase = normalizedNow
? new Date(`${normalizedNow}T00:00:00.000Z`)
: new Date(new Date().toISOString().slice(0, 10) + 'T00:00:00.000Z');
if (Number.isNaN(nowBase.getTime())) return undefined;
const rawDaysUntilDue = Math.round((target.getTime() - nowBase.getTime()) / MS_PER_DAY);
if (!Number.isFinite(rawDaysUntilDue)) return undefined;
return Math.max(
-MAX_ABSOLUTE_DAYS_UNTIL_DUE,
Math.min(MAX_ABSOLUTE_DAYS_UNTIL_DUE, rawDaysUntilDue)
);
};
const RENEWAL_DEFAULT_SELECTIONS = [
'dbs.default_renewal_mode as tenant_default_renewal_mode',
'dbs.default_notice_period_days as tenant_default_notice_period_days',
];
const withRenewalDefaultsJoin = (
query: Knex.QueryBuilder
): Knex.QueryBuilder => {
return query.leftJoin('default_billing_settings as dbs', function joinDefaultBillingSettings() {
this.on('cc.tenant', '=', 'dbs.tenant');
});
};
export const normalizeClientContract = (row: any): IClientContract => {
if (!row) return row;
const normalized = { ...row } as Record<string, unknown>;
if (normalized.contract_billing_frequency !== undefined && normalized.billing_frequency === undefined) {
normalized.billing_frequency = normalized.contract_billing_frequency;
}
const renewalMode = normalizeRenewalMode(normalized.renewal_mode);
const noticePeriodDays = normalizeNonNegativeInteger(normalized.notice_period_days);
const renewalTermMonths = normalizePositiveInteger(normalized.renewal_term_months);
const useTenantDefaults =
typeof normalized.use_tenant_renewal_defaults === 'boolean'
? normalized.use_tenant_renewal_defaults
: true;
const tenantDefaultRenewalMode =
normalizeRenewalMode(normalized.tenant_default_renewal_mode) ?? DEFAULT_RENEWAL_MODE;
const tenantDefaultNoticePeriodDays =
normalizeNonNegativeInteger(normalized.tenant_default_notice_period_days) ?? DEFAULT_NOTICE_PERIOD_DAYS;
normalized.renewal_mode = renewalMode;
normalized.notice_period_days = noticePeriodDays;
normalized.renewal_term_months = renewalTermMonths;
normalized.use_tenant_renewal_defaults = useTenantDefaults;
normalized.effective_renewal_mode = useTenantDefaults
? tenantDefaultRenewalMode
: renewalMode ?? tenantDefaultRenewalMode;
normalized.effective_notice_period_days = useTenantDefaults
? tenantDefaultNoticePeriodDays
: noticePeriodDays ?? tenantDefaultNoticePeriodDays;
const normalizedEndDate = normalizeDateOnly(normalized.end_date);
const normalizedStartDate = normalizeDateOnly(normalized.start_date);
const effectiveNoticePeriodDays = normalizeNonNegativeInteger(normalized.effective_notice_period_days);
const effectiveRenewalMode = normalizeRenewalMode(normalized.effective_renewal_mode);
const isInactiveAssignment = normalized.is_active !== true;
const shouldSkipForLifecycleState = isInactiveAssignment;
normalized.evergreen_review_anchor_date =
!shouldSkipForLifecycleState && !normalizedEndDate && normalizedStartDate
? computeNextEvergreenReviewAnchorDate({ startDate: normalizedStartDate })
: undefined;
const shouldSkipDecisionDueDate =
shouldSkipForLifecycleState || (effectiveRenewalMode === 'none' && !normalized.evergreen_review_anchor_date);
normalized.decision_due_date =
shouldSkipDecisionDueDate
? undefined
: normalizedEndDate && effectiveNoticePeriodDays !== undefined
? subtractDaysFromDateOnly(normalizedEndDate, effectiveNoticePeriodDays)
: !normalizedEndDate && normalizedStartDate && effectiveNoticePeriodDays !== undefined
? computeEvergreenDecisionDueDate({
startDate: normalizedStartDate,
noticePeriodDays: effectiveNoticePeriodDays,
})
: undefined;
if (normalizedEndDate && normalizedStartDate) {
normalized.renewal_cycle_start = normalizedStartDate;
normalized.renewal_cycle_end = normalizedEndDate;
} else if (!normalizedEndDate && normalizedStartDate && normalized.evergreen_review_anchor_date) {
const evergreenCycleBounds = computeEvergreenCycleBounds({ startDate: normalizedStartDate });
normalized.renewal_cycle_start = evergreenCycleBounds?.cycleStart;
normalized.renewal_cycle_end = evergreenCycleBounds?.cycleEnd;
} else {
normalized.renewal_cycle_start = undefined;
normalized.renewal_cycle_end = undefined;
}
normalized.renewal_cycle_key = normalized.decision_due_date
? normalizedEndDate
? normalized.renewal_cycle_end
? `fixed-term:${normalized.renewal_cycle_end as string}`
: undefined
: normalized.renewal_cycle_end
? `evergreen:${normalized.renewal_cycle_end as string}`
: undefined
: undefined;
normalized.days_until_due = normalized.decision_due_date
? computeDaysUntilDate({ targetDate: normalized.decision_due_date as string })
: undefined;
normalized.assignment_status = deriveClientContractStatus({
isActive: normalized.is_active === true,
startDate: normalizedStartDate ?? null,
endDate: normalizedEndDate ?? null,
});
delete normalized.tenant_default_renewal_mode;
delete normalized.tenant_default_notice_period_days;
return normalized as unknown as IClientContract;
};
export const dedupeClientContractsByRenewalCycle = (
rows: IClientContract[]
): IClientContract[] => {
const deduped = new Map<string, IClientContract>();
for (const row of rows) {
const cycleKey = row.renewal_cycle_key;
if (!cycleKey) {
deduped.set(`${row.tenant}:${row.client_contract_id}`, row);
continue;
}
const dedupeKey = `${row.tenant}:${row.client_contract_id}:${cycleKey}`;
if (!deduped.has(dedupeKey)) {
deduped.set(dedupeKey, row);
}
}
return [...deduped.values()];
};
export async function getClientContracts(
knexOrTrx: Knex | Knex.Transaction,
tenant: string,
clientId: string
): Promise<IClientContract[]> {
const baseQuery = knexOrTrx('client_contracts as cc')
.leftJoin('contracts as c', function joinContracts() {
this.on('cc.contract_id', '=', 'c.contract_id').andOn('cc.tenant', '=', 'c.tenant');
})
.where({ 'cc.client_id': clientId, 'cc.tenant': tenant, 'cc.is_active': true })
.orderBy('cc.start_date', 'desc');
const rows = await withRenewalDefaultsJoin(baseQuery).select([
'cc.*',
'c.billing_frequency as contract_billing_frequency',
'c.status as contract_status',
...RENEWAL_DEFAULT_SELECTIONS,
]);
return dedupeClientContractsByRenewalCycle(rows.map(normalizeClientContract));
}
export async function getActiveClientContractsByClientIds(
knexOrTrx: Knex | Knex.Transaction,
tenant: string,
clientIds: string[]
): Promise<IClientContract[]> {
if (clientIds.length === 0) return [];
const baseQuery = knexOrTrx('client_contracts as cc')
.leftJoin('contracts as c', function joinContracts() {
this.on('cc.contract_id', '=', 'c.contract_id').andOn('cc.tenant', '=', 'c.tenant');
})
.whereIn('cc.client_id', clientIds)
.andWhere({ 'cc.tenant': tenant, 'cc.is_active': true })
.orderBy([
{ column: 'cc.client_id', order: 'asc' },
{ column: 'cc.start_date', order: 'desc' },
]);
const rows = await withRenewalDefaultsJoin(baseQuery).select([
'cc.*',
'c.billing_frequency as contract_billing_frequency',
'c.status as contract_status',
...RENEWAL_DEFAULT_SELECTIONS,
]);
return dedupeClientContractsByRenewalCycle(rows.map(normalizeClientContract));
}
export async function getClientContractById(
knexOrTrx: Knex | Knex.Transaction,
tenant: string,
clientContractId: string
): Promise<IClientContract | null> {
const baseQuery = knexOrTrx('client_contracts as cc')
.leftJoin('contracts as c', function joinContracts() {
this.on('cc.contract_id', '=', 'c.contract_id').andOn('cc.tenant', '=', 'c.tenant');
})
.where({ 'cc.client_contract_id': clientContractId, 'cc.tenant': tenant });
const row = await withRenewalDefaultsJoin(baseQuery)
.select([
'cc.*',
'c.billing_frequency as contract_billing_frequency',
'c.status as contract_status',
...RENEWAL_DEFAULT_SELECTIONS,
])
.first();
return row ? normalizeClientContract(row) : null;
}
export async function getDetailedClientContract(
knexOrTrx: Knex | Knex.Transaction,
tenant: string,
clientContractId: string
): Promise<any | null> {
const baseQuery = knexOrTrx('client_contracts as cc')
.join('contracts as c', function joinContracts() {
this.on('cc.contract_id', '=', 'c.contract_id').andOn('cc.tenant', '=', 'c.tenant');
})
.where({ 'cc.client_contract_id': clientContractId, 'cc.tenant': tenant });
const clientContract = await withRenewalDefaultsJoin(baseQuery).select(
[
'cc.*',
'c.contract_name',
'c.contract_description',
'c.billing_frequency as contract_billing_frequency',
'c.status as contract_status',
...RENEWAL_DEFAULT_SELECTIONS,
]
)
.first();
if (!clientContract) return null;
const normalized = normalizeClientContract(clientContract);
const contractLines = await knexOrTrx('client_contracts as cc')
.join('contract_lines as cl', function joinContractLines() {
this.on('cc.contract_id', '=', 'cl.contract_id').andOn('cc.tenant', '=', 'cl.tenant');
})
.where({
'cc.client_contract_id': clientContractId,
'cc.tenant': tenant,
})
.distinct('cl.contract_line_id', 'cl.contract_line_name')
.select('cl.contract_line_name');
return {
...normalized,
contract_line_names: contractLines.map((line) => line.contract_line_name),
contract_line_count: contractLines.length,
};
}
export async function createClientContractAssignment(
knexOrTrx: Knex | Knex.Transaction,
tenant: string,
input: ClientContractAssignmentCreateInput
): Promise<IClientContract> {
const clientExists = await knexOrTrx('clients').where({ client_id: input.client_id, tenant }).first();
if (!clientExists) {
throw new Error(`Client ${input.client_id} not found`);
}
const contractQuery = knexOrTrx('contracts')
.where({ contract_id: input.contract_id, tenant });
if (input.is_active) {
contractQuery.andWhere({ is_active: true });
}
const contractExists = await contractQuery.first();
if (!contractExists) {
throw new Error(`Contract ${input.contract_id} not found or inactive`);
}
if (input.is_active) {
const targetCurrencyCode =
typeof (contractExists as { currency_code?: string | null }).currency_code === 'string'
? (contractExists as { currency_code: string }).currency_code
: null;
const mixedCurrencyConflict = await findMixedCurrencyActiveAssignment(knexOrTrx, tenant, {
clientId: input.client_id,
targetCurrencyCode,
});
if (mixedCurrencyConflict) {
const contractLabel = mixedCurrencyConflict.contract_name
? ` ("${mixedCurrencyConflict.contract_name}")`
: '';
throw new Error(
`Client already has an active contract in ${mixedCurrencyConflict.currency_code}${contractLabel}. ` +
`Cannot create a contract in ${targetCurrencyCode}. Mixed-currency contracts for the same client are not supported.`
);
}
}
const timestamp = new Date().toISOString();
const insertPayload: Record<string, unknown> = {
client_contract_id: uuidv4(),
client_id: input.client_id,
contract_id: input.contract_id,
template_contract_id: null,
start_date: input.start_date,
end_date: input.end_date,
is_active: input.is_active,
tenant,
created_at: timestamp,
updated_at: timestamp,
};
await assertBoardScopedTicketStatusSelection({
trx: knexOrTrx,
tenant,
boardId: input.renewal_ticket_board_id ?? null,
statusId: input.renewal_ticket_status_id ?? null,
statusLabel: 'Renewal ticket status',
});
if (input.renewal_mode === 'none' || input.renewal_mode === 'manual' || input.renewal_mode === 'auto') {
insertPayload.renewal_mode = input.renewal_mode;
}
if (Number.isInteger(input.notice_period_days) && (input.notice_period_days as number) >= 0) {
insertPayload.notice_period_days = input.notice_period_days;
}
if (Number.isInteger(input.renewal_term_months) && (input.renewal_term_months as number) > 0) {
insertPayload.renewal_term_months = input.renewal_term_months;
}
if (typeof input.use_tenant_renewal_defaults === 'boolean') {
insertPayload.use_tenant_renewal_defaults = input.use_tenant_renewal_defaults;
}
const [
hasRenewalDueDateActionPolicy,
hasRenewalTicketBoard,
hasRenewalTicketStatus,
hasRenewalTicketPriority,
hasRenewalTicketAssignee,
hasPoRequired,
hasPoNumber,
hasPoAmount,
] = await Promise.all([
(knexOrTrx as any).schema?.hasColumn?.('client_contracts', 'renewal_due_date_action_policy'),
(knexOrTrx as any).schema?.hasColumn?.('client_contracts', 'renewal_ticket_board_id'),
(knexOrTrx as any).schema?.hasColumn?.('client_contracts', 'renewal_ticket_status_id'),
(knexOrTrx as any).schema?.hasColumn?.('client_contracts', 'renewal_ticket_priority'),
(knexOrTrx as any).schema?.hasColumn?.('client_contracts', 'renewal_ticket_assignee_id'),
(knexOrTrx as any).schema?.hasColumn?.('client_contracts', 'po_required'),
(knexOrTrx as any).schema?.hasColumn?.('client_contracts', 'po_number'),
(knexOrTrx as any).schema?.hasColumn?.('client_contracts', 'po_amount'),
]);
if (hasRenewalDueDateActionPolicy) {
insertPayload.renewal_due_date_action_policy = input.renewal_due_date_action_policy ?? null;
}
if (hasRenewalTicketBoard) {
insertPayload.renewal_ticket_board_id = input.renewal_ticket_board_id ?? null;
}
if (hasRenewalTicketStatus) {
insertPayload.renewal_ticket_status_id = input.renewal_ticket_status_id ?? null;
}
if (hasRenewalTicketPriority) {
insertPayload.renewal_ticket_priority = input.renewal_ticket_priority ?? null;
}
if (hasRenewalTicketAssignee) {
insertPayload.renewal_ticket_assignee_id = input.renewal_ticket_assignee_id ?? null;
}
if (hasPoRequired) insertPayload.po_required = Boolean(input.po_required);
if (hasPoNumber) insertPayload.po_number = input.po_number ?? null;
if (hasPoAmount) insertPayload.po_amount = input.po_amount ?? null;
const [created] = await knexOrTrx<IClientContract>('client_contracts').insert(insertPayload).returning('*');
return normalizeClientContract(created);
}
export async function updateClientContractAssignment(
knexOrTrx: Knex | Knex.Transaction,
tenant: string,
clientContractId: string,
updateData: Partial<IClientContract>
): Promise<IClientContract> {
const existing = await getClientContractById(knexOrTrx, tenant, clientContractId);
if (!existing) {
throw new Error(`Client contract ${clientContractId} not found`);
}
const sanitized: Partial<IClientContract> = {
...updateData,
tenant: undefined as any,
client_contract_id: undefined as any,
client_id: undefined as any,
contract_id: undefined as any,
created_at: undefined as any,
updated_at: new Date().toISOString() as any,
};
const effectiveRenewalTicketBoardId =
updateData.renewal_ticket_board_id !== undefined
? updateData.renewal_ticket_board_id ?? null
: existing.renewal_ticket_board_id ?? null;
const effectiveRenewalTicketStatusId =
updateData.renewal_ticket_status_id !== undefined
? updateData.renewal_ticket_status_id ?? null
: existing.renewal_ticket_status_id ?? null;
await assertBoardScopedTicketStatusSelection({
trx: knexOrTrx,
tenant,
boardId: effectiveRenewalTicketBoardId,
statusId: effectiveRenewalTicketStatusId,
statusLabel: 'Renewal ticket status',
});
if (updateData.start_date !== undefined && updateData.start_date !== existing.start_date) {
const contract = await knexOrTrx('contracts')
.where({ contract_id: existing.contract_id, tenant })
.first();
if (contract && contract.is_active) {
throw new Error('Start date cannot be changed for active contracts. Set the contract to draft first.');
}
}
const [updated] = await knexOrTrx<IClientContract>('client_contracts')
.where({ tenant, client_contract_id: clientContractId })
.update(sanitized as any)
.returning('*');
if (!updated) {
throw new Error(`Client contract ${clientContractId} not found`);
}
return normalizeClientContract(updated);
}