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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
629 lines
23 KiB
TypeScript
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);
|
|
}
|