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; 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; 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(); 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 { 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 { 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 { 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 { 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 { 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 = { 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('client_contracts').insert(insertPayload).returning('*'); return normalizeClientContract(created); } export async function updateClientContractAssignment( knexOrTrx: Knex | Knex.Transaction, tenant: string, clientContractId: string, updateData: Partial ): Promise { const existing = await getClientContractById(knexOrTrx, tenant, clientContractId); if (!existing) { throw new Error(`Client contract ${clientContractId} not found`); } const sanitized: Partial = { ...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('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); }