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
1642 lines
53 KiB
TypeScript
1642 lines
53 KiB
TypeScript
import { v4 as uuidv4 } from 'uuid';
|
|
import type { CadenceOwner } from '@alga-psa/types';
|
|
import type { TestContext } from './testContext';
|
|
|
|
interface SetupTaxOptions {
|
|
regionCode?: string;
|
|
regionName?: string;
|
|
taxPercentage?: number;
|
|
startDate?: string;
|
|
description?: string;
|
|
clientId?: string;
|
|
}
|
|
|
|
interface AssignServiceTaxRateOptions {
|
|
onlyUnset?: boolean;
|
|
}
|
|
|
|
let clientTaxSettingsColumnsCache: Record<string, unknown> | null | undefined;
|
|
let clientTaxRatesColumnsCache: Record<string, unknown> | null | undefined;
|
|
const serviceTypeCache = new Map<string, string>();
|
|
const debugFlags = {
|
|
createServiceLogCount: 0
|
|
};
|
|
|
|
/**
|
|
* Clears the service type cache. Useful when tests reset their context/tenant
|
|
* and need to ensure stale service type IDs aren't reused.
|
|
*/
|
|
export function clearServiceTypeCache(): void {
|
|
serviceTypeCache.clear();
|
|
}
|
|
|
|
interface BillingSettingsOptions {
|
|
zeroDollarInvoiceHandling?: 'normal' | 'finalized';
|
|
suppressZeroDollarInvoices?: boolean;
|
|
enableCreditExpiration?: boolean;
|
|
creditExpirationDays?: number;
|
|
creditExpirationNotificationDays?: number[];
|
|
}
|
|
|
|
export async function setupClientTaxConfiguration(
|
|
context: TestContext,
|
|
options: SetupTaxOptions = {}
|
|
): Promise<string> {
|
|
const {
|
|
regionCode = 'US-NY',
|
|
regionName = 'Default Region',
|
|
taxPercentage,
|
|
startDate = '2025-01-01T00:00:00.000Z',
|
|
description = `${regionCode} Tax`
|
|
} = options;
|
|
|
|
const targetClientId = options.clientId ?? context.clientId;
|
|
|
|
const existingActiveRate = await context.db('tax_rates')
|
|
.where({ tenant: context.tenantId, region_code: regionCode, is_active: true })
|
|
.orderBy('start_date', 'desc')
|
|
.first();
|
|
|
|
const shouldCreateNewRate = typeof taxPercentage === 'number';
|
|
|
|
const taxRateId = shouldCreateNewRate ? uuidv4() : existingActiveRate?.tax_rate_id ?? uuidv4();
|
|
|
|
if (shouldCreateNewRate) {
|
|
// Deactivate any existing tax rates for this region within the tenant so the new rate becomes authoritative
|
|
await context.db('tax_rates')
|
|
.where({ tenant: context.tenantId, region_code: regionCode })
|
|
.update({ is_active: false });
|
|
}
|
|
|
|
await context.db('tax_regions')
|
|
.insert({
|
|
tenant: context.tenantId,
|
|
region_code: regionCode,
|
|
region_name: regionName,
|
|
is_active: true
|
|
})
|
|
.onConflict(['tenant', 'region_code'])
|
|
.ignore();
|
|
|
|
if (shouldCreateNewRate || !existingActiveRate) {
|
|
try {
|
|
await context.db('tax_rates')
|
|
.insert({
|
|
tax_rate_id: taxRateId,
|
|
tenant: context.tenantId,
|
|
region_code: regionCode,
|
|
tax_percentage: shouldCreateNewRate
|
|
? taxPercentage
|
|
: existingActiveRate?.tax_percentage ?? 8.875,
|
|
description,
|
|
start_date: startDate,
|
|
is_active: true
|
|
});
|
|
} catch (error: unknown) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (!message.includes('duplicate') && !(error as { code?: string }).code?.includes('23505')) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
await upsertClientTaxSettings(context, taxRateId, targetClientId);
|
|
await upsertClientDefaultTaxRate(context, taxRateId, targetClientId);
|
|
await assignServiceTaxRate(context, '*', regionCode, { onlyUnset: true });
|
|
|
|
return taxRateId;
|
|
}
|
|
|
|
export async function assignServiceTaxRate(
|
|
context: TestContext,
|
|
serviceId: string | '*',
|
|
region: string,
|
|
options: AssignServiceTaxRateOptions = {}
|
|
): Promise<void> {
|
|
const taxRate = await context.db('tax_rates')
|
|
.where({ tenant: context.tenantId, region_code: region })
|
|
.orderBy('start_date', 'desc')
|
|
.first();
|
|
|
|
if (!taxRate) {
|
|
return;
|
|
}
|
|
|
|
const query = context.db('service_catalog')
|
|
.where({ tenant: context.tenantId });
|
|
|
|
if (serviceId !== '*') {
|
|
query.andWhere({ service_id: serviceId });
|
|
}
|
|
|
|
if (options.onlyUnset) {
|
|
query.whereNull('tax_rate_id');
|
|
}
|
|
|
|
await query.update({ tax_rate_id: taxRate.tax_rate_id });
|
|
}
|
|
|
|
async function upsertClientTaxSettings(
|
|
context: TestContext,
|
|
taxRateId: string,
|
|
clientId: string
|
|
): Promise<void> {
|
|
try {
|
|
if (clientTaxSettingsColumnsCache === undefined) {
|
|
clientTaxSettingsColumnsCache = await context.db('client_tax_settings').columnInfo();
|
|
}
|
|
} catch (error) {
|
|
clientTaxSettingsColumnsCache = null;
|
|
}
|
|
|
|
if (!clientTaxSettingsColumnsCache || Object.keys(clientTaxSettingsColumnsCache).length === 0) {
|
|
return;
|
|
}
|
|
|
|
const clientExists = await context.db('clients')
|
|
.where({ tenant: context.tenantId, client_id: clientId })
|
|
.first();
|
|
|
|
if (!clientExists) {
|
|
return;
|
|
}
|
|
|
|
const baseData: Record<string, unknown> = {
|
|
tenant: context.tenantId,
|
|
client_id: clientId,
|
|
is_reverse_charge_applicable: false
|
|
};
|
|
|
|
if ('tax_rate_id' in clientTaxSettingsColumnsCache) {
|
|
baseData.tax_rate_id = taxRateId;
|
|
}
|
|
|
|
await context.db('client_tax_settings')
|
|
.insert(baseData)
|
|
.onConflict(['tenant', 'client_id'])
|
|
.merge(baseData);
|
|
}
|
|
|
|
async function upsertClientDefaultTaxRate(
|
|
context: TestContext,
|
|
taxRateId: string,
|
|
clientId: string
|
|
): Promise<void> {
|
|
try {
|
|
if (clientTaxRatesColumnsCache === undefined) {
|
|
clientTaxRatesColumnsCache = await context.db('client_tax_rates').columnInfo();
|
|
}
|
|
} catch (error) {
|
|
clientTaxRatesColumnsCache = null;
|
|
}
|
|
|
|
if (!clientTaxRatesColumnsCache || Object.keys(clientTaxRatesColumnsCache).length === 0) {
|
|
return;
|
|
}
|
|
|
|
const clientExists = await context.db('clients')
|
|
.where({ tenant: context.tenantId, client_id: clientId })
|
|
.first();
|
|
|
|
if (!clientExists) {
|
|
return;
|
|
}
|
|
|
|
if ('is_default' in clientTaxRatesColumnsCache) {
|
|
await context.db('client_tax_rates')
|
|
.where({ tenant: context.tenantId, client_id: clientId })
|
|
.update({ is_default: false });
|
|
}
|
|
|
|
const rateData: Record<string, unknown> = {
|
|
tenant: context.tenantId,
|
|
client_id: clientId,
|
|
tax_rate_id: taxRateId
|
|
};
|
|
|
|
if ('is_default' in clientTaxRatesColumnsCache) {
|
|
rateData.is_default = true;
|
|
}
|
|
|
|
if ('location_id' in clientTaxRatesColumnsCache) {
|
|
rateData.location_id = null;
|
|
}
|
|
|
|
const existingRate = await context.db('client_tax_rates')
|
|
.where({
|
|
tenant: context.tenantId,
|
|
client_id: clientId,
|
|
tax_rate_id: taxRateId
|
|
})
|
|
.first();
|
|
|
|
if (existingRate) {
|
|
await context.db('client_tax_rates')
|
|
.where({
|
|
tenant: context.tenantId,
|
|
client_tax_rates_id: existingRate.client_tax_rates_id
|
|
})
|
|
.update({
|
|
...rateData,
|
|
updated_at: context.db.fn.now()
|
|
});
|
|
} else {
|
|
await context.db('client_tax_rates').insert({
|
|
...rateData,
|
|
created_at: context.db.fn.now(),
|
|
updated_at: context.db.fn.now()
|
|
});
|
|
}
|
|
}
|
|
|
|
interface CreateServiceOptions {
|
|
service_id?: string;
|
|
service_name?: string;
|
|
billing_method?: 'fixed' | 'hourly' | 'usage' | 'time';
|
|
default_rate?: number;
|
|
unit_of_measure?: string;
|
|
description?: string | null;
|
|
category_id?: string | null;
|
|
custom_service_type_id?: string;
|
|
tax_region?: string;
|
|
tax_rate_id?: string | null;
|
|
}
|
|
|
|
interface CreateFixedPlanOptions {
|
|
planId?: string;
|
|
clientBillingPlanId?: string;
|
|
contractId?: string;
|
|
clientContractId?: string;
|
|
planName?: string;
|
|
billingFrequency?: 'monthly' | 'annual';
|
|
baseRateCents?: number;
|
|
detailBaseRateCents?: number;
|
|
quantity?: number;
|
|
startDate?: string;
|
|
endDate?: string | null;
|
|
billingTiming?: 'arrears' | 'advance';
|
|
cadenceOwner?: CadenceOwner;
|
|
enableProration?: boolean;
|
|
billingCycleAlignment?: 'start' | 'end' | 'prorated';
|
|
clientId?: string;
|
|
customRateCents?: number | null;
|
|
contractHeaderIsActive?: boolean;
|
|
contractHeaderStatus?: string;
|
|
assignmentIsActive?: boolean;
|
|
assignmentStatus?: string;
|
|
assignmentPoRequired?: boolean;
|
|
assignmentPoNumber?: string | null;
|
|
assignmentPoAmount?: number | null;
|
|
clientContractLineIsActive?: boolean;
|
|
}
|
|
|
|
interface AddServiceToPlanOptions {
|
|
quantity?: number;
|
|
detailBaseRateCents?: number;
|
|
}
|
|
|
|
interface CreateBucketOverlayOptions {
|
|
configId?: string;
|
|
serviceId?: string;
|
|
totalMinutes?: number;
|
|
totalHours?: number;
|
|
overageRateCents?: number;
|
|
allowRollover?: boolean;
|
|
billingPeriod?: string;
|
|
}
|
|
|
|
interface CreateBucketUsageOptions {
|
|
usageId?: string;
|
|
planId?: string;
|
|
contractLineId?: string;
|
|
serviceId: string;
|
|
clientId: string;
|
|
periodStart: string;
|
|
periodEnd: string;
|
|
minutesUsed: number;
|
|
overageMinutes?: number;
|
|
rolledOverMinutes?: number;
|
|
}
|
|
|
|
interface DirectConcurrentAssignmentSeedOptions {
|
|
contractId?: string;
|
|
clientContractId?: string;
|
|
clientId?: string;
|
|
contractName?: string;
|
|
contractHeaderIsActive?: boolean;
|
|
contractHeaderStatus?: string;
|
|
assignmentIsActive?: boolean;
|
|
assignmentStatus?: string;
|
|
startDate?: string;
|
|
endDate?: string | null;
|
|
}
|
|
|
|
async function ensureServiceType(
|
|
context: TestContext,
|
|
billingMethod: 'fixed' | 'hourly' | 'usage' = 'fixed'
|
|
): Promise<string> {
|
|
const cacheKey = `${context.tenantId}:${billingMethod}`;
|
|
if (serviceTypeCache.has(cacheKey)) {
|
|
return serviceTypeCache.get(cacheKey)!;
|
|
}
|
|
|
|
const columns = await context.db('service_types').columnInfo();
|
|
const tenantColumn = columns.tenant ? 'tenant' : columns.tenant_id ? 'tenant_id' : null;
|
|
// Newer schemas dropped billing_method from service_types; key off the
|
|
// generated name instead so the helper works on both shapes.
|
|
const hasBillingMethodColumn = 'billing_method' in columns;
|
|
|
|
if (!tenantColumn) {
|
|
throw new Error('Unable to determine tenant column for service_types table');
|
|
}
|
|
|
|
const typeName =
|
|
billingMethod === 'fixed'
|
|
? 'Fixed Service Type'
|
|
: billingMethod === 'hourly'
|
|
? 'Hourly Service Type'
|
|
: 'Usage Service Type';
|
|
|
|
const existingType = await context.db('service_types')
|
|
.where(
|
|
hasBillingMethodColumn
|
|
? { [tenantColumn]: context.tenantId, billing_method: billingMethod }
|
|
: { [tenantColumn]: context.tenantId, name: typeName }
|
|
)
|
|
.first('id');
|
|
|
|
if (existingType?.id) {
|
|
serviceTypeCache.set(cacheKey, existingType.id);
|
|
return existingType.id;
|
|
}
|
|
|
|
const typeId = uuidv4();
|
|
const typeData: Record<string, unknown> = {
|
|
id: typeId,
|
|
name: typeName,
|
|
is_active: true,
|
|
description: 'Auto-generated service type for invoice tests',
|
|
[tenantColumn]: context.tenantId
|
|
};
|
|
|
|
if (hasBillingMethodColumn) {
|
|
typeData.billing_method = billingMethod;
|
|
}
|
|
|
|
// Leave order_number null to avoid collisions with unique constraints in legacy schemas.
|
|
|
|
await context.db('service_types').insert(typeData);
|
|
if (process.env.DEBUG_SERVICE_TYPES === 'true' && debugFlags.createServiceLogCount < 5) {
|
|
const row = await context.db('service_types').where({ id: typeId }).first();
|
|
console.log('Inserted service_type row', row);
|
|
}
|
|
serviceTypeCache.set(cacheKey, typeId);
|
|
return typeId;
|
|
}
|
|
|
|
async function getStandardServiceTypeId(
|
|
context: TestContext,
|
|
billingMethod: 'fixed' | 'hourly' | 'usage'
|
|
): Promise<string | null> {
|
|
const hasTable = await context.db.schema.hasTable('standard_service_types');
|
|
if (!hasTable) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const columns = await context.db('standard_service_types').columnInfo();
|
|
const tenantColumn = columns.tenant ? 'tenant' : columns.tenant_id ? 'tenant_id' : null;
|
|
const hasBillingMethodColumn = 'billing_method' in columns;
|
|
|
|
let query = hasBillingMethodColumn
|
|
? context.db('standard_service_types').where({ billing_method: billingMethod })
|
|
: context.db('standard_service_types');
|
|
if (tenantColumn) {
|
|
query = query.andWhere(tenantColumn, context.tenantId);
|
|
}
|
|
|
|
const record = await query.first('id');
|
|
if (record?.id) {
|
|
return record.id as string;
|
|
}
|
|
|
|
const fallback = await context.db('standard_service_types').first('id');
|
|
return (fallback?.id as string) ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function createTestService(
|
|
context: TestContext,
|
|
overrides: CreateServiceOptions = {}
|
|
): Promise<string> {
|
|
const serviceId = overrides.service_id ?? uuidv4();
|
|
const billingMethod = overrides.billing_method ?? 'fixed';
|
|
const normalizedBillingMethod = billingMethod === 'time' ? 'hourly' : billingMethod;
|
|
const cacheKey = `${context.tenantId}:${normalizedBillingMethod}`;
|
|
let serviceTypeId: string | null = overrides.custom_service_type_id ?? null;
|
|
|
|
if (!serviceTypeId) {
|
|
try {
|
|
serviceTypeId = await ensureServiceType(context, normalizedBillingMethod);
|
|
} catch (error) {
|
|
// If service types aren't available in this schema iteration, fall back to null.
|
|
serviceTypeId = null;
|
|
}
|
|
}
|
|
|
|
const serviceCatalogColumns = await context.db('service_catalog').columnInfo();
|
|
|
|
const hasCustomServiceTypeColumn = 'custom_service_type_id' in serviceCatalogColumns;
|
|
const hasStandardServiceTypeColumn = 'standard_service_type_id' in serviceCatalogColumns;
|
|
|
|
let resolvedCustomServiceTypeId: string | null = serviceTypeId;
|
|
if (hasCustomServiceTypeColumn && resolvedCustomServiceTypeId) {
|
|
const typeExists = await context.db('service_types')
|
|
.where({ id: resolvedCustomServiceTypeId })
|
|
.first('id')
|
|
.catch(() => null);
|
|
|
|
if (!typeExists) {
|
|
serviceTypeCache.delete(cacheKey);
|
|
resolvedCustomServiceTypeId = await ensureServiceType(context, normalizedBillingMethod);
|
|
}
|
|
}
|
|
|
|
let resolvedStandardServiceTypeId: string | null = null;
|
|
if (hasStandardServiceTypeColumn) {
|
|
resolvedStandardServiceTypeId = await getStandardServiceTypeId(context, normalizedBillingMethod);
|
|
}
|
|
|
|
if (process.env.DEBUG_SERVICE_TYPES === 'true' && debugFlags.createServiceLogCount < 5) {
|
|
const hasServiceTypesTable = await context.db.schema.hasTable('service_types');
|
|
const serviceTypesColumns = hasServiceTypesTable ? await context.db('service_types').columnInfo() : null;
|
|
const hasStandardTable = await context.db.schema.hasTable('standard_service_types');
|
|
const standardColumns = hasStandardTable ? await context.db('standard_service_types').columnInfo() : null;
|
|
console.log('service_catalog columns', serviceCatalogColumns);
|
|
console.log('service_types columns', serviceTypesColumns);
|
|
console.log('standard_service_types columns', standardColumns);
|
|
console.log('resolved custom serviceTypeId', resolvedCustomServiceTypeId);
|
|
console.log('resolved standard serviceTypeId', resolvedStandardServiceTypeId);
|
|
debugFlags.createServiceLogCount += 1;
|
|
}
|
|
|
|
const serviceData: Record<string, unknown> = {
|
|
service_id: serviceId,
|
|
tenant: context.tenantId,
|
|
service_name: overrides.service_name ?? 'Test Service',
|
|
billing_method: normalizedBillingMethod,
|
|
default_rate: overrides.default_rate ?? 1000,
|
|
unit_of_measure: overrides.unit_of_measure ?? 'each',
|
|
description: overrides.description ?? 'Test Service Description',
|
|
category_id: overrides.category_id ?? null,
|
|
tax_rate_id: overrides.tax_rate_id ?? null
|
|
};
|
|
|
|
if (hasCustomServiceTypeColumn) {
|
|
serviceData.custom_service_type_id = resolvedCustomServiceTypeId;
|
|
}
|
|
|
|
if (hasStandardServiceTypeColumn) {
|
|
serviceData.standard_service_type_id = resolvedStandardServiceTypeId;
|
|
}
|
|
|
|
await context.db('service_catalog').insert(serviceData);
|
|
|
|
if (overrides.tax_region) {
|
|
await assignServiceTaxRate(context, serviceId, overrides.tax_region);
|
|
}
|
|
|
|
return serviceId;
|
|
}
|
|
|
|
export async function createFixedPlanAssignment(
|
|
context: TestContext,
|
|
serviceId: string,
|
|
options: CreateFixedPlanOptions = {}
|
|
): Promise<{ planId: string; clientBillingPlanId: string; contractLineId: string; clientContractLineId: string; contractId: string; clientContractId: string }> {
|
|
const contractLineId = options.planId ?? uuidv4();
|
|
const clientContractLineId = options.clientBillingPlanId ?? uuidv4();
|
|
const legacyPlanId = contractLineId;
|
|
const legacyClientPlanId = clientContractLineId;
|
|
const contractId = options.contractId ?? uuidv4();
|
|
const clientContractId = options.clientContractId ?? uuidv4();
|
|
const configId = uuidv4();
|
|
const baseRateCents = options.baseRateCents ?? 1000;
|
|
const baseRateDollars = baseRateCents / 100;
|
|
const detailBaseRateCents = options.detailBaseRateCents ?? baseRateCents;
|
|
const detailBaseRateDollars = detailBaseRateCents / 100;
|
|
const enableProration = options.enableProration ?? false;
|
|
const billingCycleAlignment: 'start' | 'end' | 'prorated' = options.billingCycleAlignment ?? 'start';
|
|
const quantity = options.quantity ?? 1;
|
|
const planName = options.planName ?? 'Test Plan';
|
|
const billingFrequency = options.billingFrequency ?? 'monthly';
|
|
const targetClientId = options.clientId ?? context.clientId;
|
|
const billingTiming: 'arrears' | 'advance' = options.billingTiming ?? 'arrears';
|
|
const cadenceOwner: CadenceOwner = options.cadenceOwner ?? 'client';
|
|
const contractHeaderIsActive = options.contractHeaderIsActive ?? true;
|
|
const contractHeaderStatus = options.contractHeaderStatus ?? 'Active';
|
|
const assignmentIsActive = options.assignmentIsActive ?? true;
|
|
const assignmentStatus = options.assignmentStatus ?? 'pending';
|
|
const assignmentPoRequired = options.assignmentPoRequired ?? false;
|
|
const assignmentPoNumber = options.assignmentPoNumber ?? null;
|
|
const assignmentPoAmount = options.assignmentPoAmount ?? null;
|
|
const clientContractLineIsActive = options.clientContractLineIsActive ?? true;
|
|
|
|
if (await context.db.schema.hasTable('contracts')) {
|
|
const contractColumns = await context.db('contracts').columnInfo();
|
|
const contractData: Record<string, unknown> = {
|
|
tenant: context.tenantId,
|
|
contract_id: contractId,
|
|
contract_name: planName,
|
|
contract_description: `${planName} fixture`,
|
|
billing_frequency: billingFrequency,
|
|
is_active: contractHeaderIsActive,
|
|
status: contractHeaderStatus,
|
|
is_template: false,
|
|
currency_code: 'USD',
|
|
created_at: context.db.fn.now(),
|
|
updated_at: context.db.fn.now()
|
|
};
|
|
|
|
if ('owner_client_id' in contractColumns) {
|
|
contractData.owner_client_id = targetClientId;
|
|
}
|
|
|
|
await context.db('contracts')
|
|
.insert(contractData)
|
|
.onConflict(['tenant', 'contract_id'])
|
|
.merge({
|
|
contract_name: contractData.contract_name,
|
|
contract_description: contractData.contract_description,
|
|
billing_frequency: contractData.billing_frequency,
|
|
is_active: contractHeaderIsActive,
|
|
status: contractHeaderStatus,
|
|
is_template: contractData.is_template,
|
|
currency_code: contractData.currency_code,
|
|
updated_at: context.db.fn.now(),
|
|
...(contractData.owner_client_id ? { owner_client_id: contractData.owner_client_id } : {})
|
|
});
|
|
}
|
|
|
|
if (await context.db.schema.hasTable('client_contracts')) {
|
|
await context.db('client_contracts')
|
|
.insert({
|
|
tenant: context.tenantId,
|
|
client_contract_id: clientContractId,
|
|
client_id: targetClientId,
|
|
contract_id: contractId,
|
|
start_date: options.startDate ?? '2025-02-01',
|
|
end_date: options.endDate ?? null,
|
|
is_active: assignmentIsActive,
|
|
status: assignmentStatus,
|
|
po_number: assignmentPoNumber,
|
|
po_amount: assignmentPoAmount,
|
|
po_required: assignmentPoRequired,
|
|
template_contract_id: null,
|
|
created_at: context.db.fn.now(),
|
|
updated_at: context.db.fn.now()
|
|
})
|
|
.onConflict(['tenant', 'client_contract_id'])
|
|
.merge({
|
|
client_id: targetClientId,
|
|
contract_id: contractId,
|
|
start_date: options.startDate ?? '2025-02-01',
|
|
end_date: options.endDate ?? null,
|
|
is_active: assignmentIsActive,
|
|
status: assignmentStatus,
|
|
po_number: assignmentPoNumber,
|
|
po_amount: assignmentPoAmount,
|
|
po_required: assignmentPoRequired,
|
|
template_contract_id: null,
|
|
updated_at: context.db.fn.now()
|
|
});
|
|
}
|
|
|
|
const contractLineColumns = await context.db('contract_lines').columnInfo();
|
|
const contractLineData: Record<string, unknown> = {
|
|
contract_line_id: contractLineId,
|
|
tenant: context.tenantId,
|
|
contract_line_name: planName,
|
|
billing_frequency: billingFrequency,
|
|
is_custom: false,
|
|
contract_line_type: 'Fixed',
|
|
custom_rate: baseRateDollars,
|
|
enable_proration: enableProration,
|
|
billing_cycle_alignment: billingCycleAlignment,
|
|
billing_timing: billingTiming,
|
|
};
|
|
|
|
if ('contract_id' in contractLineColumns) {
|
|
contractLineData.contract_id = contractId;
|
|
}
|
|
|
|
if ('cadence_owner' in contractLineColumns) {
|
|
contractLineData.cadence_owner = cadenceOwner;
|
|
}
|
|
|
|
// Primary contract line tables
|
|
await context.db('contract_lines')
|
|
.insert(contractLineData)
|
|
.onConflict(['tenant', 'contract_line_id'])
|
|
.merge({
|
|
contract_line_name: planName,
|
|
billing_frequency: billingFrequency,
|
|
contract_line_type: 'Fixed',
|
|
custom_rate: baseRateDollars,
|
|
enable_proration: enableProration,
|
|
billing_cycle_alignment: billingCycleAlignment,
|
|
billing_timing: billingTiming,
|
|
...(contractLineData.contract_id ? { contract_id: contractId } : {}),
|
|
...(contractLineData.cadence_owner ? { cadence_owner: cadenceOwner } : {}),
|
|
});
|
|
|
|
await context.db('contract_line_service_configuration')
|
|
.insert({
|
|
config_id: configId,
|
|
contract_line_id: contractLineId,
|
|
service_id: serviceId,
|
|
configuration_type: 'Fixed',
|
|
custom_rate: null,
|
|
quantity,
|
|
tenant: context.tenantId
|
|
})
|
|
.onConflict(['tenant', 'config_id'])
|
|
.merge({
|
|
contract_line_id: contractLineId,
|
|
service_id: serviceId,
|
|
configuration_type: 'Fixed',
|
|
custom_rate: null,
|
|
quantity
|
|
});
|
|
|
|
await context.db('contract_line_service_fixed_config')
|
|
.insert({
|
|
config_id: configId,
|
|
tenant: context.tenantId,
|
|
base_rate: baseRateDollars
|
|
})
|
|
.onConflict(['tenant', 'config_id'])
|
|
.merge({ base_rate: baseRateDollars });
|
|
|
|
await context.db('contract_line_services')
|
|
.insert({
|
|
tenant: context.tenantId,
|
|
contract_line_id: contractLineId,
|
|
service_id: serviceId,
|
|
quantity,
|
|
custom_rate: null
|
|
})
|
|
.onConflict(['tenant', 'service_id', 'contract_line_id'])
|
|
.merge({ quantity, custom_rate: null });
|
|
|
|
if (await context.db.schema.hasTable('client_contract_lines')) {
|
|
await context.db('client_contract_lines')
|
|
.insert({
|
|
tenant: context.tenantId,
|
|
client_contract_line_id: clientContractLineId,
|
|
client_id: targetClientId,
|
|
contract_line_id: contractLineId,
|
|
start_date: options.startDate ?? '2025-02-01',
|
|
end_date: options.endDate ?? null,
|
|
is_active: clientContractLineIsActive
|
|
})
|
|
.onConflict(['tenant', 'client_contract_line_id'])
|
|
.merge({
|
|
client_id: targetClientId,
|
|
contract_line_id: contractLineId,
|
|
start_date: options.startDate ?? '2025-02-01',
|
|
end_date: options.endDate ?? null,
|
|
is_active: clientContractLineIsActive
|
|
});
|
|
}
|
|
|
|
const legacyPlanTablesExist = await context.db.schema.hasTable('billing_plans');
|
|
|
|
if (legacyPlanTablesExist) {
|
|
await context.db('billing_plans')
|
|
.insert({
|
|
plan_id: legacyPlanId,
|
|
tenant: context.tenantId,
|
|
plan_name: planName,
|
|
billing_frequency: billingFrequency,
|
|
is_custom: false,
|
|
plan_type: 'Fixed'
|
|
})
|
|
.onConflict(['tenant', 'plan_id'])
|
|
.merge({
|
|
plan_name: planName,
|
|
billing_frequency: billingFrequency,
|
|
plan_type: 'Fixed'
|
|
});
|
|
|
|
await context.db('billing_plan_fixed_config')
|
|
.insert({
|
|
plan_id: legacyPlanId,
|
|
tenant: context.tenantId,
|
|
base_rate: baseRateDollars,
|
|
enable_proration: enableProration,
|
|
billing_cycle_alignment: billingCycleAlignment
|
|
})
|
|
.onConflict(['tenant', 'plan_id'])
|
|
.merge({
|
|
base_rate: baseRateDollars,
|
|
enable_proration: enableProration,
|
|
billing_cycle_alignment: billingCycleAlignment
|
|
});
|
|
|
|
await context.db('plan_service_configuration')
|
|
.insert({
|
|
config_id: configId,
|
|
plan_id: legacyPlanId,
|
|
service_id: serviceId,
|
|
configuration_type: 'Fixed',
|
|
custom_rate: null,
|
|
quantity,
|
|
tenant: context.tenantId
|
|
})
|
|
.onConflict(['tenant', 'config_id'])
|
|
.merge({
|
|
plan_id: legacyPlanId,
|
|
service_id: serviceId,
|
|
configuration_type: 'Fixed',
|
|
custom_rate: null,
|
|
quantity
|
|
});
|
|
|
|
await context.db('plan_service_fixed_config')
|
|
.insert({
|
|
config_id: configId,
|
|
tenant: context.tenantId,
|
|
base_rate: detailBaseRateDollars
|
|
})
|
|
.onConflict(['tenant', 'config_id'])
|
|
.merge({ base_rate: detailBaseRateDollars });
|
|
|
|
await context.db('plan_services')
|
|
.insert({
|
|
tenant: context.tenantId,
|
|
plan_id: legacyPlanId,
|
|
service_id: serviceId,
|
|
quantity,
|
|
custom_rate: null
|
|
})
|
|
.onConflict(['tenant', 'service_id', 'plan_id'])
|
|
.merge({ quantity, custom_rate: null });
|
|
|
|
await context.db('client_billing_plans')
|
|
.insert({
|
|
tenant: context.tenantId,
|
|
client_billing_plan_id: legacyClientPlanId,
|
|
client_id: targetClientId,
|
|
plan_id: legacyPlanId,
|
|
service_category: null,
|
|
is_active: assignmentIsActive,
|
|
start_date: options.startDate ?? '2025-02-01',
|
|
end_date: options.endDate ?? null,
|
|
client_bundle_id: null
|
|
})
|
|
.onConflict(['tenant', 'client_billing_plan_id'])
|
|
.merge({
|
|
client_id: targetClientId,
|
|
plan_id: legacyPlanId,
|
|
is_active: assignmentIsActive,
|
|
start_date: options.startDate ?? '2025-02-01',
|
|
end_date: options.endDate ?? null
|
|
});
|
|
}
|
|
|
|
const now = context.db.fn.now();
|
|
const effectiveDate = `${options.startDate ?? '2025-02-01'}T00:00:00Z`;
|
|
const customRateDollars = options.customRateCents !== undefined
|
|
? options.customRateCents / 100
|
|
: null;
|
|
|
|
const hasLegacyClientServiceTables =
|
|
(await context.db.schema.hasTable('client_contract_services')) &&
|
|
(await context.db.schema.hasTable('client_contract_service_configuration')) &&
|
|
(await context.db.schema.hasTable('client_contract_service_fixed_config'));
|
|
|
|
if (hasLegacyClientServiceTables) {
|
|
let existingClientService = await context.db('client_contract_services')
|
|
.where({
|
|
tenant: context.tenantId,
|
|
client_contract_line_id: clientContractLineId,
|
|
service_id: serviceId
|
|
})
|
|
.first<{ client_contract_service_id: string }>('client_contract_service_id');
|
|
|
|
const clientContractServiceId = existingClientService?.client_contract_service_id ?? uuidv4();
|
|
|
|
if (existingClientService) {
|
|
await context.db('client_contract_services')
|
|
.where({
|
|
tenant: context.tenantId,
|
|
client_contract_service_id: clientContractServiceId
|
|
})
|
|
.update({
|
|
quantity,
|
|
custom_rate: customRateDollars,
|
|
updated_at: now
|
|
});
|
|
} else {
|
|
await context.db('client_contract_services').insert({
|
|
tenant: context.tenantId,
|
|
client_contract_service_id: clientContractServiceId,
|
|
client_contract_line_id: clientContractLineId,
|
|
service_id: serviceId,
|
|
quantity,
|
|
custom_rate: customRateDollars,
|
|
effective_date: effectiveDate,
|
|
created_at: now,
|
|
updated_at: now
|
|
});
|
|
}
|
|
|
|
let existingClientConfig = await context.db('client_contract_service_configuration')
|
|
.where({
|
|
tenant: context.tenantId,
|
|
client_contract_service_id: clientContractServiceId
|
|
})
|
|
.first<{ config_id: string }>('config_id');
|
|
|
|
const clientConfigId = existingClientConfig?.config_id ?? uuidv4();
|
|
|
|
if (existingClientConfig) {
|
|
await context.db('client_contract_service_configuration')
|
|
.where({
|
|
tenant: context.tenantId,
|
|
config_id: clientConfigId
|
|
})
|
|
.update({
|
|
configuration_type: 'Fixed',
|
|
custom_rate: customRateDollars,
|
|
quantity,
|
|
updated_at: now
|
|
});
|
|
} else {
|
|
await context.db('client_contract_service_configuration').insert({
|
|
tenant: context.tenantId,
|
|
config_id: clientConfigId,
|
|
client_contract_service_id: clientContractServiceId,
|
|
configuration_type: 'Fixed',
|
|
custom_rate: customRateDollars,
|
|
quantity,
|
|
created_at: now,
|
|
updated_at: now
|
|
});
|
|
}
|
|
|
|
await context.db('client_contract_service_fixed_config')
|
|
.insert({
|
|
tenant: context.tenantId,
|
|
config_id: clientConfigId,
|
|
base_rate: baseRateDollars,
|
|
enable_proration: enableProration,
|
|
billing_cycle_alignment: billingCycleAlignment,
|
|
created_at: now,
|
|
updated_at: now
|
|
})
|
|
.onConflict(['tenant', 'config_id'])
|
|
.merge({
|
|
base_rate: baseRateDollars,
|
|
enable_proration: enableProration,
|
|
billing_cycle_alignment: billingCycleAlignment,
|
|
updated_at: now
|
|
});
|
|
}
|
|
|
|
return {
|
|
planId: legacyPlanId,
|
|
clientBillingPlanId: legacyClientPlanId,
|
|
contractLineId,
|
|
clientContractLineId,
|
|
contractId,
|
|
clientContractId
|
|
};
|
|
}
|
|
|
|
export async function createConcurrentFixedPlanAssignments(
|
|
context: TestContext,
|
|
serviceId: string,
|
|
assignments: CreateFixedPlanOptions[]
|
|
): Promise<Array<{
|
|
planId: string;
|
|
clientBillingPlanId: string;
|
|
contractLineId: string;
|
|
clientContractLineId: string;
|
|
contractId: string;
|
|
clientContractId: string;
|
|
}>> {
|
|
if (assignments.length < 2) {
|
|
throw new Error('createConcurrentFixedPlanAssignments requires at least two assignments');
|
|
}
|
|
|
|
const seededAssignments: Array<{
|
|
planId: string;
|
|
clientBillingPlanId: string;
|
|
contractLineId: string;
|
|
clientContractLineId: string;
|
|
contractId: string;
|
|
clientContractId: string;
|
|
}> = [];
|
|
|
|
for (const assignmentOptions of assignments) {
|
|
seededAssignments.push(await createFixedPlanAssignment(context, serviceId, {
|
|
startDate: '2025-02-01',
|
|
endDate: null,
|
|
assignmentIsActive: true,
|
|
...assignmentOptions,
|
|
}));
|
|
}
|
|
|
|
return seededAssignments;
|
|
}
|
|
|
|
export async function seedConcurrentClientContractAssignmentsDirect(
|
|
context: TestContext,
|
|
assignments: DirectConcurrentAssignmentSeedOptions[]
|
|
): Promise<Array<{ contractId: string; clientContractId: string }>> {
|
|
if (assignments.length < 2) {
|
|
throw new Error('seedConcurrentClientContractAssignmentsDirect requires at least two assignments');
|
|
}
|
|
|
|
const hasContractsTable = await context.db.schema.hasTable('contracts');
|
|
const hasClientContractsTable = await context.db.schema.hasTable('client_contracts');
|
|
if (!hasContractsTable || !hasClientContractsTable) {
|
|
throw new Error('contracts and client_contracts tables are required for direct concurrent assignment seeding');
|
|
}
|
|
|
|
const seeded: Array<{ contractId: string; clientContractId: string }> = [];
|
|
|
|
for (const assignment of assignments) {
|
|
const contractId = assignment.contractId ?? uuidv4();
|
|
const clientContractId = assignment.clientContractId ?? uuidv4();
|
|
const targetClientId = assignment.clientId ?? context.clientId;
|
|
const contractName = assignment.contractName ?? `Direct Assignment ${seeded.length + 1}`;
|
|
const contractHeaderIsActive = assignment.contractHeaderIsActive ?? true;
|
|
const contractHeaderStatus = assignment.contractHeaderStatus ?? 'Active';
|
|
const assignmentIsActive = assignment.assignmentIsActive ?? true;
|
|
const assignmentStatus = assignment.assignmentStatus ?? 'active';
|
|
const startDate = assignment.startDate ?? '2025-02-01';
|
|
const endDate = assignment.endDate ?? null;
|
|
|
|
await context.createEntity('contracts', {
|
|
contract_id: contractId,
|
|
contract_name: contractName,
|
|
contract_description: `${contractName} direct-seeded fixture`,
|
|
billing_frequency: 'monthly',
|
|
is_active: contractHeaderIsActive,
|
|
status: contractHeaderStatus,
|
|
is_template: false,
|
|
currency_code: 'USD',
|
|
owner_client_id: targetClientId,
|
|
created_at: context.db.fn.now(),
|
|
updated_at: context.db.fn.now(),
|
|
}, 'contract_id');
|
|
|
|
await context.createEntity('client_contracts', {
|
|
client_contract_id: clientContractId,
|
|
client_id: targetClientId,
|
|
contract_id: contractId,
|
|
start_date: startDate,
|
|
end_date: endDate,
|
|
is_active: assignmentIsActive,
|
|
status: assignmentStatus,
|
|
po_required: false,
|
|
po_number: null,
|
|
po_amount: null,
|
|
template_contract_id: null,
|
|
created_at: context.db.fn.now(),
|
|
updated_at: context.db.fn.now(),
|
|
}, 'client_contract_id');
|
|
|
|
seeded.push({ contractId, clientContractId });
|
|
}
|
|
|
|
return seeded;
|
|
}
|
|
|
|
export async function ensureClientPlanBundlesTable(context: TestContext): Promise<void> {
|
|
await context.db.raw(`
|
|
CREATE TABLE IF NOT EXISTS client_plan_bundles (
|
|
bundle_id UUID PRIMARY KEY,
|
|
client_id UUID NOT NULL,
|
|
tenant UUID NOT NULL,
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
start_date TIMESTAMPTZ NOT NULL,
|
|
end_date TIMESTAMPTZ,
|
|
po_required BOOLEAN NOT NULL DEFAULT FALSE,
|
|
po_number TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
)
|
|
`);
|
|
}
|
|
|
|
export async function ensureDefaultBillingSettings(
|
|
context: TestContext,
|
|
options: BillingSettingsOptions = {}
|
|
): Promise<void> {
|
|
const {
|
|
zeroDollarInvoiceHandling = 'normal',
|
|
suppressZeroDollarInvoices = false,
|
|
enableCreditExpiration = false,
|
|
creditExpirationDays = 365,
|
|
creditExpirationNotificationDays = [30, 7, 1]
|
|
} = options;
|
|
|
|
const notificationArraySql = `ARRAY[${creditExpirationNotificationDays.map(() => '?').join(',')}]::INTEGER[]`;
|
|
|
|
const hasDefaultSettingsTable = await context.db.schema.hasTable('default_billing_settings');
|
|
if (hasDefaultSettingsTable) {
|
|
await context.db('default_billing_settings')
|
|
.insert({
|
|
tenant: context.tenantId,
|
|
zero_dollar_invoice_handling: zeroDollarInvoiceHandling,
|
|
suppress_zero_dollar_invoices: suppressZeroDollarInvoices,
|
|
enable_credit_expiration: enableCreditExpiration,
|
|
credit_expiration_days: creditExpirationDays,
|
|
credit_expiration_notification_days: context.db.raw(notificationArraySql, creditExpirationNotificationDays),
|
|
created_at: context.db.fn.now(),
|
|
updated_at: context.db.fn.now()
|
|
})
|
|
.onConflict('tenant')
|
|
.merge({
|
|
zero_dollar_invoice_handling: zeroDollarInvoiceHandling,
|
|
suppress_zero_dollar_invoices: suppressZeroDollarInvoices,
|
|
enable_credit_expiration: enableCreditExpiration,
|
|
credit_expiration_days: creditExpirationDays,
|
|
credit_expiration_notification_days: context.db.raw(notificationArraySql, creditExpirationNotificationDays),
|
|
updated_at: context.db.fn.now()
|
|
});
|
|
}
|
|
|
|
const hasCompanySettingsTable = await context.db.schema.hasTable('company_billing_settings');
|
|
if (hasCompanySettingsTable) {
|
|
await context.db('company_billing_settings')
|
|
.insert({
|
|
tenant: context.tenantId,
|
|
company_id: context.clientId,
|
|
zero_dollar_invoice_handling: zeroDollarInvoiceHandling,
|
|
suppress_zero_dollar_invoices: suppressZeroDollarInvoices,
|
|
enable_credit_expiration: enableCreditExpiration,
|
|
credit_expiration_days: creditExpirationDays,
|
|
credit_expiration_notification_days: context.db.raw(notificationArraySql, creditExpirationNotificationDays),
|
|
created_at: context.db.fn.now(),
|
|
updated_at: context.db.fn.now()
|
|
})
|
|
.onConflict(['tenant', 'company_id'])
|
|
.merge({
|
|
zero_dollar_invoice_handling: zeroDollarInvoiceHandling,
|
|
suppress_zero_dollar_invoices: suppressZeroDollarInvoices,
|
|
enable_credit_expiration: enableCreditExpiration,
|
|
credit_expiration_days: creditExpirationDays,
|
|
credit_expiration_notification_days: context.db.raw(notificationArraySql, creditExpirationNotificationDays),
|
|
updated_at: context.db.fn.now()
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function addServiceToFixedPlan(
|
|
context: TestContext,
|
|
planId: string,
|
|
serviceId: string,
|
|
options: AddServiceToPlanOptions = {}
|
|
): Promise<string> {
|
|
const configId = uuidv4();
|
|
const quantity = options.quantity ?? 1;
|
|
const detailBaseRateCents = options.detailBaseRateCents ?? 0;
|
|
const detailBaseRateDollars = detailBaseRateCents / 100;
|
|
|
|
// Insert into new contract line tables
|
|
await context.db('contract_line_service_configuration')
|
|
.insert({
|
|
config_id: configId,
|
|
contract_line_id: planId,
|
|
service_id: serviceId,
|
|
configuration_type: 'Fixed',
|
|
custom_rate: null,
|
|
quantity,
|
|
tenant: context.tenantId
|
|
});
|
|
|
|
await context.db('contract_line_service_fixed_config')
|
|
.insert({
|
|
config_id: configId,
|
|
tenant: context.tenantId,
|
|
base_rate: detailBaseRateDollars
|
|
});
|
|
|
|
await context.db('contract_line_services')
|
|
.insert({
|
|
tenant: context.tenantId,
|
|
contract_line_id: planId,
|
|
service_id: serviceId,
|
|
quantity,
|
|
custom_rate: null
|
|
})
|
|
.onConflict(['tenant', 'service_id', 'contract_line_id'])
|
|
.merge({ quantity, custom_rate: null });
|
|
|
|
const planServiceConfigExists = await context.db.schema.hasTable('plan_service_configuration');
|
|
const planServiceFixedExists = await context.db.schema.hasTable('plan_service_fixed_config');
|
|
const planServicesExists = await context.db.schema.hasTable('plan_services');
|
|
|
|
if (planServiceConfigExists && planServiceFixedExists && planServicesExists) {
|
|
// Insert into legacy plan tables for compatibility
|
|
await context.db('plan_service_configuration')
|
|
.insert({
|
|
config_id: configId,
|
|
plan_id: planId,
|
|
service_id: serviceId,
|
|
configuration_type: 'Fixed',
|
|
custom_rate: null,
|
|
quantity,
|
|
tenant: context.tenantId
|
|
});
|
|
|
|
await context.db('plan_service_fixed_config')
|
|
.insert({
|
|
config_id: configId,
|
|
tenant: context.tenantId,
|
|
base_rate: detailBaseRateDollars
|
|
});
|
|
|
|
await context.db('plan_services')
|
|
.insert({
|
|
tenant: context.tenantId,
|
|
plan_id: planId,
|
|
service_id: serviceId,
|
|
quantity,
|
|
custom_rate: null
|
|
})
|
|
.onConflict(['tenant', 'service_id', 'plan_id'])
|
|
.merge({ quantity, custom_rate: null });
|
|
}
|
|
|
|
return configId;
|
|
}
|
|
|
|
let planBucketConfigColumnsCache: Record<string, unknown> | null | undefined;
|
|
let contractLineBucketConfigColumnsCache: Record<string, unknown> | null | undefined;
|
|
let bucketUsageColumnsCache: Record<string, unknown> | null | undefined;
|
|
let clientContractBucketConfigColumnsCache: Record<string, unknown> | null | undefined;
|
|
|
|
async function ensurePlanBucketConfigColumns(context: TestContext): Promise<Record<string, unknown> | null> {
|
|
if (planBucketConfigColumnsCache === undefined) {
|
|
const tableExists = await context.db.schema.hasTable('plan_service_bucket_config');
|
|
|
|
if (!tableExists) {
|
|
planBucketConfigColumnsCache = null;
|
|
} else {
|
|
try {
|
|
planBucketConfigColumnsCache = await context.db('plan_service_bucket_config').columnInfo();
|
|
} catch (error) {
|
|
planBucketConfigColumnsCache = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
return planBucketConfigColumnsCache ?? null;
|
|
}
|
|
|
|
async function ensureContractLineBucketConfigColumns(context: TestContext): Promise<Record<string, unknown> | null> {
|
|
if (contractLineBucketConfigColumnsCache === undefined) {
|
|
try {
|
|
contractLineBucketConfigColumnsCache = await context.db('contract_line_service_bucket_config').columnInfo();
|
|
} catch (error) {
|
|
contractLineBucketConfigColumnsCache = null;
|
|
}
|
|
}
|
|
|
|
return contractLineBucketConfigColumnsCache ?? null;
|
|
}
|
|
|
|
async function ensureBucketUsageColumns(context: TestContext): Promise<Record<string, unknown> | null> {
|
|
if (bucketUsageColumnsCache === undefined) {
|
|
try {
|
|
bucketUsageColumnsCache = await context.db('bucket_usage').columnInfo();
|
|
} catch (error) {
|
|
bucketUsageColumnsCache = null;
|
|
}
|
|
}
|
|
|
|
return bucketUsageColumnsCache ?? null;
|
|
}
|
|
|
|
async function ensureClientContractBucketConfigColumns(context: TestContext): Promise<Record<string, unknown> | null> {
|
|
if (clientContractBucketConfigColumnsCache === undefined) {
|
|
try {
|
|
clientContractBucketConfigColumnsCache = await context.db('client_contract_service_bucket_config').columnInfo();
|
|
} catch (error) {
|
|
clientContractBucketConfigColumnsCache = null;
|
|
}
|
|
}
|
|
|
|
return clientContractBucketConfigColumnsCache ?? null;
|
|
}
|
|
|
|
export async function createBucketOverlayForPlan(
|
|
context: TestContext,
|
|
planId: string,
|
|
options: CreateBucketOverlayOptions = {}
|
|
): Promise<{ configId: string; serviceId: string }> {
|
|
const totalMinutes = options.totalMinutes ?? Math.round((options.totalHours ?? 40) * 60);
|
|
const overageRateCents = options.overageRateCents ?? 0;
|
|
const allowRollover = options.allowRollover ?? false;
|
|
const billingPeriod = options.billingPeriod ?? 'monthly';
|
|
|
|
// Identify the service this overlay should attach to, defaulting to the fixed configuration for the plan.
|
|
let serviceId = options.serviceId;
|
|
let quantity: number | null = null;
|
|
let customRate: number | null = null;
|
|
|
|
let contractBaseConfig;
|
|
|
|
if (serviceId) {
|
|
contractBaseConfig = await context.db('contract_line_service_configuration')
|
|
.where({
|
|
tenant: context.tenantId,
|
|
contract_line_id: planId,
|
|
service_id: serviceId
|
|
})
|
|
.whereNot('configuration_type', 'Bucket')
|
|
.first();
|
|
} else {
|
|
contractBaseConfig = await context.db('contract_line_service_configuration')
|
|
.where({
|
|
tenant: context.tenantId,
|
|
contract_line_id: planId
|
|
})
|
|
.whereNot('configuration_type', 'Bucket')
|
|
.orderBy('created_at', 'asc')
|
|
.first();
|
|
|
|
if (contractBaseConfig) {
|
|
serviceId = contractBaseConfig.service_id;
|
|
}
|
|
}
|
|
|
|
if (contractBaseConfig) {
|
|
quantity = contractBaseConfig.quantity ?? null;
|
|
customRate = contractBaseConfig.custom_rate ?? null;
|
|
}
|
|
|
|
let planBaseConfig;
|
|
if (!serviceId) {
|
|
planBaseConfig = await context.db('plan_service_configuration')
|
|
.where({
|
|
tenant: context.tenantId,
|
|
plan_id: planId
|
|
})
|
|
.whereNot('configuration_type', 'Bucket')
|
|
.orderBy('created_at', 'asc')
|
|
.first();
|
|
|
|
if (planBaseConfig) {
|
|
serviceId = planBaseConfig.service_id;
|
|
quantity = planBaseConfig.quantity ?? quantity;
|
|
customRate = planBaseConfig.custom_rate ?? customRate;
|
|
}
|
|
} else if (!contractBaseConfig) {
|
|
planBaseConfig = await context.db('plan_service_configuration')
|
|
.where({
|
|
tenant: context.tenantId,
|
|
plan_id: planId,
|
|
service_id: serviceId
|
|
})
|
|
.whereNot('configuration_type', 'Bucket')
|
|
.first();
|
|
|
|
if (planBaseConfig) {
|
|
quantity = planBaseConfig.quantity ?? quantity;
|
|
customRate = planBaseConfig.custom_rate ?? customRate;
|
|
}
|
|
}
|
|
|
|
if (!serviceId) {
|
|
throw new Error(`Unable to determine service for bucket overlay on plan ${planId}`);
|
|
}
|
|
|
|
// Reuse existing overlay config if one exists so tests can update settings idempotently.
|
|
const existingOverlayConfig = await context.db('contract_line_service_configuration')
|
|
.where({
|
|
tenant: context.tenantId,
|
|
contract_line_id: planId,
|
|
service_id: serviceId,
|
|
configuration_type: 'Bucket'
|
|
})
|
|
.first();
|
|
|
|
const configId = options.configId ?? existingOverlayConfig?.config_id ?? uuidv4();
|
|
|
|
await context.db('contract_line_services')
|
|
.insert({
|
|
tenant: context.tenantId,
|
|
contract_line_id: planId,
|
|
service_id: serviceId,
|
|
quantity,
|
|
custom_rate: customRate
|
|
})
|
|
.onConflict(['tenant', 'service_id', 'contract_line_id'])
|
|
.merge({ quantity, custom_rate: customRate });
|
|
|
|
await context.db('contract_line_service_configuration')
|
|
.insert({
|
|
config_id: configId,
|
|
contract_line_id: planId,
|
|
service_id: serviceId,
|
|
configuration_type: 'Bucket',
|
|
custom_rate: null,
|
|
quantity: null,
|
|
tenant: context.tenantId
|
|
})
|
|
.onConflict(['tenant', 'config_id'])
|
|
.merge({
|
|
contract_line_id: planId,
|
|
service_id: serviceId,
|
|
configuration_type: 'Bucket'
|
|
});
|
|
|
|
const contractBucketColumns = await ensureContractLineBucketConfigColumns(context);
|
|
if (!contractBucketColumns) {
|
|
throw new Error('contract_line_service_bucket_config table is unavailable');
|
|
}
|
|
|
|
const contractTotalMinutesColumn = contractBucketColumns.total_minutes
|
|
? 'total_minutes'
|
|
: contractBucketColumns.total_hours
|
|
? 'total_hours'
|
|
: null;
|
|
|
|
if (!contractTotalMinutesColumn) {
|
|
throw new Error('Unable to determine total minutes column for contract bucket config');
|
|
}
|
|
|
|
const contractBucketData: Record<string, unknown> = {
|
|
config_id: configId,
|
|
tenant: context.tenantId,
|
|
billing_period: billingPeriod,
|
|
overage_rate: overageRateCents,
|
|
allow_rollover: allowRollover
|
|
};
|
|
|
|
if (contractTotalMinutesColumn === 'total_minutes') {
|
|
contractBucketData.total_minutes = totalMinutes;
|
|
} else {
|
|
contractBucketData.total_hours = Math.round(totalMinutes / 60);
|
|
}
|
|
|
|
const contractBucketUpdate: Record<string, unknown> = {
|
|
billing_period: contractBucketData.billing_period,
|
|
overage_rate: contractBucketData.overage_rate,
|
|
allow_rollover: contractBucketData.allow_rollover,
|
|
};
|
|
|
|
if (contractTotalMinutesColumn === 'total_minutes') {
|
|
contractBucketUpdate.total_minutes = contractBucketData.total_minutes;
|
|
} else {
|
|
contractBucketUpdate.total_hours = contractBucketData.total_hours;
|
|
}
|
|
|
|
await context.db('contract_line_service_bucket_config')
|
|
.insert(contractBucketData)
|
|
.onConflict(['tenant', 'config_id'])
|
|
.merge(contractBucketUpdate);
|
|
|
|
const planServicesTableExists = await context.db.schema.hasTable('plan_services');
|
|
|
|
if (planServicesTableExists) {
|
|
const planServiceConfigExists = await context.db.schema.hasTable('plan_service_configuration');
|
|
const planServiceBucketExists = await context.db.schema.hasTable('plan_service_bucket_config');
|
|
|
|
if (planServiceConfigExists && planServiceBucketExists) {
|
|
await context.db('plan_services')
|
|
.insert({
|
|
tenant: context.tenantId,
|
|
plan_id: planId,
|
|
service_id: serviceId,
|
|
quantity,
|
|
custom_rate: customRate
|
|
})
|
|
.onConflict(['tenant', 'service_id', 'plan_id'])
|
|
.merge({ quantity, custom_rate: customRate });
|
|
|
|
// Maintain legacy plan_service_* tables so tests remain compatible during the transition.
|
|
await context.db('plan_service_configuration')
|
|
.insert({
|
|
config_id: configId,
|
|
plan_id: planId,
|
|
service_id: serviceId,
|
|
configuration_type: 'Bucket',
|
|
custom_rate: null,
|
|
quantity: null,
|
|
tenant: context.tenantId
|
|
})
|
|
.onConflict(['tenant', 'config_id'])
|
|
.merge({
|
|
plan_id: planId,
|
|
service_id: serviceId,
|
|
configuration_type: 'Bucket'
|
|
});
|
|
|
|
const planBucketColumns = await ensurePlanBucketConfigColumns(context);
|
|
|
|
if (planBucketColumns) {
|
|
const planTotalMinutesColumn = planBucketColumns.total_minutes
|
|
? 'total_minutes'
|
|
: planBucketColumns.total_hours
|
|
? 'total_hours'
|
|
: null;
|
|
|
|
if (planTotalMinutesColumn) {
|
|
const planBucketData: Record<string, unknown> = {
|
|
config_id: configId,
|
|
tenant: context.tenantId,
|
|
billing_period: billingPeriod,
|
|
overage_rate: overageRateCents,
|
|
allow_rollover: allowRollover
|
|
};
|
|
|
|
if (planTotalMinutesColumn === 'total_minutes') {
|
|
planBucketData.total_minutes = totalMinutes;
|
|
} else {
|
|
planBucketData.total_hours = Math.round(totalMinutes / 60);
|
|
}
|
|
|
|
const planBucketUpdate: Record<string, unknown> = {
|
|
billing_period: planBucketData.billing_period,
|
|
overage_rate: planBucketData.overage_rate,
|
|
allow_rollover: planBucketData.allow_rollover,
|
|
};
|
|
|
|
if (planTotalMinutesColumn === 'total_minutes') {
|
|
planBucketUpdate.total_minutes = planBucketData.total_minutes;
|
|
} else {
|
|
planBucketUpdate.total_hours = planBucketData.total_hours;
|
|
}
|
|
|
|
await context.db('plan_service_bucket_config')
|
|
.insert(planBucketData)
|
|
.onConflict(['tenant', 'config_id'])
|
|
.merge(planBucketUpdate);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const clientServices = await context.db('client_contract_services as ccs')
|
|
.join('client_contract_lines as ccl', function () {
|
|
this.on('ccs.client_contract_line_id', '=', 'ccl.client_contract_line_id')
|
|
.andOn('ccs.tenant', '=', 'ccl.tenant');
|
|
})
|
|
.where({
|
|
'ccl.contract_line_id': planId,
|
|
'ccs.service_id': serviceId,
|
|
'ccs.tenant': context.tenantId
|
|
})
|
|
.select('ccs.client_contract_service_id');
|
|
|
|
if (clientServices.length > 0) {
|
|
const clientBucketColumns = await ensureClientContractBucketConfigColumns(context);
|
|
const now = context.db.fn.now();
|
|
|
|
for (const clientService of clientServices) {
|
|
const existingClientBucketConfig = await context.db('client_contract_service_configuration')
|
|
.where({
|
|
tenant: context.tenantId,
|
|
client_contract_service_id: clientService.client_contract_service_id,
|
|
configuration_type: 'Bucket'
|
|
})
|
|
.first<{ config_id: string }>('config_id');
|
|
|
|
const clientConfigId = existingClientBucketConfig?.config_id ?? uuidv4();
|
|
|
|
if (existingClientBucketConfig) {
|
|
await context.db('client_contract_service_configuration')
|
|
.where({
|
|
tenant: context.tenantId,
|
|
config_id: clientConfigId
|
|
})
|
|
.update({
|
|
configuration_type: 'Bucket',
|
|
custom_rate: null,
|
|
quantity: null,
|
|
updated_at: now
|
|
});
|
|
} else {
|
|
await context.db('client_contract_service_configuration').insert({
|
|
tenant: context.tenantId,
|
|
config_id: clientConfigId,
|
|
client_contract_service_id: clientService.client_contract_service_id,
|
|
configuration_type: 'Bucket',
|
|
custom_rate: null,
|
|
quantity: null,
|
|
created_at: now,
|
|
updated_at: now
|
|
});
|
|
}
|
|
|
|
if (clientBucketColumns) {
|
|
const clientBucketData: Record<string, unknown> = {
|
|
tenant: context.tenantId,
|
|
config_id: clientConfigId,
|
|
billing_period: billingPeriod,
|
|
overage_rate: overageRateCents,
|
|
allow_rollover: allowRollover,
|
|
created_at: now,
|
|
updated_at: now
|
|
};
|
|
|
|
const clientBucketUpdate: Record<string, unknown> = {
|
|
billing_period: clientBucketData.billing_period,
|
|
overage_rate: clientBucketData.overage_rate,
|
|
allow_rollover: clientBucketData.allow_rollover,
|
|
updated_at: now
|
|
};
|
|
|
|
if (clientBucketColumns.total_minutes !== undefined) {
|
|
clientBucketData.total_minutes = totalMinutes;
|
|
clientBucketUpdate.total_minutes = totalMinutes;
|
|
} else if (clientBucketColumns.total_hours !== undefined) {
|
|
const totalHours = Math.round(totalMinutes / 60);
|
|
clientBucketData.total_hours = totalHours;
|
|
clientBucketUpdate.total_hours = totalHours;
|
|
}
|
|
|
|
await context.db('client_contract_service_bucket_config')
|
|
.insert(clientBucketData)
|
|
.onConflict(['tenant', 'config_id'])
|
|
.merge(clientBucketUpdate);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { configId, serviceId };
|
|
}
|
|
|
|
export async function createBucketUsageRecord(
|
|
context: TestContext,
|
|
options: CreateBucketUsageOptions
|
|
): Promise<string> {
|
|
const usageColumns = await ensureBucketUsageColumns(context);
|
|
|
|
if (!usageColumns) {
|
|
throw new Error('bucket_usage table is unavailable');
|
|
}
|
|
|
|
const usageId = options.usageId ?? uuidv4();
|
|
const contractLineId = options.contractLineId ?? options.planId;
|
|
|
|
if (!contractLineId) {
|
|
throw new Error('A contract line identifier is required to record bucket usage');
|
|
}
|
|
|
|
const record: Record<string, unknown> = {
|
|
usage_id: usageId,
|
|
tenant: context.tenantId,
|
|
client_id: options.clientId,
|
|
period_start: options.periodStart,
|
|
period_end: options.periodEnd
|
|
};
|
|
|
|
if (usageColumns.minutes_used) {
|
|
record.minutes_used = options.minutesUsed;
|
|
} else if (usageColumns.hours_used) {
|
|
record.hours_used = Math.round(options.minutesUsed / 60);
|
|
}
|
|
|
|
if (usageColumns.overage_minutes) {
|
|
record.overage_minutes = options.overageMinutes ?? 0;
|
|
} else if (usageColumns.overage_hours) {
|
|
const overageHours = (options.overageMinutes ?? 0) / 60;
|
|
record.overage_hours = Math.round(overageHours);
|
|
}
|
|
|
|
if (usageColumns.contract_line_id) {
|
|
record.contract_line_id = contractLineId;
|
|
}
|
|
|
|
if (usageColumns.plan_id) {
|
|
record.plan_id = options.planId ?? contractLineId;
|
|
}
|
|
|
|
if (usageColumns.service_catalog_id) {
|
|
record.service_catalog_id = options.serviceId;
|
|
} else if (usageColumns.service_id) {
|
|
record.service_id = options.serviceId;
|
|
}
|
|
|
|
const rolledOverColumn = usageColumns.rolled_over_minutes ? 'rolled_over_minutes' : usageColumns.rolled_over_hours ? 'rolled_over_hours' : null;
|
|
|
|
if (rolledOverColumn) {
|
|
record[rolledOverColumn] = options.rolledOverMinutes ?? 0;
|
|
}
|
|
|
|
await context.db('bucket_usage').insert(record);
|
|
|
|
return usageId;
|
|
}
|