diff --git a/package.json b/package.json index bebf63d9..c47d3646 100644 --- a/package.json +++ b/package.json @@ -51,11 +51,10 @@ "test:coverage": "jest --coverage --passWithNoTests", "test:declarations": "ts-node --project tsconfig.tests.json scripts/check-declarations.ts && tsc -p tsconfig.declaration-tests.json --noEmit && npm run -s test:exact-public-config", "test:exact-public-config": "tsc -p tsconfig.exact-public-config-tests.json --noEmit", - "test:exact-source": "tsc -p tsconfig.exact-source.json --noEmit", "test:integration": "npm run -s typecheck && jest -c jest.integration.config.js --passWithNoTests", "test:integration:ci": "npm run -s typecheck && jest -c jest.integration.config.js --runInBand", "test:watch": "jest --watch", - "typecheck": "tsc -p tsconfig.tests.json --noEmit && npm run -s test:exact-source" + "typecheck": "tsc -p tsconfig.tests.json --noEmit" }, "config": { "localnet_quickstart_ref": "5c90cf4d0eb934712fd8c269b56e6272b605ccad" diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 80c67d94..c2c0ce32 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -1,10 +1,7 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; -import type { - ConversionTriggerFieldShape, - ConvertibleConversionTrigger, - OcfConvertibleIssuance, -} from '../../../types/native'; +import type { ConvertibleConversionTrigger, OcfConvertibleIssuance } from '../../../types/native'; +import { assertUniqueConversionTriggerIds, parseConversionTriggerFields } from '../../../utils/conversionTriggers'; import { dateStringToDAMLTime, isRecord, @@ -57,18 +54,6 @@ const CONVERSION_RIGHT_FIELDS = [ 'converts_to_future_round', 'converts_to_stock_class_id', ] as const; -const TRIGGER_FIELDS = [ - 'type', - 'trigger_id', - 'conversion_right', - 'nickname', - 'trigger_description', - 'trigger_date', - 'trigger_condition', - 'start_date', - 'end_date', -] as const; - function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { return new OcpValidationError(field, `${field} is required`, { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -239,16 +224,20 @@ function triggerToDaml( ): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.OcfConvertibleConversionTrigger { const source = `convertibleIssuance.conversion_triggers.${index}`; const trigger = requireRecord(value, source); - const nativeType = requireString(trigger.type, `${source}.type`) as ConvertibleConversionTrigger['type']; - assertExactObjectFields(trigger, TRIGGER_FIELDS, source); - const type = triggerTypeToDaml(nativeType, `${source}.type`); - const triggerFields = triggerFieldsToDaml(trigger as unknown as ConversionTriggerFieldShape, source); + const parsed = parseConversionTriggerFields( + { + ...trigger, + trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`), + }, + source + ); + const triggerFields = triggerFieldsToDaml(parsed, source); return { - type_: type, - trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`), - conversion_right: conversionRightToDaml(trigger.conversion_right, `${source}.conversion_right`), - nickname: optionalTextToDaml(trigger.nickname, `${source}.nickname`), - trigger_description: optionalTextToDaml(trigger.trigger_description, `${source}.trigger_description`), + type_: triggerTypeToDaml(parsed.type, `${source}.type`), + trigger_id: parsed.trigger_id, + conversion_right: conversionRightToDaml(parsed.conversion_right, `${source}.conversion_right`), + nickname: optionalTextToDaml(parsed.nickname, `${source}.nickname`), + trigger_description: optionalTextToDaml(parsed.trigger_description, `${source}.trigger_description`), ...triggerFields, }; } @@ -276,6 +265,12 @@ export function convertibleIssuanceDataToDaml( }); } const triggers = requireNonEmptyArray(issuance.conversion_triggers, 'convertibleIssuance.conversion_triggers'); + const damlTriggers = triggers.map(triggerToDaml); + assertUniqueConversionTriggerIds( + damlTriggers, + 'convertibleIssuance.conversion_triggers', + OcpErrorCodes.INVALID_FORMAT + ); return { id: requireString(issuance.id, 'convertibleIssuance.id'), date: requiredDateToDaml(issuance.date, 'convertibleIssuance.date'), @@ -297,7 +292,7 @@ export function convertibleIssuanceDataToDaml( ), investment_amount: requiredMonetaryToDaml(issuance.investment_amount, 'convertibleIssuance.investment_amount'), convertible_type: convertibleTypeToDaml(issuance.convertible_type), - conversion_triggers: triggers.map(triggerToDaml), + conversion_triggers: damlTriggers, pro_rata: canonicalOptionalNumericToDaml(issuance.pro_rata, 'convertibleIssuance.pro_rata'), seniority: seniorityToDaml(issuance.seniority), comments: commentsToDaml(issuance.comments, 'convertibleIssuance.comments'), diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 1ed1b96f..b03d0bf4 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -8,6 +8,11 @@ import type { ConvertibleType, OcfConvertibleIssuance, } from '../../../types/native'; +import { + assertDamlConversionTriggerFieldNames, + assertUniqueConversionTriggerIds, + parseConversionTriggerFields, +} from '../../../utils/conversionTriggers'; import { damlTimeToDateString, isRecord, @@ -153,20 +158,23 @@ function conversionRightFromDaml(value: unknown, field: string): ConvertibleConv } function conversionTriggerFromDaml(value: unknown, index: number): ConvertibleConversionTrigger { - const field = `convertibleIssuance.conversion_triggers.${index}`; - const trigger = requireRecord(value, field); - const nickname = optionalString(trigger.nickname, `${field}.nickname`); - const description = optionalString(trigger.trigger_description, `${field}.trigger_description`); - const typePath = `${field}.type_`; + const source = `convertibleIssuance.conversion_triggers.${index}`; + const trigger = requireRecord(value, source); + assertDamlConversionTriggerFieldNames(trigger, source); + const typePath = `${source}.type_`; const type = mapDamlTriggerTypeToOcf(requireString(trigger.type_, typePath), typePath); - const triggerFields = triggerFieldsFromDaml(trigger, type, field); - return { - ...triggerFields, - trigger_id: requireString(trigger.trigger_id, `${field}.trigger_id`), - conversion_right: conversionRightFromDaml(trigger.conversion_right, `${field}.conversion_right`), - ...(nickname ? { nickname } : {}), - ...(description ? { trigger_description: description } : {}), - }; + const triggerFields = triggerFieldsFromDaml(trigger, type, source); + return parseConversionTriggerFields( + { + trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`), + conversion_right: conversionRightFromDaml(trigger.conversion_right, `${source}.conversion_right`), + nickname: trigger.nickname, + trigger_description: trigger.trigger_description, + ...triggerFields, + }, + source, + { nullIsAbsent: true, unexpectedFieldCode: OcpErrorCodes.SCHEMA_MISMATCH } + ); } function securityLawExemptionsFromDaml(value: unknown): Array<{ description: string; jurisdiction: string }> { @@ -201,6 +209,11 @@ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConverti conversionTriggerFromDaml(firstConversionTrigger, 0), ...remainingConversionTriggers.map((trigger, index) => conversionTriggerFromDaml(trigger, index + 1)), ]; + assertUniqueConversionTriggerIds( + nativeConversionTriggers, + 'convertibleIssuance.conversion_triggers', + OcpErrorCodes.SCHEMA_MISMATCH + ); const seniority = parseDamlSafeInteger(data.seniority, 'convertibleIssuance.seniority'); const boardApprovalDate = optionalDamlTimeToDateString( data.board_approval_date, diff --git a/src/functions/OpenCapTable/shared/triggerFields.ts b/src/functions/OpenCapTable/shared/triggerFields.ts index 588e1f7b..57823165 100644 --- a/src/functions/OpenCapTable/shared/triggerFields.ts +++ b/src/functions/OpenCapTable/shared/triggerFields.ts @@ -4,6 +4,7 @@ import type { ConversionTriggerFieldShapeFor, ConversionTriggerType, } from '../../../types/native'; +import { assertConversionTriggerRangeOrder } from '../../../utils/conversionTriggers'; import { damlTimeToDateString, dateStringToDAMLTime } from '../../../utils/typeConversions'; export type OcfTriggerDiscriminator = ConversionTriggerType; @@ -145,11 +146,14 @@ export function triggerFieldsToDaml(input: ConversionTriggerFieldShape, basePath }; case 'ELECTIVE_IN_RANGE': rejectInputFields(input, ['trigger_date', 'trigger_condition'], input.type, basePath); + const startDate = requiredDateToDaml(input.start_date, basePath, 'start_date'); + const endDate = requiredDateToDaml(input.end_date, basePath, 'end_date'); + assertConversionTriggerRangeOrder(startDate, endDate, basePath, OcpErrorCodes.INVALID_FORMAT); return { trigger_date: null, trigger_condition: null, - start_date: requiredDateToDaml(input.start_date, basePath, 'start_date'), - end_date: requiredDateToDaml(input.end_date, basePath, 'end_date'), + start_date: startDate, + end_date: endDate, }; case 'AUTOMATIC_ON_CONDITION': case 'ELECTIVE_ON_CONDITION': @@ -187,10 +191,13 @@ export function triggerFieldsFromDaml( }; case 'ELECTIVE_IN_RANGE': rejectDamlFields(input, ['trigger_date', 'trigger_condition'], type, basePath); + const startDate = requiredDateFromDaml(input.start_date, basePath, 'start_date'); + const endDate = requiredDateFromDaml(input.end_date, basePath, 'end_date'); + assertConversionTriggerRangeOrder(startDate, endDate, basePath, OcpErrorCodes.SCHEMA_MISMATCH); return { type, - start_date: requiredDateFromDaml(input.start_date, basePath, 'start_date'), - end_date: requiredDateFromDaml(input.end_date, basePath, 'end_date'), + start_date: startDate, + end_date: endDate, }; case 'AUTOMATIC_ON_CONDITION': case 'ELECTIVE_ON_CONDITION': diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index ed701422..16c84651 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -1,12 +1,14 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { + ConversionTriggerFor, ConvertibleConversionMechanism, PersistedOcfWarrantIssuance, PersistedStockClassRatioConversionMechanism, PersistedWarrantConversionMechanism, PersistedWarrantExerciseTrigger, } from '../../../types/native'; +import { assertUniqueConversionTriggerIds, parseConversionTriggerFields } from '../../../utils/conversionTriggers'; import { dateStringToDAMLTime, isRecord, @@ -72,17 +74,6 @@ const CONVERSION_RIGHT_FIELDS = [ 'converts_to_future_round', 'converts_to_stock_class_id', ] as const; -const TRIGGER_FIELDS = [ - 'type', - 'trigger_id', - 'conversion_right', - 'nickname', - 'trigger_description', - 'trigger_date', - 'trigger_condition', - 'start_date', - 'end_date', -] as const; function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { return new OcpValidationError(field, `${field} is required`, { @@ -257,14 +248,13 @@ function requireStockClassTarget(value: unknown, field: string): string { } function storageTrigger( - trigger: Record, - triggerType: PersistedWarrantExerciseTrigger['type'], + trigger: ConversionTriggerFor, convertsToStockClassId: string, source: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { - const triggerFields = triggerFieldsToDaml(trigger as unknown as PersistedWarrantExerciseTrigger, source); + const triggerFields = triggerFieldsToDaml(trigger, source); return { - type_: triggerTypeToDaml(triggerType, `${source}.type`), + type_: triggerTypeToDaml(trigger.type, `${source}.type`), trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`), nickname: optionalTextToDaml(trigger.nickname, `${source}.nickname`), trigger_description: optionalTextToDaml(trigger.trigger_description, `${source}.trigger_description`), @@ -285,8 +275,7 @@ function storageTrigger( } function stockClassRightToDaml( - trigger: Record, - triggerType: PersistedWarrantExerciseTrigger['type'], + trigger: ConversionTriggerFor, right: Record, source: string, triggerSource: string @@ -304,7 +293,7 @@ function stockClassRightToDaml( value: { type_: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: mechanism.conversion_mechanism, - conversion_trigger: storageTrigger(trigger, triggerType, convertsToStockClassId, triggerSource), + conversion_trigger: storageTrigger(trigger, convertsToStockClassId, triggerSource), converts_to_stock_class_id: convertsToStockClassId, ratio: mechanism.ratio, conversion_price: mechanism.conversion_price, @@ -326,8 +315,7 @@ function stockClassRightToDaml( } function conversionRightToDaml( - trigger: Record, - triggerType: PersistedWarrantExerciseTrigger['type'], + trigger: ConversionTriggerFor, source: string, triggerSource: string ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { @@ -374,7 +362,7 @@ function conversionRightToDaml( }, }; case 'STOCK_CLASS_CONVERSION_RIGHT': - return stockClassRightToDaml(trigger, triggerType, right, source, triggerSource); + return stockClassRightToDaml(trigger, right, source, triggerSource); default: throw new OcpParseError(`Unknown warrant conversion right type: ${rightType}`, { source: `${source}.type`, @@ -386,16 +374,14 @@ function conversionRightToDaml( function triggerToDaml(value: unknown, index: number): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { const source = `warrantIssuance.exercise_triggers.${index}`; const trigger = requireRecord(value, source); - const nativeType = requireString(trigger.type, `${source}.type`) as PersistedWarrantExerciseTrigger['type']; - assertExactObjectFields(trigger, TRIGGER_FIELDS, source); - const type = triggerTypeToDaml(nativeType, `${source}.type`); - const triggerFields = triggerFieldsToDaml(trigger as unknown as PersistedWarrantExerciseTrigger, source); + const parsed = parseConversionTriggerFields(trigger, source); + const triggerFields = triggerFieldsToDaml(parsed, source); return { - type_: type, - trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`), - conversion_right: conversionRightToDaml(trigger, nativeType, `${source}.conversion_right`, source), - nickname: optionalTextToDaml(trigger.nickname, `${source}.nickname`), - trigger_description: optionalTextToDaml(trigger.trigger_description, `${source}.trigger_description`), + type_: triggerTypeToDaml(parsed.type, `${source}.type`), + trigger_id: parsed.trigger_id, + conversion_right: conversionRightToDaml(parsed, `${source}.conversion_right`, source), + nickname: optionalTextToDaml(parsed.nickname, `${source}.nickname`), + trigger_description: optionalTextToDaml(parsed.trigger_description, `${source}.trigger_description`), ...triggerFields, }; } @@ -419,6 +405,8 @@ export function warrantIssuanceDataToDaml( ? quantitySourceToDaml('UNSPECIFIED') : quantitySourceToDaml(issuance.quantity_source); const triggers = requireArray(issuance.exercise_triggers, 'warrantIssuance.exercise_triggers'); + const damlTriggers = triggers.map(triggerToDaml); + assertUniqueConversionTriggerIds(damlTriggers, 'warrantIssuance.exercise_triggers', OcpErrorCodes.INVALID_FORMAT); const vestings = optionalArray(issuance.vestings, 'warrantIssuance.vestings'); return { @@ -444,7 +432,7 @@ export function warrantIssuanceDataToDaml( quantity_source: quantitySource, exercise_price: optionalMonetaryToDaml(issuance.exercise_price, 'warrantIssuance.exercise_price'), purchase_price: requiredMonetaryToDaml(issuance.purchase_price, 'warrantIssuance.purchase_price'), - exercise_triggers: triggers.map(triggerToDaml), + exercise_triggers: damlTriggers, warrant_expiration_date: optionalDateStringToDAMLTime( issuance.warrant_expiration_date, 'warrantIssuance.warrant_expiration_date' diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 04419069..93c6cb92 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -13,6 +13,11 @@ import type { WarrantExerciseTrigger, WarrantTriggerConversionRight, } from '../../../types/native'; +import { + assertDamlConversionTriggerFieldNames, + assertUniqueConversionTriggerIds, + parseConversionTriggerFields, +} from '../../../utils/conversionTriggers'; import { damlTimeToDateString, isRecord, @@ -86,7 +91,11 @@ function requireRecord(value: unknown, field: string): Record { function requireString(value: unknown, field: string): string { if (value === null || value === undefined) { - throw requiredMissing(field, 'non-empty string', value); + throw new OcpValidationError(field, `${field} is required and must be a non-empty string`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: value, + }); } if (typeof value !== 'string') { throw invalidType(field, `${field} must be a string`, 'non-empty string', value); @@ -225,14 +234,24 @@ function conversionRightFromDaml(value: unknown, field: string): DecodedWarrantR } function triggerFromDaml(value: unknown, index: number): WarrantExerciseTrigger { - const field = `warrantIssuance.exercise_triggers.${index}`; - const trigger = requireRecord(value, field); - const nickname = optionalString(trigger.nickname, `${field}.nickname`); - const description = optionalString(trigger.trigger_description, `${field}.trigger_description`); - const typePath = `${field}.type_`; + const source = `warrantIssuance.exercise_triggers.${index}`; + const trigger = requireRecord(value, source); + assertDamlConversionTriggerFieldNames(trigger, source); + const typePath = `${source}.type_`; const type = mapDamlTriggerTypeToOcf(requireString(trigger.type_, typePath), typePath); - const triggerFields = triggerFieldsFromDaml(trigger, type, field); - const decodedRight = conversionRightFromDaml(trigger.conversion_right, `${field}.conversion_right`); + const triggerFields = triggerFieldsFromDaml(trigger, type, source); + const decodedRight = conversionRightFromDaml(trigger.conversion_right, `${source}.conversion_right`); + const parsed = parseConversionTriggerFields( + { + trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`), + conversion_right: decodedRight.right, + nickname: trigger.nickname, + trigger_description: trigger.trigger_description, + ...triggerFields, + }, + source, + { nullIsAbsent: true, unexpectedFieldCode: OcpErrorCodes.SCHEMA_MISMATCH } + ); if (decodedRight.kind === 'stock-class') { assertStockClassStorageTrigger( decodedRight.nestedTrigger, @@ -241,13 +260,7 @@ function triggerFromDaml(value: unknown, index: number): WarrantExerciseTrigger trigger ); } - return { - trigger_id: requireString(trigger.trigger_id, `${field}.trigger_id`), - conversion_right: decodedRight.right, - ...(nickname ? { nickname } : {}), - ...(description ? { trigger_description: description } : {}), - ...triggerFields, - }; + return parsed; } function quantitySourceFromDaml(value: unknown): QuantitySourceType | undefined { @@ -328,6 +341,12 @@ export function damlWarrantIssuanceDataToNative(value: unknown): OcfWarrantIssua assertCanonicalJsonGraph(value, 'warrantIssuance'); const data = requireRecord(value, 'warrantIssuance'); const exerciseTriggers = requireArray(data.exercise_triggers, 'warrantIssuance.exercise_triggers'); + const nativeExerciseTriggers = exerciseTriggers.map(triggerFromDaml); + assertUniqueConversionTriggerIds( + nativeExerciseTriggers, + 'warrantIssuance.exercise_triggers', + OcpErrorCodes.SCHEMA_MISMATCH + ); const quantity = data.quantity === null || data.quantity === undefined ? undefined @@ -359,7 +378,7 @@ export function damlWarrantIssuanceDataToNative(value: unknown): OcfWarrantIssua custom_id: requireString(data.custom_id, 'warrantIssuance.custom_id'), stakeholder_id: requireString(data.stakeholder_id, 'warrantIssuance.stakeholder_id'), purchase_price: monetaryFromDaml(data.purchase_price, 'warrantIssuance.purchase_price'), - exercise_triggers: exerciseTriggers.map(triggerFromDaml), + exercise_triggers: nativeExerciseTriggers, security_law_exemptions: securityLawExemptionsFromDaml(data.security_law_exemptions), ...(quantity !== undefined ? { quantity } : {}), ...(quantitySource ? { quantity_source: quantitySource } : {}), diff --git a/src/types/native.ts b/src/types/native.ts index 80679610..c3cb9433 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -141,8 +141,48 @@ export type ConversionTriggerFieldShapeFor = /** Union of every exact discriminator-specific trigger field shape. */ export type ConversionTriggerFieldShape = ConversionTriggerFieldShapeFor; -/** Exact OCF conversion-trigger union parameterized by its conversion-right type. */ -export type ConversionTriggerFor = ConversionTriggerBase & ConversionTriggerFieldShape; +/** Automatic or elective conversion activated by a legal condition. */ +export type ConversionOnConditionTrigger< + TRight, + TriggerType extends 'AUTOMATIC_ON_CONDITION' | 'ELECTIVE_ON_CONDITION', +> = ConversionTriggerBase & ConversionTriggerFieldShapeFor; + +export type AutomaticConversionOnConditionTrigger = ConversionOnConditionTrigger< + TRight, + 'AUTOMATIC_ON_CONDITION' +>; + +export type ElectiveConversionOnConditionTrigger = ConversionOnConditionTrigger< + TRight, + 'ELECTIVE_ON_CONDITION' +>; + +/** Automatic conversion on one calendar date. */ +export type AutomaticConversionOnDateTrigger = ConversionTriggerBase & + ConversionTriggerFieldShapeFor<'AUTOMATIC_ON_DATE'>; + +/** Elective conversion during an inclusive date range. */ +export type ElectiveConversionInRangeTrigger = ConversionTriggerBase & + ConversionTriggerFieldShapeFor<'ELECTIVE_IN_RANGE'>; + +/** A trigger with no condition or date payload. */ +export type PayloadlessConversionTrigger< + TRight, + TriggerType extends 'ELECTIVE_AT_WILL' | 'UNSPECIFIED', +> = ConversionTriggerBase & ConversionTriggerFieldShapeFor; + +export type ElectiveConversionAtWillTrigger = PayloadlessConversionTrigger; + +export type UnspecifiedConversionTrigger = PayloadlessConversionTrigger; + +/** The six exact conversion-trigger shapes defined by the pinned OCF schemas. */ +export type ConversionTriggerFor = + | AutomaticConversionOnConditionTrigger + | AutomaticConversionOnDateTrigger + | ElectiveConversionInRangeTrigger + | ElectiveConversionOnConditionTrigger + | ElectiveConversionAtWillTrigger + | UnspecifiedConversionTrigger; // ===== Capitalization Definition Rules ===== @@ -361,7 +401,7 @@ export interface PersistedWarrantConversionRight extends Omit; /** Mechanisms permitted by the OCF ConvertibleConversionRight schema. */ @@ -380,7 +420,7 @@ export interface ConvertibleConversionRight { converts_to_stock_class_id?: string; } -/** Convertible Conversion Trigger Describes when and how a convertible instrument can convert. */ +/** Convertible trigger with discriminator-specific timing fields. */ export type ConvertibleConversionTrigger = ConversionTriggerFor; /** diff --git a/src/utils/conversionTriggers.ts b/src/utils/conversionTriggers.ts new file mode 100644 index 00000000..ae3f1277 --- /dev/null +++ b/src/utils/conversionTriggers.ts @@ -0,0 +1,388 @@ +import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../errors'; +import type { ConversionTriggerFor, ConversionTriggerType } from '../types/native'; + +const CONVERSION_TRIGGER_TYPES: ReadonlySet = new Set([ + 'AUTOMATIC_ON_CONDITION', + 'AUTOMATIC_ON_DATE', + 'ELECTIVE_IN_RANGE', + 'ELECTIVE_ON_CONDITION', + 'ELECTIVE_AT_WILL', + 'UNSPECIFIED', +]); + +function fieldSet(...fields: string[]): ReadonlySet { + return new Set(fields); +} + +const COMMON_CONVERSION_TRIGGER_FIELDS = [ + 'type', + 'trigger_id', + 'conversion_right', + 'nickname', + 'trigger_description', +] as const; + +const CANONICAL_CONVERSION_TRIGGER_FIELDS = fieldSet( + ...COMMON_CONVERSION_TRIGGER_FIELDS, + 'trigger_date', + 'trigger_condition', + 'start_date', + 'end_date' +); + +const ALLOWED_CONVERSION_TRIGGER_FIELDS: Readonly>> = { + AUTOMATIC_ON_CONDITION: fieldSet(...COMMON_CONVERSION_TRIGGER_FIELDS, 'trigger_condition'), + AUTOMATIC_ON_DATE: fieldSet(...COMMON_CONVERSION_TRIGGER_FIELDS, 'trigger_date'), + ELECTIVE_IN_RANGE: fieldSet(...COMMON_CONVERSION_TRIGGER_FIELDS, 'start_date', 'end_date'), + ELECTIVE_ON_CONDITION: fieldSet(...COMMON_CONVERSION_TRIGGER_FIELDS, 'trigger_condition'), + ELECTIVE_AT_WILL: fieldSet(...COMMON_CONVERSION_TRIGGER_FIELDS), + UNSPECIFIED: fieldSet(...COMMON_CONVERSION_TRIGGER_FIELDS), +}; + +const DAML_CONVERSION_TRIGGER_FIELDS = fieldSet( + 'type_', + 'trigger_id', + 'conversion_right', + 'nickname', + 'trigger_description', + 'trigger_date', + 'trigger_condition', + 'start_date', + 'end_date' +); + +function isConversionTriggerType(value: unknown): value is ConversionTriggerType { + switch (value) { + case 'AUTOMATIC_ON_CONDITION': + case 'AUTOMATIC_ON_DATE': + case 'ELECTIVE_IN_RANGE': + case 'ELECTIVE_ON_CONDITION': + case 'ELECTIVE_AT_WILL': + case 'UNSPECIFIED': + return true; + default: + return false; + } +} + +export interface ConversionTriggerFields { + type: unknown; + trigger_id: unknown; + conversion_right: ConversionRight; + nickname?: unknown; + trigger_description?: unknown; + trigger_date?: unknown; + trigger_condition?: unknown; + start_date?: unknown; + end_date?: unknown; +} + +interface ConversionTriggerParseOptions { + nullIsAbsent?: boolean; + unexpectedFieldCode?: OcpErrorCode; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function fieldPath(source: string, field: string): string { + return `${source}.${field}`; +} + +/** + * Reject ambiguous trigger references before they cross the OCF/DAML boundary. + * + * `trigger_id` is the stable reference used by later transactions and is + * required by OCF to be unique within its parent trigger list. + */ +export function assertUniqueConversionTriggerIds( + triggers: ReadonlyArray<{ readonly trigger_id: string }>, + source: string, + code: OcpErrorCode +): void { + const firstIndexById = new Map(); + + for (const [index, trigger] of triggers.entries()) { + const firstIndex = firstIndexById.get(trigger.trigger_id); + if (firstIndex !== undefined) { + throw new OcpValidationError( + `${source}.${index}.trigger_id`, + `Duplicate trigger_id ${JSON.stringify(trigger.trigger_id)}; first declared at ${source}.${firstIndex}.trigger_id`, + { + code, + expectedType: 'trigger_id unique within its parent trigger list', + receivedValue: trigger.trigger_id, + context: { + triggerId: trigger.trigger_id, + firstIndex, + duplicateIndex: index, + }, + } + ); + } + firstIndexById.set(trigger.trigger_id, index); + } +} + +/** Validate the inclusive ordering of one canonical ELECTIVE_IN_RANGE window. */ +export function assertConversionTriggerRangeOrder( + startDate: string, + endDate: string, + source: string, + code: OcpErrorCode +): void { + if (startDate <= endDate) return; + + throw new OcpValidationError( + fieldPath(source, 'end_date'), + `end_date ${JSON.stringify(endDate)} must be on or after start_date ${JSON.stringify(startDate)}`, + { + code, + expectedType: `date on or after ${startDate}`, + receivedValue: endDate, + context: { startDate, endDate }, + } + ); +} + +function rejectUnknownOwnFields( + fields: Record, + allowedFields: ReadonlySet, + source: string, + code: OcpErrorCode, + message: (field: string) => string, + absentFields?: ReadonlySet +): void { + for (const field of Object.keys(fields)) { + if (allowedFields.has(field)) continue; + + const value = fields[field]; + if (absentFields?.has(field) && (value === undefined || value === null)) continue; + + throw new OcpValidationError(fieldPath(source, field), message(field), { + code, + expectedType: 'absent', + receivedValue: value, + }); + } +} + +/** Reject fields that cannot exist on the generated DAML conversion-trigger record. */ +export function assertDamlConversionTriggerFieldNames(fields: Record, source: string): void { + rejectUnknownOwnFields( + fields, + DAML_CONVERSION_TRIGGER_FIELDS, + source, + OcpErrorCodes.SCHEMA_MISMATCH, + (field) => `${field} is not a valid DAML conversion-trigger field` + ); +} + +function requireString(value: unknown, source: string, field: string): string { + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath(source, field), `${field} is required and must be a string`, { + code: value === undefined || value === null ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_TYPE, + expectedType: 'string', + receivedValue: value, + }); + } + if (value.length === 0) { + throw new OcpValidationError(fieldPath(source, field), `${field} is required and must be a non-empty string`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + return value; +} + +function optionalString(value: unknown, source: string, field: string, nullIsAbsent: boolean): string | undefined { + if (value === undefined || (nullIsAbsent && value === null)) return undefined; + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath(source, field), `${field} must be a string when present`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string', + receivedValue: value, + }); + } + if (value.trim().length === 0) { + throw new OcpValidationError(fieldPath(source, field), `${field} must be a non-blank string when present`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-blank string', + receivedValue: value, + }); + } + return value; +} + +function requireTriggerType(value: unknown, source: string): ConversionTriggerType { + const field = fieldPath(source, 'type'); + const expectedType = [...CONVERSION_TRIGGER_TYPES].join(' | '); + if (value === undefined || value === null) { + throw new OcpValidationError(field, 'Conversion trigger type is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(field, 'Conversion trigger type must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue: value, + }); + } + if (value.length === 0) { + throw new OcpValidationError(field, 'Conversion trigger type must be non-empty', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + } + if (!isConversionTriggerType(value)) { + throw new OcpValidationError(field, `Unknown conversion trigger type: ${value}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType, + receivedValue: value, + }); + } + return value; +} + +function commonFields( + fields: ConversionTriggerFields, + source: string, + nullIsAbsent: boolean +): { + trigger_id: string; + conversion_right: ConversionRight; + nickname?: string; + trigger_description?: string; +} { + const trigger_id = requireString(fields.trigger_id, source, 'trigger_id'); + if (fields.conversion_right === undefined || fields.conversion_right === null) { + throw new OcpValidationError(fieldPath(source, 'conversion_right'), 'conversion_right is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: fields.conversion_right, + }); + } + const nickname = optionalString(fields.nickname, source, 'nickname', nullIsAbsent); + const trigger_description = optionalString(fields.trigger_description, source, 'trigger_description', nullIsAbsent); + return { + trigger_id, + conversion_right: fields.conversion_right, + ...(nickname !== undefined ? { nickname } : {}), + ...(trigger_description !== undefined ? { trigger_description } : {}), + }; +} + +/** + * Validate and construct one exact canonical conversion-trigger variant. + * + * DAML readers may opt into treating `null` as an absent optional. OCF writers + * keep the strict default and reject `null`. Any present field owned by another + * variant is rejected instead of silently discarded. + */ +export function parseConversionTriggerFields( + fields: ConversionTriggerFields, + source: string, + options?: ConversionTriggerParseOptions +): ConversionTriggerFor; +export function parseConversionTriggerFields( + fields: unknown, + source: string, + options?: ConversionTriggerParseOptions +): ConversionTriggerFor; +export function parseConversionTriggerFields( + fields: unknown, + source: string, + options: ConversionTriggerParseOptions = {} +): ConversionTriggerFor { + if (!isRecord(fields)) { + throw new OcpValidationError(source, 'Conversion trigger must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: fields, + }); + } + const triggerFields: ConversionTriggerFields = { + type: fields.type, + trigger_id: fields.trigger_id, + conversion_right: fields.conversion_right, + nickname: fields.nickname, + trigger_description: fields.trigger_description, + trigger_date: fields.trigger_date, + trigger_condition: fields.trigger_condition, + start_date: fields.start_date, + end_date: fields.end_date, + }; + const nullIsAbsent = options.nullIsAbsent ?? false; + const type = requireTriggerType(triggerFields.type, source); + const unexpectedFieldCode = options.unexpectedFieldCode ?? OcpErrorCodes.INVALID_FORMAT; + rejectUnknownOwnFields( + fields, + CANONICAL_CONVERSION_TRIGGER_FIELDS, + source, + OcpErrorCodes.SCHEMA_MISMATCH, + (field) => `${field} is not a valid conversion-trigger field` + ); + rejectUnknownOwnFields( + fields, + ALLOWED_CONVERSION_TRIGGER_FIELDS[type], + source, + unexpectedFieldCode, + (field) => `${field} is not valid for conversion trigger type ${type}`, + nullIsAbsent ? CANONICAL_CONVERSION_TRIGGER_FIELDS : undefined + ); + const common = commonFields(triggerFields, source, nullIsAbsent); + + switch (type) { + case 'AUTOMATIC_ON_CONDITION': + case 'ELECTIVE_ON_CONDITION': { + return { + ...common, + type, + trigger_condition: requireString(triggerFields.trigger_condition, source, 'trigger_condition'), + }; + } + case 'AUTOMATIC_ON_DATE': { + return { + ...common, + type, + trigger_date: requireString(triggerFields.trigger_date, source, 'trigger_date'), + }; + } + case 'ELECTIVE_IN_RANGE': { + return { + ...common, + type, + start_date: requireString(triggerFields.start_date, source, 'start_date'), + end_date: requireString(triggerFields.end_date, source, 'end_date'), + }; + } + case 'ELECTIVE_AT_WILL': + case 'UNSPECIFIED': { + return { ...common, type }; + } + } +} + +/** Validate semantic invariants that JSON Schema cannot express across a parent trigger list. */ +export function assertConversionTriggerListSemantics( + triggers: readonly unknown[], + source: string, + code: OcpErrorCode +): void { + const parsedTriggers = triggers.map((trigger, index) => + parseConversionTriggerFields(trigger, `${source}.${index}`, { unexpectedFieldCode: code }) + ); + + for (const [index, trigger] of parsedTriggers.entries()) { + if (trigger.type === 'ELECTIVE_IN_RANGE') { + assertConversionTriggerRangeOrder(trigger.start_date, trigger.end_date, `${source}.${index}`, code); + } + } + + assertUniqueConversionTriggerIds(parsedTriggers, source, code); +} diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index a21047b7..ec65b975 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -21,6 +21,7 @@ import type { PersistedStockClassRatioConversionMechanism, PersistedWarrantConversionMechanism, } from '../types/native'; +import { assertConversionTriggerListSemantics } from './conversionTriggers'; import { assertSafeOcfJson } from './ocfJsonValidation'; import { normalizeOcfData } from './planSecurityAliases'; @@ -546,6 +547,17 @@ function parseWithOcfSchema(input: Record, objectType: string): /** Enforce ledger-v34 refinements only at the SDK's strongly typed entity boundary. */ function validateTypedConversionRefinements(value: Record): void { + if (value.object_type === 'TX_CONVERTIBLE_ISSUANCE' && Array.isArray(value.conversion_triggers)) { + assertConversionTriggerListSemantics( + value.conversion_triggers, + 'conversion_triggers', + OcpErrorCodes.INVALID_FORMAT + ); + } + if (value.object_type === 'TX_WARRANT_ISSUANCE' && Array.isArray(value.exercise_triggers)) { + assertConversionTriggerListSemantics(value.exercise_triggers, 'exercise_triggers', OcpErrorCodes.INVALID_FORMAT); + } + const visit = (current: unknown, currentPath: string): void => { if (Array.isArray(current)) { current.forEach((item, index) => visit(item, currentPath === '' ? `${index}` : `${currentPath}.${index}`)); diff --git a/test/capTable/archiveFullCapTable.test.ts b/test/capTable/archiveFullCapTable.test.ts index 94f1bf34..8db31db0 100644 --- a/test/capTable/archiveFullCapTable.test.ts +++ b/test/capTable/archiveFullCapTable.test.ts @@ -42,14 +42,16 @@ function mockActiveContractsForCapTableState( responses: { current?: unknown[] } ): void { const current = responses.current ?? []; - mockClient.getActiveContracts.mockImplementation(async (req: { templateIds?: string[] }) => { - await Promise.resolve(); - const ids = req.templateIds; - if (isCurrentTemplateQuery(ids)) { - return current as never; + mockClient.getActiveContracts.mockImplementation( + async (req: Parameters[0]) => { + await Promise.resolve(); + const ids = req.templateIds; + if (isCurrentTemplateQuery(ids)) { + return current as never; + } + throw new Error(`Unexpected getActiveContracts templateIds in test: ${JSON.stringify(ids)}`); } - throw new Error(`Unexpected getActiveContracts templateIds in test: ${JSON.stringify(ids)}`); - }); + ); } interface TestIssuerData { diff --git a/test/capTable/getCapTableState.test.ts b/test/capTable/getCapTableState.test.ts index d3dae296..b990ebab 100644 --- a/test/capTable/getCapTableState.test.ts +++ b/test/capTable/getCapTableState.test.ts @@ -41,14 +41,16 @@ function mockActiveContractsForCapTableState( responses: { current?: unknown[] } ): void { const current = responses.current ?? []; - mockClient.getActiveContracts.mockImplementation(async (req: { templateIds?: string[] }) => { - await Promise.resolve(); - const ids = req.templateIds; - if (isCurrentTemplateQuery(ids)) { - return current as never; + mockClient.getActiveContracts.mockImplementation( + async (req: Parameters[0]) => { + await Promise.resolve(); + const ids = req.templateIds; + if (isCurrentTemplateQuery(ids)) { + return current as never; + } + throw new Error(`Unexpected getActiveContracts templateIds in test: ${JSON.stringify(ids)}`); } - throw new Error(`Unexpected getActiveContracts templateIds in test: ${JSON.stringify(ids)}`); - }); + ); } /** diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index c7ae66f1..5319ae4d 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -306,10 +306,10 @@ describe('OcpClient', () => { expect(() => new OcpClient({ ledger, defaultContext: { workflowId: 123 } } as never)).toThrow( expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'dependencies.defaultContext.workflowId' }) ); - expect(() => new OcpClient({ ledger, validator: undefined })).toThrow( + expect(() => new OcpClient({ ledger, validator: undefined } as never)).toThrow( expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'dependencies.validator' }) ); - expect(() => OcpClient.fromEnv({ factory: undefined })).toThrow( + expect(() => OcpClient.fromEnv({ factory: undefined } as never)).toThrow( expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'options.factory' }) ); @@ -504,7 +504,7 @@ describe('OcpClient OpenCapTable.issuerAuthorization.authorize', () => { }); await expect( - ocp.OpenCapTable.issuerAuthorization.authorize({ issuer: 'issuer::party', factory: undefined }) + ocp.OpenCapTable.issuerAuthorization.authorize({ issuer: 'issuer::party', factory: undefined } as never) ).rejects.toMatchObject({ name: 'OcpValidationError', fieldPath: 'authorizeIssuer.factory', @@ -612,7 +612,7 @@ describe('OcpClient OpenCapTable.issuerAuthorization.authorize', () => { expect(mockedAuthorizeIssuer).toHaveBeenCalledTimes(1); }); - it('rejects explicit undefined per-call observability fields', async () => { + it('keeps client-level observability defaults when per-call fields are omitted', async () => { const ledger = createLedgerJsonApiClient(config); const logger = { debug: jest.fn(), @@ -632,12 +632,34 @@ describe('OcpClient OpenCapTable.issuerAuthorization.authorize', () => { defaultContext: { workflowId: 'workflow-default' }, }); + await ocp.OpenCapTable.issuerAuthorization.authorize({ + issuer: 'issuer::party', + context: { commandId: 'command-call' }, + }); + + expect(mockedAuthorizeIssuer).toHaveBeenCalledWith( + ledger, + expect.objectContaining({ + issuer: 'issuer::party', + logger, + metrics, + defaultContext: { workflowId: 'workflow-default' }, + context: { commandId: 'command-call' }, + }) + ); + expect(mockedAuthorizeIssuer).toHaveBeenCalledTimes(1); + }); + + it('rejects explicit undefined per-call observability fields', async () => { + const ledger = createLedgerJsonApiClient(config); + const ocp = new OcpClient({ ledger }); + await expect( ocp.OpenCapTable.issuerAuthorization.authorize({ issuer: 'issuer::party', logger: undefined, context: { commandId: 'command-call' }, - }) + } as never) ).rejects.toThrow('logger must be omitted rather than set to undefined'); expect(mockedAuthorizeIssuer).not.toHaveBeenCalled(); }); diff --git a/test/client/OcpContextManager.test.ts b/test/client/OcpContextManager.test.ts index 4f4a4445..89f66469 100644 --- a/test/client/OcpContextManager.test.ts +++ b/test/client/OcpContextManager.test.ts @@ -77,11 +77,9 @@ describe('OcpContextManager', () => { expect(contextManager.issuerParty).toBeNull(); }); - it('should not change values when undefined is passed', () => { + it('should not change omitted values', () => { contextManager.setIssuerParty('issuer::party-123'); - contextManager.setAll({ - issuerParty: undefined, - }); + contextManager.setAll({}); expect(contextManager.issuerParty).toBe('issuer::party-123'); }); diff --git a/test/config/environment.test.ts b/test/config/environment.test.ts index 9d725809..2cc14751 100644 --- a/test/config/environment.test.ts +++ b/test/config/environment.test.ts @@ -157,12 +157,9 @@ describe('environment configuration', () => { it('rejects explicit undefined env-loader overrides to match exact optional declarations', () => { expect(() => - loadEnvironmentConfigFromEnv( - {}, - { - ledgerApiUrl: undefined, - } - ) + loadEnvironmentConfigFromEnv({}, { + ledgerApiUrl: undefined, + } as never) ).toThrow('ledgerApiUrl must be omitted rather than set to undefined'); }); diff --git a/test/converters/conversionDescriptorBoundaries.test.ts b/test/converters/conversionDescriptorBoundaries.test.ts index 7eda62d7..69a1f07d 100644 --- a/test/converters/conversionDescriptorBoundaries.test.ts +++ b/test/converters/conversionDescriptorBoundaries.test.ts @@ -24,6 +24,7 @@ import { import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import type { ConvertibleConversionMechanism, + OcfConvertibleIssuance, OcfStockClass, OcfStockClassConversionRatioAdjustment, PersistedWarrantConversionMechanism, @@ -147,7 +148,7 @@ const WARRANT_MECHANISMS: readonly PersistedWarrantConversionMechanism[] = [ function convertibleInput( mechanism: ConvertibleConversionMechanism = CONVERTIBLE_MECHANISMS[0] as ConvertibleConversionMechanism -): ConvertibleIssuanceInput { +): OcfConvertibleIssuance { return { object_type: 'TX_CONVERTIBLE_ISSUANCE', id: 'convertible-1', @@ -693,7 +694,7 @@ describe('bounded dense-array validation', () => { const convertible = convertibleInput(note as never); for (const write of [ () => convertibleIssuanceDataToDaml(convertible), - () => convertToDaml('convertibleIssuance', convertible as never), + () => convertToDaml('convertibleIssuance', convertible), ]) { expectArrayBoundary(write, noteInterestRatesPath, OcpErrorCodes.REQUIRED_FIELD_MISSING); } @@ -717,7 +718,7 @@ describe('bounded dense-array validation', () => { } as never); for (const write of [ () => convertibleIssuanceDataToDaml(convertible), - () => convertToDaml('convertibleIssuance', convertible as never), + () => convertToDaml('convertibleIssuance', convertible), ]) { expectArrayBoundary( write, diff --git a/test/converters/conversionTriggerVariants.test.ts b/test/converters/conversionTriggerVariants.test.ts new file mode 100644 index 00000000..ee2ee56d --- /dev/null +++ b/test/converters/conversionTriggerVariants.test.ts @@ -0,0 +1,715 @@ +import { OcpErrorCodes, OcpParseError, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; +import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import type { ConvertibleConversionTrigger, PersistedWarrantExerciseTrigger } from '../../src/types/native'; +import { parseConversionTriggerFields } from '../../src/utils/conversionTriggers'; +import { requireFirst } from '../../src/utils/requireDefined'; + +const convertibleRight = { + type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, + conversion_mechanism: { + type: 'FIXED_AMOUNT_CONVERSION' as const, + converts_to_quantity: '100', + }, +}; + +const warrantRight = { + type: 'WARRANT_CONVERSION_RIGHT' as const, + conversion_mechanism: { + type: 'FIXED_AMOUNT_CONVERSION' as const, + converts_to_quantity: '100', + }, +}; + +const convertibleTriggerVariants: ConvertibleConversionTrigger[] = [ + { + type: 'AUTOMATIC_ON_CONDITION', + trigger_id: 'automatic-condition', + trigger_condition: 'Qualified financing closes', + conversion_right: convertibleRight, + }, + { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'automatic-date', + trigger_date: '2027-01-01', + conversion_right: convertibleRight, + }, + { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'elective-range', + start_date: '2027-01-01', + end_date: '2027-12-31', + conversion_right: convertibleRight, + }, + { + type: 'ELECTIVE_ON_CONDITION', + trigger_id: 'elective-condition', + trigger_condition: 'Holder elects after a liquidity event', + conversion_right: convertibleRight, + }, + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'elective-at-will', + conversion_right: convertibleRight, + }, + { + type: 'UNSPECIFIED', + trigger_id: 'unspecified', + conversion_right: convertibleRight, + }, +]; + +const warrantTriggerVariants: PersistedWarrantExerciseTrigger[] = convertibleTriggerVariants.map((trigger) => ({ + ...trigger, + conversion_right: warrantRight, +})); + +const convertibleBase = { + id: 'convertible-1', + date: '2026-07-09', + security_id: 'security-1', + custom_id: 'SAFE-1', + stakeholder_id: 'stakeholder-1', + security_law_exemptions: [], + investment_amount: { amount: '1000', currency: 'USD' }, + convertible_type: 'SAFE' as const, + seniority: 1, +}; + +const warrantBase = { + id: 'warrant-1', + date: '2026-07-09', + security_id: 'security-1', + custom_id: 'W-1', + stakeholder_id: 'stakeholder-1', + security_law_exemptions: [], + purchase_price: { amount: '1000', currency: 'USD' }, +}; + +function convertibleRangeTrigger(startDate: string, endDate: string): ConvertibleConversionTrigger { + return { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'elective-range', + start_date: startDate, + end_date: endDate, + conversion_right: convertibleRight, + }; +} + +function warrantRangeTrigger(startDate: string, endDate: string): PersistedWarrantExerciseTrigger { + return { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'elective-range', + start_date: startDate, + end_date: endDate, + conversion_right: warrantRight, + }; +} + +function expectValidationError(run: () => unknown, fieldPath: string, code: OcpErrorCode): void { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ fieldPath, code }); + return; + } + throw new Error(`Expected OcpValidationError at ${fieldPath}`); +} + +function expectParseError(run: () => unknown, source: string, code: OcpErrorCode): void { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ source, code }); + return; + } + throw new Error(`Expected OcpParseError at ${source}`); +} + +function expectDuplicateTriggerIdError( + run: () => unknown, + fieldPath: string, + code: OcpErrorCode, + triggerId: string, + firstIndex: number, + duplicateIndex: number +): void { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + fieldPath, + code, + receivedValue: triggerId, + context: expect.objectContaining({ triggerId, firstIndex, duplicateIndex }), + }); + expect((error as Error).message).toContain(`Duplicate trigger_id ${JSON.stringify(triggerId)}`); + return; + } + throw new Error(`Expected duplicate trigger_id validation at ${fieldPath}`); +} + +function requireFirstTwo(values: readonly T[], description: string): [T, T] { + return [requireFirst(values, `first ${description}`), requireFirst(values.slice(1), `second ${description}`)]; +} + +describe('exact conversion-trigger converter behavior', () => { + it('rejects a non-object trigger payload', () => { + expect(() => parseConversionTriggerFields(null, 'conversionTrigger')).toThrow( + /Conversion trigger must be an object/ + ); + }); + + it.each([ + { name: 'missing', value: undefined, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, + { name: 'null', value: null, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, + { name: 'non-string', value: 42, code: OcpErrorCodes.INVALID_TYPE }, + { name: 'empty', value: '', code: OcpErrorCodes.INVALID_FORMAT }, + { name: 'unknown', value: 'NOT_A_TRIGGER', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE }, + ])('rejects a $name trigger discriminator with exact taxonomy', ({ value, code }) => { + expectValidationError( + () => + parseConversionTriggerFields( + { + type: value, + trigger_id: 'invalid-type', + conversion_right: convertibleRight, + }, + 'conversionTrigger' + ), + 'conversionTrigger.type', + code + ); + }); + + it.each([ + { field: 'unexpected_field', value: 'not allowed', code: OcpErrorCodes.SCHEMA_MISMATCH }, + { field: 'trigger_date', value: undefined, code: OcpErrorCodes.INVALID_FORMAT }, + ])('rejects an own $field outside the canonical variant shape', ({ field, value, code }) => { + expectValidationError( + () => + parseConversionTriggerFields( + { + type: 'UNSPECIFIED', + trigger_id: 'strict-own-fields', + conversion_right: convertibleRight, + [field]: value, + }, + 'conversionTrigger.3' + ), + `conversionTrigger.3.${field}`, + code + ); + }); + + it.each([ + { + field: 'trigger_id', + trigger: { type: 'UNSPECIFIED', trigger_id: '', conversion_right: convertibleRight }, + }, + { + field: 'trigger_condition', + trigger: { + type: 'AUTOMATIC_ON_CONDITION', + trigger_id: 'empty-condition', + trigger_condition: '', + conversion_right: convertibleRight, + }, + }, + { + field: 'trigger_date', + trigger: { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'empty-date', + trigger_date: '', + conversion_right: convertibleRight, + }, + }, + { + field: 'start_date', + trigger: { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'empty-start', + start_date: '', + end_date: '2027-12-31', + conversion_right: convertibleRight, + }, + }, + { + field: 'end_date', + trigger: { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'empty-end', + start_date: '2027-01-01', + end_date: '', + conversion_right: convertibleRight, + }, + }, + ])('rejects an empty required $field', ({ field, trigger }) => { + expect(() => parseConversionTriggerFields(trigger, 'conversionTrigger')).toThrow( + new RegExp(`${field} is required and must be a non-empty string`) + ); + }); + + it.each(convertibleTriggerVariants)('round-trips convertible $type without synthesizing fields', (trigger) => { + const daml = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [trigger], + }); + const native = damlConvertibleIssuanceDataToNative(daml); + + expect(requireFirst(native.conversion_triggers, 'native convertible trigger')).toEqual(trigger); + }); + + it.each(warrantTriggerVariants)('round-trips warrant $type without synthesizing fields', (trigger) => { + const daml = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [trigger], + }); + const native = damlWarrantIssuanceDataToNative(daml); + + expect(requireFirst(native.exercise_triggers, 'native warrant trigger')).toEqual(trigger); + }); + + it('round-trips a convertible conversion right nested in a warrant trigger', () => { + const trigger: PersistedWarrantExerciseTrigger = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'warrant-convertible-right', + conversion_right: convertibleRight, + }; + + const daml = warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [trigger] }); + const native = damlWarrantIssuanceDataToNative(daml); + + expect(requireFirst(native.exercise_triggers, 'native warrant trigger with convertible right')).toEqual(trigger); + }); + + it('keeps the exact mechanism path for an invalid convertible right nested in a warrant trigger', () => { + const invalidTrigger = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'invalid-warrant-convertible-right', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '0' }, + }, + } as const; + + expectValidationError( + () => warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [invalidTrigger] }), + 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_mechanism.converts_to_quantity', + OcpErrorCodes.OUT_OF_RANGE + ); + }); + + it.each([ + { + family: 'convertible', + fieldPath: 'convertibleIssuance.conversion_triggers.1.trigger_id', + run: () => + convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [ + { ...convertibleTriggerVariants[0]!, trigger_id: 'shared-trigger-id' }, + { ...convertibleTriggerVariants[1]!, trigger_id: 'shared-trigger-id' }, + ], + }), + }, + { + family: 'warrant', + fieldPath: 'warrantIssuance.exercise_triggers.1.trigger_id', + run: () => + warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [ + { ...warrantTriggerVariants[0]!, trigger_id: 'shared-trigger-id' }, + { ...warrantTriggerVariants[1]!, trigger_id: 'shared-trigger-id' }, + ], + }), + }, + ] as const)('rejects duplicate $family writer trigger IDs across differing variants', ({ run, fieldPath }) => { + expectDuplicateTriggerIdError(run, fieldPath, OcpErrorCodes.INVALID_FORMAT, 'shared-trigger-id', 0, 1); + }); + + it.each([ + { + family: 'convertible', + fieldPath: 'convertibleIssuance.conversion_triggers.1.trigger_id', + run: () => { + const daml = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: requireFirstTwo(convertibleTriggerVariants, 'convertible trigger'), + }); + const [first, second] = requireFirstTwo(daml.conversion_triggers, 'DAML convertible trigger'); + second.trigger_id = first.trigger_id; + return damlConvertibleIssuanceDataToNative(daml); + }, + }, + { + family: 'warrant', + fieldPath: 'warrantIssuance.exercise_triggers.1.trigger_id', + run: () => { + const daml = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: warrantTriggerVariants.slice(0, 2), + }); + const [first, second] = requireFirstTwo(daml.exercise_triggers, 'DAML warrant trigger'); + second.trigger_id = first.trigger_id; + return damlWarrantIssuanceDataToNative(daml); + }, + }, + ] as const)('rejects duplicate $family reader trigger IDs across differing variants', ({ run, fieldPath }) => { + expectDuplicateTriggerIdError(run, fieldPath, OcpErrorCodes.SCHEMA_MISMATCH, 'automatic-condition', 0, 1); + }); + + it.each([ + { + family: 'convertible writer', + fieldPath: 'convertibleIssuance.conversion_triggers.0.end_date', + code: OcpErrorCodes.INVALID_FORMAT, + run: () => + convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [convertibleRangeTrigger('2028-12-31', '2028-01-01')], + }), + }, + { + family: 'warrant writer', + fieldPath: 'warrantIssuance.exercise_triggers.0.end_date', + code: OcpErrorCodes.INVALID_FORMAT, + run: () => + warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [warrantRangeTrigger('2028-12-31', '2028-01-01')], + }), + }, + { + family: 'convertible reader', + fieldPath: 'convertibleIssuance.conversion_triggers.0.end_date', + code: OcpErrorCodes.SCHEMA_MISMATCH, + run: () => { + const daml = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [convertibleRangeTrigger('2027-01-01', '2027-12-31')], + }); + const trigger = requireFirst(daml.conversion_triggers, 'DAML convertible trigger'); + trigger.start_date = '2028-12-31T00:00:00.000Z'; + trigger.end_date = '2028-01-01T00:00:00.000Z'; + return damlConvertibleIssuanceDataToNative(daml); + }, + }, + { + family: 'warrant reader', + fieldPath: 'warrantIssuance.exercise_triggers.0.end_date', + code: OcpErrorCodes.SCHEMA_MISMATCH, + run: () => { + const daml = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [warrantRangeTrigger('2027-01-01', '2027-12-31')], + }); + const trigger = requireFirst(daml.exercise_triggers, 'DAML warrant trigger'); + trigger.start_date = '2028-12-31T00:00:00.000Z'; + trigger.end_date = '2028-01-01T00:00:00.000Z'; + return damlWarrantIssuanceDataToNative(daml); + }, + }, + ] as const)('rejects a reversed ELECTIVE_IN_RANGE window at the $family boundary', ({ run, fieldPath, code }) => { + expectValidationError(run, fieldPath, code); + }); + + it.each([ + { name: 'ordered', startDate: '2027-01-01', endDate: '2027-12-31' }, + { name: 'single-day inclusive', startDate: '2027-06-15', endDate: '2027-06-15' }, + ] as const)('round-trips $name ranges for both trigger families', ({ startDate, endDate }) => { + const convertibleTrigger = convertibleRangeTrigger(startDate, endDate); + const warrantTrigger = warrantRangeTrigger(startDate, endDate); + + const convertible = damlConvertibleIssuanceDataToNative( + convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [convertibleTrigger] }) + ); + const warrant = damlWarrantIssuanceDataToNative( + warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [warrantTrigger] }) + ); + + expect(requireFirst(convertible.conversion_triggers, 'native convertible range trigger')).toEqual( + convertibleTrigger + ); + expect(requireFirst(warrant.exercise_triggers, 'native warrant range trigger')).toEqual(warrantTrigger); + }); + + it('rejects a cross-variant field at the convertible write boundary', () => { + const invalidTrigger = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'invalid-at-will', + trigger_date: '2027-01-01', + conversion_right: convertibleRight, + } as unknown as ConvertibleConversionTrigger; + + expect(() => convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [invalidTrigger] })).toThrow( + OcpValidationError + ); + expect(() => convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [invalidTrigger] })).toThrow( + /trigger_date is not valid for conversion trigger type ELECTIVE_AT_WILL/ + ); + }); + + it('rejects an unknown field at the indexed convertible write boundary', () => { + const invalidTrigger = { + ...requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger'), + unexpected_field: 'not allowed', + } as unknown as ConvertibleConversionTrigger; + + expectValidationError( + () => + convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [convertibleTriggerVariants[0]!, invalidTrigger], + }), + 'convertibleIssuance.conversion_triggers.1.unexpected_field', + OcpErrorCodes.SCHEMA_MISMATCH + ); + }); + + it('rejects a missing variant field at the warrant write boundary', () => { + const invalidTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'invalid-range', + start_date: '2027-01-01', + conversion_right: warrantRight, + } as unknown as PersistedWarrantExerciseTrigger; + + expect(() => warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [invalidTrigger] })).toThrow( + /end_date is required and must be a string/ + ); + }); + + it('rejects an unknown field at the indexed warrant write boundary', () => { + const invalidTrigger = { + ...requireFirst(warrantTriggerVariants.slice(4, 5), 'at-will warrant trigger'), + unexpected_field: 'not allowed', + } as unknown as PersistedWarrantExerciseTrigger; + + expectValidationError( + () => + warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [warrantTriggerVariants[0]!, invalidTrigger], + }), + 'warrantIssuance.exercise_triggers.1.unexpected_field', + OcpErrorCodes.SCHEMA_MISMATCH + ); + }); + + it('rejects OCF nulls instead of treating them as omitted write fields', () => { + const invalidTrigger = { + type: 'UNSPECIFIED', + trigger_id: 'null-nickname', + nickname: null, + conversion_right: convertibleRight, + } as unknown as ConvertibleConversionTrigger; + + expect(() => convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [invalidTrigger] })).toThrow( + /nickname must be a string when present/ + ); + }); + + it.each([ + { field: 'nickname', value: '' }, + { field: 'nickname', value: ' ' }, + { field: 'trigger_description', value: '' }, + { field: 'trigger_description', value: ' ' }, + ] as const)('rejects blank $field values at both write boundaries', ({ field, value }) => { + const convertibleTrigger = { + ...requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger'), + [field]: value, + }; + const warrantTrigger = { + ...requireFirst(warrantTriggerVariants.slice(4, 5), 'at-will warrant trigger'), + [field]: value, + }; + + expectValidationError( + () => convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [convertibleTrigger] }), + `convertibleIssuance.conversion_triggers.0.${field}`, + OcpErrorCodes.INVALID_FORMAT + ); + expectValidationError( + () => warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [warrantTrigger] }), + `warrantIssuance.exercise_triggers.0.${field}`, + OcpErrorCodes.INVALID_FORMAT + ); + }); + + it('rejects a missing conversion right at the write boundary', () => { + const invalidTrigger = { + type: 'UNSPECIFIED', + trigger_id: 'missing-right', + } as unknown as ConvertibleConversionTrigger; + + expect(() => convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [invalidTrigger] })).toThrow( + /conversion_right is required/ + ); + }); + + it('rejects a non-null cross-variant field from a DAML convertible payload', () => { + const daml = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger')], + }); + requireFirst(daml.conversion_triggers, 'DAML convertible trigger').trigger_condition = 'not allowed'; + + expect(() => damlConvertibleIssuanceDataToNative(daml)).toThrow( + /trigger_condition is not allowed for ELECTIVE_AT_WILL triggers/ + ); + }); + + it('rejects an unknown field from an indexed DAML convertible payload as a schema mismatch', () => { + const daml = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: requireFirstTwo(convertibleTriggerVariants, 'convertible trigger'), + }); + const trigger = requireFirst(daml.conversion_triggers.slice(1), 'second DAML convertible trigger'); + (trigger as unknown as Record).unexpected_field = 'not generated by DAML'; + + expectValidationError( + () => damlConvertibleIssuanceDataToNative(daml), + 'convertibleIssuance.conversion_triggers.1.unexpected_field', + OcpErrorCodes.SCHEMA_MISMATCH + ); + }); + + it('keeps indexed context for malformed DAML convertible rights and mechanisms', () => { + const malformedRight = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: requireFirstTwo(convertibleTriggerVariants, 'convertible trigger'), + }); + const secondRightTrigger = requireFirst( + malformedRight.conversion_triggers.slice(1), + 'second DAML convertible trigger' + ); + secondRightTrigger.conversion_right.type_ = 'INVALID_RIGHT'; + + expectValidationError( + () => damlConvertibleIssuanceDataToNative(malformedRight), + 'convertibleIssuance.conversion_triggers.1.conversion_right.type_', + OcpErrorCodes.INVALID_FORMAT + ); + + const malformedMechanism = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: requireFirstTwo(convertibleTriggerVariants, 'convertible trigger'), + }); + const secondMechanismTrigger = requireFirst( + malformedMechanism.conversion_triggers.slice(1), + 'second DAML convertible trigger' + ); + secondMechanismTrigger.conversion_right.conversion_mechanism = { + tag: 'INVALID_MECHANISM', + value: {}, + } as never; + + expectParseError( + () => damlConvertibleIssuanceDataToNative(malformedMechanism), + 'convertibleIssuance.conversion_triggers.1.conversion_right.conversion_mechanism.tag', + OcpErrorCodes.UNKNOWN_ENUM_VALUE + ); + }); + + it('rejects a missing trigger_id from a DAML warrant payload instead of synthesizing one', () => { + const daml = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [requireFirst(warrantTriggerVariants, 'warrant trigger')], + }); + requireFirst(daml.exercise_triggers, 'DAML warrant trigger').trigger_id = null as unknown as string; + + expectValidationError( + () => damlWarrantIssuanceDataToNative(daml), + 'warrantIssuance.exercise_triggers.0.trigger_id', + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); + }); + + it('rejects an unknown field from an indexed DAML warrant payload as a schema mismatch', () => { + const daml = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: warrantTriggerVariants.slice(0, 2), + }); + const trigger = requireFirst(daml.exercise_triggers.slice(1), 'second DAML warrant trigger'); + (trigger as unknown as Record).unexpected_field = 'not generated by DAML'; + + expectValidationError( + () => damlWarrantIssuanceDataToNative(daml), + 'warrantIssuance.exercise_triggers.1.unexpected_field', + OcpErrorCodes.SCHEMA_MISMATCH + ); + }); + + it('keeps indexed context for malformed DAML warrant rights and mechanisms', () => { + const malformedRight = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: warrantTriggerVariants.slice(0, 2), + }); + const secondRightTrigger = requireFirst(malformedRight.exercise_triggers.slice(1), 'second DAML warrant trigger'); + const secondRight = secondRightTrigger.conversion_right as { value: Record }; + secondRight.value.type_ = 'INVALID_RIGHT'; + + expectValidationError( + () => damlWarrantIssuanceDataToNative(malformedRight), + 'warrantIssuance.exercise_triggers.1.conversion_right.value.type_', + OcpErrorCodes.INVALID_FORMAT + ); + + const malformedMechanism = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: warrantTriggerVariants.slice(0, 2), + }); + const secondMechanismTrigger = requireFirst( + malformedMechanism.exercise_triggers.slice(1), + 'second DAML warrant trigger' + ); + const secondMechanismRight = secondMechanismTrigger.conversion_right as { value: Record }; + secondMechanismRight.value.conversion_mechanism = { tag: 'INVALID_MECHANISM', value: {} }; + + expectParseError( + () => damlWarrantIssuanceDataToNative(malformedMechanism), + 'warrantIssuance.exercise_triggers.1.conversion_right.value.conversion_mechanism.tag', + OcpErrorCodes.UNKNOWN_ENUM_VALUE + ); + }); + + it.each([ + { field: 'nickname', value: '' }, + { field: 'nickname', value: ' ' }, + { field: 'trigger_description', value: '' }, + { field: 'trigger_description', value: ' ' }, + ] as const)('rejects blank $field values at both read boundaries', ({ field, value }) => { + const convertibleDaml = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger')], + }); + const convertibleTrigger = requireFirst(convertibleDaml.conversion_triggers, 'DAML convertible trigger'); + (convertibleTrigger as unknown as Record)[field] = value; + + const warrantDaml = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [requireFirst(warrantTriggerVariants.slice(4, 5), 'at-will warrant trigger')], + }); + const warrantTrigger = requireFirst(warrantDaml.exercise_triggers, 'DAML warrant trigger'); + (warrantTrigger as unknown as Record)[field] = value; + + expectValidationError( + () => damlConvertibleIssuanceDataToNative(convertibleDaml), + `convertibleIssuance.conversion_triggers.0.${field}`, + OcpErrorCodes.INVALID_FORMAT + ); + expectValidationError( + () => damlWarrantIssuanceDataToNative(warrantDaml), + `warrantIssuance.exercise_triggers.0.${field}`, + OcpErrorCodes.INVALID_FORMAT + ); + }); +}); diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 8c45cfb0..58e2272a 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -14,6 +14,7 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import type { ConvertibleConversionTrigger } from '../../src/types/native'; import { requireFirst } from '../../src/utils/requireDefined'; import { expectInvalidDate } from '../utils/dateValidationAssertions'; import { loadProductionFixture } from '../utils/productionFixtures'; @@ -44,6 +45,27 @@ const SAFE_TRIGGER_BASE = { }, }; +type TriggerDateField = 'trigger_date' | 'start_date' | 'end_date'; + +function triggerTimingWithField(field: TriggerDateField, value: unknown): Record { + if (field === 'trigger_date') { + return { type: 'AUTOMATIC_ON_DATE', trigger_date: value }; + } + return { + type: 'ELECTIVE_IN_RANGE', + start_date: field === 'start_date' ? value : '2024-01-01', + end_date: field === 'end_date' ? value : '2024-12-31', + }; +} + +function convertibleTriggerWithDateField(field: TriggerDateField, value: unknown): ConvertibleConversionTrigger { + const trigger: unknown = { + ...SAFE_TRIGGER_BASE, + ...triggerTimingWithField(field, value), + }; + return trigger as ConvertibleConversionTrigger; +} + function noteInterestRatePath(triggerIndex = 0, interestRateIndex = 0): string { return ( `convertibleIssuance.conversion_triggers.${triggerIndex}.conversion_right.` + @@ -757,6 +779,22 @@ function buildDamlSafeTrigger(conversionTiming?: string) { }; } +function buildDamlSafeTriggerWithDateField(field: TriggerDateField, value: unknown) { + if (field === 'trigger_date') { + return { + ...buildDamlSafeTrigger(), + type_: 'OcfTriggerTypeTypeAutomaticOnDate', + trigger_date: value, + }; + } + return { + ...buildDamlSafeTrigger(), + type_: 'OcfTriggerTypeTypeElectiveInRange', + start_date: field === 'start_date' ? value : '2024-01-01', + end_date: field === 'end_date' ? value : '2024-12-31', + }; +} + describe('convertible issuance required read taxonomy', () => { test.each([ ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], @@ -1428,6 +1466,24 @@ describe('convertible issuance approval-date read boundaries', () => { ); }); + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'rejects a present non-string conversion trigger %s', + (field) => { + const invalidDate = { seconds: 1 }; + + expectInvalidDate( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [buildDamlSafeTriggerWithDateField(field, invalidDate)], + }), + `convertibleIssuance.conversion_triggers.0.${field}`, + invalidDate, + OcpErrorCodes.INVALID_TYPE + ); + } + ); + it('decodes only the required ELECTIVE_IN_RANGE start and end dates', () => { const trigger = { ...buildDamlSafeTrigger(), @@ -1459,6 +1515,39 @@ describe('convertible issuance approval-date read boundaries', () => { ); }); + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'rejects a present empty conversion trigger %s', + (field) => { + expectInvalidDate( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [buildDamlSafeTriggerWithDateField(field, '')], + }), + `convertibleIssuance.conversion_triggers.0.${field}`, + '', + OcpErrorCodes.INVALID_FORMAT + ); + } + ); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'omits a null or absent conversion trigger %s', + (field) => { + const withNull = damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [{ ...buildDamlSafeTrigger(), [field]: null }], + }).conversion_triggers[0] as unknown as Record; + const withoutField = damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [buildDamlSafeTrigger()], + }).conversion_triggers[0] as unknown as Record; + + expect(withNull[field]).toBeUndefined(); + expect(withoutField[field]).toBeUndefined(); + } + ); + test.each([ ['empty', '', OcpErrorCodes.INVALID_FORMAT], ['non-string', { seconds: 1 }, OcpErrorCodes.INVALID_TYPE], @@ -1776,7 +1865,7 @@ describe('convertible issuance write field boundaries', () => { convertibleIssuanceDataToDaml({ ...BASE_INPUT, conversion_triggers: [ - { ...SAFE_TRIGGER_BASE, trigger_date: '2024-01-15' } as unknown as typeof SAFE_TRIGGER_BASE, + { ...SAFE_TRIGGER_BASE, trigger_date: '2024-01-15' } as unknown as ConvertibleConversionTrigger, ], }), 'convertibleIssuance.conversion_triggers.0.trigger_date', @@ -1785,6 +1874,51 @@ describe('convertible issuance write field boundaries', () => { ); }); + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'validates a present conversion trigger %s instead of treating a falsy value as absent', + (field) => { + expectInvalidDate( + () => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [convertibleTriggerWithDateField(field, '')], + }), + `convertibleIssuance.conversion_triggers.0.${field}`, + '' + ); + } + ); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'rejects invalid required conversion trigger %s values', + (field) => { + const invalidDate = { seconds: 1 }; + expectInvalidDate( + () => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [convertibleTriggerWithDateField(field, invalidDate)], + }), + `convertibleIssuance.conversion_triggers.0.${field}`, + invalidDate, + OcpErrorCodes.INVALID_TYPE + ); + + for (const value of [null, undefined]) { + expectInvalidDate( + () => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [convertibleTriggerWithDateField(field, value)], + }), + `convertibleIssuance.conversion_triggers.0.${field}`, + value, + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); + } + } + ); + test.each([ ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index ad7424e6..167f4101 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -53,6 +53,8 @@ function captureValidationError(action: () => unknown): OcpValidationError { } describe('WarrantIssuance round-trip equivalence', () => { + type TriggerDateField = 'trigger_date' | 'start_date' | 'end_date'; + const baseWarrantIssuance = { id: '4afe6226-a717-4596-8bcc-fa3c22b154de', date: '2022-01-14', @@ -83,6 +85,24 @@ describe('WarrantIssuance round-trip equivalence', () => { }; const baseExerciseTrigger = requireFirst(baseWarrantIssuance.exercise_triggers, 'base warrant exercise trigger'); + function triggerTimingWithField(field: TriggerDateField, value: unknown): Record { + if (field === 'trigger_date') { + return { type: 'AUTOMATIC_ON_DATE', trigger_date: value }; + } + return { + type: 'ELECTIVE_IN_RANGE', + start_date: field === 'start_date' ? value : '2024-01-01', + end_date: field === 'end_date' ? value : '2024-12-31', + }; + } + + function warrantTriggerWithDateField(field: TriggerDateField, value: unknown): PersistedWarrantExerciseTrigger { + return { + trigger_id: baseExerciseTrigger.trigger_id, + conversion_right: baseExerciseTrigger.conversion_right, + ...triggerTimingWithField(field, value), + } as unknown as PersistedWarrantExerciseTrigger; + } function stockClassTrigger(overrides: Record = {}): PersistedWarrantExerciseTrigger { const triggerType = (overrides.type ?? 'AUTOMATIC_ON_CONDITION') as WarrantTriggerTypeInput; const trigger = { @@ -105,6 +125,37 @@ describe('WarrantIssuance round-trip equivalence', () => { : trigger) as unknown as PersistedWarrantExerciseTrigger; } + function stockClassTriggerWithTiming(timing: Record): PersistedWarrantExerciseTrigger { + const base = stockClassTrigger(); + return { + trigger_id: base.trigger_id, + conversion_right: base.conversion_right, + ...timing, + } as unknown as PersistedWarrantExerciseTrigger; + } + + function stockClassTriggerWithDateField(field: TriggerDateField, value: unknown): PersistedWarrantExerciseTrigger { + return stockClassTriggerWithTiming(triggerTimingWithField(field, value)); + } + + function stockClassPayloadWithNestedTrigger(trigger = stockClassTrigger()): { + payload: Record; + nested: Record; + stockClassRight: Record; + } { + const daml = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [trigger], + }); + const payload = JSON.parse(JSON.stringify(daml)) as Record; + const triggers = payload.exercise_triggers as Array>; + const outer = requireFirst(triggers, 'serialized stock-class warrant trigger'); + const right = outer.conversion_right as { value: Record }; + const stockClassRight = right.value; + const nested = stockClassRight.conversion_trigger as Record; + return { payload, nested, stockClassRight }; + } + function expectInvalidLedgerMonetary(convert: () => unknown, fieldPath: string, receivedValue: unknown): void { try { convert(); @@ -1209,6 +1260,73 @@ describe('WarrantIssuance round-trip equivalence', () => { ); }); + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'rejects a present non-string exercise trigger %s on readback', + (field) => { + const invalidDate = { seconds: 1 }; + const daml = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [warrantTriggerWithDateField(field, '2024-01-15')], + }); + const trigger = daml.exercise_triggers[0]; + + expectInvalidWarrantDate( + () => + damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [{ ...trigger, [field]: invalidDate }], + }), + `warrantIssuance.exercise_triggers.0.${field}`, + invalidDate, + OcpErrorCodes.INVALID_TYPE + ); + } + ); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'rejects a present empty exercise trigger %s on readback', + (field) => { + const daml = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [warrantTriggerWithDateField(field, '2024-01-15')], + }); + const trigger = daml.exercise_triggers[0]; + + expectInvalidWarrantDate( + () => + damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [{ ...trigger, [field]: '' }], + }), + `warrantIssuance.exercise_triggers.0.${field}`, + '', + OcpErrorCodes.INVALID_FORMAT + ); + } + ); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'omits a null or absent exercise trigger %s on readback', + (field) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const trigger = { ...daml.exercise_triggers[0] } as unknown as Record; + const absentTrigger = { ...trigger }; + delete absentTrigger[field]; + + const withNull = damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [{ ...trigger, [field]: null }], + }).exercise_triggers[0] as unknown as Record; + const withoutField = damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [absentTrigger], + }).exercise_triggers[0] as unknown as Record; + + expect(withNull[field]).toBeUndefined(); + expect(withoutField[field]).toBeUndefined(); + } + ); + test.each(['board_approval_date', 'stockholder_approval_date', 'warrant_expiration_date'] as const)( 'enforces optional write boundary semantics for %s', (field) => { @@ -1250,57 +1368,299 @@ describe('WarrantIssuance round-trip equivalence', () => { () => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, - exercise_triggers: [{ ...baseExerciseTrigger, trigger_date: '2024-01-15' }], - } as never), + exercise_triggers: [ + { ...baseExerciseTrigger, trigger_date: '2024-01-15' } as unknown as PersistedWarrantExerciseTrigger, + ], + }), 'warrantIssuance.exercise_triggers.0.trigger_date', '2024-01-15', OcpErrorCodes.INVALID_FORMAT ); }); - test('uses identical canonical AUTOMATIC_ON_DATE semantics for outer and nested stock-class triggers', () => { - const result = warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - exercise_triggers: [ - stockClassTrigger({ + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'enforces required outer trigger write boundary semantics for %s', + (field) => { + const fieldPath = `warrantIssuance.exercise_triggers.0.${field}`; + const invalidDate = { seconds: 1 }; + + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [warrantTriggerWithDateField(field, '')], + }), + fieldPath, + '' + ); + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [warrantTriggerWithDateField(field, invalidDate)], + }), + fieldPath, + invalidDate, + OcpErrorCodes.INVALID_TYPE + ); + + for (const value of [null, undefined]) { + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [warrantTriggerWithDateField(field, value)], + }), + fieldPath, + value, + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); + } + } + ); + + test('uses identical canonical date semantics for outer and nested stock-class triggers', () => { + const cases = [ + { + trigger: stockClassTriggerWithTiming({ type: 'AUTOMATIC_ON_DATE', trigger_date: '2024-01-15T23:30:00-05:00', }), - ], - }); - const outer = result.exercise_triggers[0] as unknown as Record; - const right = outer.conversion_right as { value: { conversion_trigger: Record } }; - const nested = right.value.conversion_trigger; - - expect(outer).toMatchObject({ trigger_date: '2024-01-15T00:00:00.000Z', start_date: null, end_date: null }); - expect(nested).toMatchObject({ trigger_date: '2024-01-15T00:00:00.000Z', start_date: null, end_date: null }); - }); - - test('uses identical canonical ELECTIVE_IN_RANGE semantics for outer and nested stock-class triggers', () => { - const result = warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - exercise_triggers: [ - stockClassTrigger({ + expected: { + trigger_date: '2024-01-15T00:00:00.000Z', + trigger_condition: null, + start_date: null, + end_date: null, + }, + }, + { + trigger: stockClassTriggerWithTiming({ type: 'ELECTIVE_IN_RANGE', start_date: '2024-01-15T00:30:00+14:00', - end_date: '2024-02-15T23:30:00-05:00', + end_date: '2024-01-15T12:00:00Z', }), - ], - }); - const outer = result.exercise_triggers[0] as unknown as Record; - const right = outer.conversion_right as { value: { conversion_trigger: Record } }; - const nested = right.value.conversion_trigger; + expected: { + trigger_date: null, + trigger_condition: null, + start_date: '2024-01-15T00:00:00.000Z', + end_date: '2024-01-15T00:00:00.000Z', + }, + }, + ]; - expect(outer).toMatchObject({ - trigger_date: null, - start_date: '2024-01-15T00:00:00.000Z', - end_date: '2024-02-15T00:00:00.000Z', - }); - expect(nested).toMatchObject({ - trigger_date: null, - start_date: '2024-01-15T00:00:00.000Z', - end_date: '2024-02-15T00:00:00.000Z', - }); + for (const { trigger, expected } of cases) { + const result = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [trigger], + }); + const outer = result.exercise_triggers[0] as unknown as Record; + const right = outer.conversion_right as { value: { conversion_trigger: Record } }; + const nested = right.value.conversion_trigger; + + expect(outer).toMatchObject(expected); + expect(nested).toMatchObject(expected); + } + }); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'validates nested stock-class trigger %s before serialization', + (field) => { + const invalidDate = { seconds: 1 }; + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [stockClassTriggerWithDateField(field, invalidDate)], + }), + `warrantIssuance.exercise_triggers.0.${field}`, + invalidDate, + OcpErrorCodes.INVALID_TYPE + ); + } + ); + + test.each([ + { + name: 'trigger identity drift', + mutate: (nested: Record) => { + nested.trigger_id = 'different-trigger'; + }, + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.trigger_id', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }, + { + name: 'an unknown field', + mutate: (nested: Record) => { + nested.unexpected_field = 'not generated by DAML'; + }, + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.unexpected_field', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }, + { + name: 'target drift', + mutate: (nested: Record) => { + const right = nested.conversion_right as { value: Record }; + right.value.converts_to_stock_class_id = 'different-stock-class'; + }, + fieldPath: + 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.value.converts_to_stock_class_id', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }, + { + name: 'placeholder right tag drift', + mutate: (nested: Record) => { + const right = nested.conversion_right as Record; + right.tag = 'OcfRightWarrant'; + }, + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.tag', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }, + { + name: 'placeholder right type drift', + mutate: (nested: Record) => { + const right = nested.conversion_right as { value: Record }; + right.value.type_ = 'WARRANT_CONVERSION_RIGHT'; + }, + fieldPath: + 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.value.type_', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }, + { + name: 'future-round placeholder drift', + mutate: (nested: Record) => { + const right = nested.conversion_right as { value: Record }; + right.value.converts_to_future_round = true; + }, + fieldPath: + 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.value.converts_to_future_round', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }, + { + name: 'placeholder mechanism drift', + mutate: (nested: Record) => { + const right = nested.conversion_right as { value: Record }; + right.value.conversion_mechanism = { + tag: 'OcfConvMechFixedAmount', + value: { converts_to_quantity: '1' }, + }; + }, + fieldPath: + 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.tag', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }, + { + name: 'placeholder description drift', + mutate: (nested: Record) => { + const right = nested.conversion_right as { value: Record }; + const mechanism = right.value.conversion_mechanism as { value: Record }; + mechanism.value.custom_conversion_description = 'Different placeholder'; + }, + fieldPath: + 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.value.custom_conversion_description', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }, + { + name: 'an unknown placeholder-right variant field', + mutate: (nested: Record) => { + const right = nested.conversion_right as Record; + right.unexpected_field = true; + }, + fieldPath: + 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.unexpected_field', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }, + { + name: 'an unknown placeholder-right value field', + mutate: (nested: Record) => { + const right = nested.conversion_right as { value: Record }; + right.value.unexpected_field = true; + }, + fieldPath: + 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.value.unexpected_field', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }, + { + name: 'an unknown placeholder-mechanism field', + mutate: (nested: Record) => { + const right = nested.conversion_right as { value: Record }; + const mechanism = right.value.conversion_mechanism as Record; + mechanism.unexpected_field = true; + }, + fieldPath: + 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.unexpected_field', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }, + { + name: 'an unknown placeholder-mechanism value field', + mutate: (nested: Record) => { + const right = nested.conversion_right as { value: Record }; + const mechanism = right.value.conversion_mechanism as { value: Record }; + mechanism.value.unexpected_field = true; + }, + fieldPath: + 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.value.unexpected_field', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }, + ])('rejects nested stock-class storage trigger corruption: $name', ({ mutate, fieldPath, code }) => { + const { payload, nested } = stockClassPayloadWithNestedTrigger(); + mutate(nested); + + try { + damlWarrantIssuanceDataToNative(payload); + throw new Error('Expected nested stock-class trigger validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ code, fieldPath }); + } + }); + + test('treats an unknown private nested stock-class trigger discriminator as storage-shape drift', () => { + const { payload, nested } = stockClassPayloadWithNestedTrigger(); + nested.type_ = 'bad-trigger-type'; + + try { + damlWarrantIssuanceDataToNative(payload); + throw new Error('Expected nested stock-class trigger discriminator validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.type_', + receivedValue: 'bad-trigger-type', + }); + } + }); + + test('rejects a missing nested stock-class storage trigger record', () => { + const { payload, stockClassRight } = stockClassPayloadWithNestedTrigger(); + stockClassRight.conversion_trigger = null; + + try { + damlWarrantIssuanceDataToNative(payload); + throw new Error('Expected the missing nested stock-class storage trigger to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger', + receivedValue: null, + }); + } + }); + + test('treats a malformed private nested stock-class trigger date as storage-shape drift', () => { + const { payload, nested } = stockClassPayloadWithNestedTrigger( + stockClassTriggerWithTiming({ type: 'AUTOMATIC_ON_DATE', trigger_date: '2024-01-15' }) + ); + nested.trigger_date = 'definitely-not-a-date'; + + expect(() => damlWarrantIssuanceDataToNative(payload)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.trigger_date', + receivedValue: 'definitely-not-a-date', + }) + ); }); test('STOCK_CLASS_CONVERSION_RIGHT rejects non-NORMAL rounding_type (not persisted in DAML)', () => { diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index efcbad54..da009194 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -27,7 +27,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { { type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_id: 't1', - trigger_date: '2024-01-15T00:00:00Z', + trigger_date: '2025-01-01T00:00:00Z', conversion_right: { type_: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: { @@ -68,7 +68,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { { type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_id: 't1', - trigger_date: '2024-01-15T00:00:00Z', + trigger_date: '2025-01-01T00:00:00Z', conversion_right: { type_: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: { diff --git a/test/declarations/conversionTriggers.types.ts b/test/declarations/conversionTriggers.types.ts new file mode 100644 index 00000000..f1734579 --- /dev/null +++ b/test/declarations/conversionTriggers.types.ts @@ -0,0 +1,137 @@ +/** Built-declaration contracts for the six exact canonical conversion-trigger variants. */ + +import type { + ConvertibleConversionRight, + ConvertibleConversionTrigger, + WarrantConversionRight, + WarrantExerciseTrigger, +} from '../../dist'; + +const convertibleRight: ConvertibleConversionRight = { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: '100', + }, +}; + +const warrantRight: WarrantConversionRight = { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: '100', + }, +}; + +const convertibleTriggers: ConvertibleConversionTrigger[] = [ + { + type: 'AUTOMATIC_ON_CONDITION', + trigger_id: 'automatic-condition', + trigger_condition: 'Qualified financing closes', + conversion_right: convertibleRight, + }, + { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'automatic-date', + trigger_date: '2027-01-01', + conversion_right: convertibleRight, + }, + { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'elective-range', + start_date: '2027-01-01', + end_date: '2027-12-31', + conversion_right: convertibleRight, + }, + { + type: 'ELECTIVE_ON_CONDITION', + trigger_id: 'elective-condition', + trigger_condition: 'Holder elects after a liquidity event', + conversion_right: convertibleRight, + }, + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'elective-at-will', + conversion_right: convertibleRight, + }, + { + type: 'UNSPECIFIED', + trigger_id: 'unspecified', + conversion_right: convertibleRight, + }, +]; + +const warrantTrigger: WarrantExerciseTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'warrant-date', + trigger_date: '2027-06-01', + conversion_right: warrantRight, +}; + +// Inclusive single-day ranges remain valid in the published declarations. +const equalDateRangeTrigger: WarrantExerciseTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'single-day-range', + start_date: '2027-06-01', + end_date: '2027-06-01', + conversion_right: warrantRight, +}; + +// @ts-expect-error built condition variants require trigger_condition +const missingCondition: ConvertibleConversionTrigger = { + type: 'AUTOMATIC_ON_CONDITION', + trigger_id: 'missing-condition', + conversion_right: convertibleRight, +}; + +// @ts-expect-error built condition variants forbid date fields +const conditionWithDate: ConvertibleConversionTrigger = { + type: 'ELECTIVE_ON_CONDITION', + trigger_id: 'condition-with-date', + trigger_condition: 'Qualified financing closes', + trigger_date: '2027-01-01', + conversion_right: convertibleRight, +}; + +// @ts-expect-error built automatic date variants require trigger_date +const missingTriggerDate: ConvertibleConversionTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'missing-date', + conversion_right: convertibleRight, +}; + +// @ts-expect-error built automatic date variants forbid conditions +const dateWithCondition: WarrantExerciseTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'date-with-condition', + trigger_date: '2027-01-01', + trigger_condition: 'Not valid on this variant', + conversion_right: warrantRight, +}; + +// @ts-expect-error built range variants require both boundaries +const incompleteRange: ConvertibleConversionTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'incomplete-range', + start_date: '2027-01-01', + conversion_right: convertibleRight, +}; + +// @ts-expect-error built payloadless variants forbid every timing field +const atWillWithRange: WarrantExerciseTrigger = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'at-will-with-range', + start_date: '2027-01-01', + end_date: '2027-12-31', + conversion_right: warrantRight, +}; + +void convertibleTriggers; +void warrantTrigger; +void equalDateRangeTrigger; +void missingCondition; +void conditionWithDate; +void missingTriggerDate; +void dateWithCondition; +void incompleteRange; +void atWillWithRange; diff --git a/test/functions/factory/createFactory.test.ts b/test/functions/factory/createFactory.test.ts index 2bca998e..2ec63156 100644 --- a/test/functions/factory/createFactory.test.ts +++ b/test/functions/factory/createFactory.test.ts @@ -217,10 +217,13 @@ describe('createFactory', () => { fieldPath: 'createFactory.loger', }); await expect( - createFactory(mockClient as unknown as LedgerJsonApiClient, { - systemOperator, - templateId: undefined, - }) + createFactory( + mockClient as unknown as LedgerJsonApiClient, + { + systemOperator, + templateId: undefined, + } as never + ) ).rejects.toMatchObject({ name: 'OcpValidationError', fieldPath: 'createFactory.templateId', diff --git a/test/integration/entities/acceptanceTypes.integration.test.ts b/test/integration/entities/acceptanceTypes.integration.test.ts index d27d132d..3eb2a0a3 100644 --- a/test/integration/entities/acceptanceTypes.integration.test.ts +++ b/test/integration/entities/acceptanceTypes.integration.test.ts @@ -33,6 +33,7 @@ import { setupStockSecurity, setupTestIssuer, setupWarrantSecurity, + withCapTableContractDetails, } from '../utils'; /** @@ -141,7 +142,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -189,7 +190,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: warrantSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -239,7 +240,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: convertibleSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -287,7 +288,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: eqCompSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -336,7 +337,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { const warrantSecurity = await setupWarrantSecurity(ctx.ocp, { issuerContractId: stockSecurity.capTableContractId, issuerParty: ctx.issuerParty, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), }); events = await ctx.ocp.ledger.getEventsByContractId({ contractId: warrantSecurity.capTableContractId }); @@ -353,7 +354,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { const convertibleSecurity = await setupConvertibleSecurity(ctx.ocp, { issuerContractId: warrantSecurity.capTableContractId, issuerParty: ctx.issuerParty, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), }); events = await ctx.ocp.ledger.getEventsByContractId({ contractId: convertibleSecurity.capTableContractId }); @@ -370,7 +371,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { const eqCompSecurity = await setupEquityCompensationSecurity(ctx.ocp, { issuerContractId: convertibleSecurity.capTableContractId, issuerParty: ctx.issuerParty, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), }); events = await ctx.ocp.ledger.getEventsByContractId({ contractId: eqCompSecurity.capTableContractId }); @@ -402,7 +403,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: eqCompSecurity.capTableContractId, - capTableContractDetails: finalCapTableDetails, + ...withCapTableContractDetails(finalCapTableDetails), actAs: [ctx.issuerParty], }); @@ -459,7 +460,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -512,7 +513,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { // Create the acceptance first const createBatch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), actAs: [ctx.issuerParty], }); @@ -594,7 +595,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { // Create the acceptance first const createBatch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), actAs: [ctx.issuerParty], }); diff --git a/test/integration/entities/stockClassAdjustments.integration.test.ts b/test/integration/entities/stockClassAdjustments.integration.test.ts index 92e1fab6..96854030 100644 --- a/test/integration/entities/stockClassAdjustments.integration.test.ts +++ b/test/integration/entities/stockClassAdjustments.integration.test.ts @@ -27,6 +27,7 @@ import { requireCreatedEventBlob, setupStockSecurity, setupTestIssuer, + withCapTableContractDetails, } from '../utils'; createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { @@ -178,7 +179,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { const stockSecurity2 = await setupStockSecurity(ctx.ocp, { issuerContractId: stockSecurity1.capTableContractId, issuerParty: ctx.issuerParty, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), }); events = await ctx.ocp.ledger.getEventsByContractId({ contractId: stockSecurity2.capTableContractId }); @@ -194,7 +195,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { const stockSecurity3 = await setupStockSecurity(ctx.ocp, { issuerContractId: stockSecurity2.capTableContractId, issuerParty: ctx.issuerParty, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), }); events = await ctx.ocp.ledger.getEventsByContractId({ contractId: stockSecurity3.capTableContractId }); @@ -212,7 +213,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity3.capTableContractId, - capTableContractDetails: finalCapTableDetails, + ...withCapTableContractDetails(finalCapTableDetails), actAs: [ctx.issuerParty], }); @@ -269,7 +270,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -325,7 +326,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { const stockSecurity2 = await setupStockSecurity(ctx.ocp, { issuerContractId: stockSecurity1.capTableContractId, issuerParty: ctx.issuerParty, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), }); events = await ctx.ocp.ledger.getEventsByContractId({ contractId: stockSecurity2.capTableContractId }); @@ -341,7 +342,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { const stockSecurity3 = await setupStockSecurity(ctx.ocp, { issuerContractId: stockSecurity2.capTableContractId, issuerParty: ctx.issuerParty, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), }); events = await ctx.ocp.ledger.getEventsByContractId({ contractId: stockSecurity3.capTableContractId }); @@ -356,7 +357,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity3.capTableContractId, - capTableContractDetails: finalCapTableDetails, + ...withCapTableContractDetails(finalCapTableDetails), actAs: [ctx.issuerParty], }); diff --git a/test/integration/entities/transferTypes.integration.test.ts b/test/integration/entities/transferTypes.integration.test.ts index b72a8d38..9381ddd7 100644 --- a/test/integration/entities/transferTypes.integration.test.ts +++ b/test/integration/entities/transferTypes.integration.test.ts @@ -34,6 +34,7 @@ import { setupStockSecurity, setupTestIssuer, setupWarrantSecurity, + withCapTableContractDetails, } from '../utils'; /** Extract a contract ID from a transaction tree response. */ @@ -107,7 +108,7 @@ createIntegrationTestSuite('Transfer Type operations', (getContext) => { const cmd = buildUpdateCapTableCommand( { capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), }, { creates: [{ type: 'stockTransfer', data: transferData }] } ); @@ -185,7 +186,7 @@ createIntegrationTestSuite('Transfer Type operations', (getContext) => { const cmd = buildUpdateCapTableCommand( { capTableContractId: convertibleSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), }, { creates: [{ type: 'convertibleTransfer', data: transferData }] } ); @@ -258,7 +259,7 @@ createIntegrationTestSuite('Transfer Type operations', (getContext) => { const cmd = buildUpdateCapTableCommand( { capTableContractId: eqCompSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), }, { creates: [{ type: 'equityCompensationTransfer', data: transferData }] } ); @@ -330,7 +331,7 @@ createIntegrationTestSuite('Transfer Type operations', (getContext) => { const cmd = buildUpdateCapTableCommand( { capTableContractId: warrantSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), }, { creates: [{ type: 'warrantTransfer', data: transferData }] } ); diff --git a/test/integration/entities/valuationVesting.integration.test.ts b/test/integration/entities/valuationVesting.integration.test.ts index 5f0b028d..b52ea9ac 100644 --- a/test/integration/entities/valuationVesting.integration.test.ts +++ b/test/integration/entities/valuationVesting.integration.test.ts @@ -29,6 +29,7 @@ import { requireCreatedEventBlob, setupStockSecurity, setupTestIssuer, + withCapTableContractDetails, } from '../utils'; function extractContractIdString(cid: { value: unknown }): string { @@ -193,7 +194,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -254,7 +255,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -314,7 +315,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -365,7 +366,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont const stockSecurity2 = await setupStockSecurity(ctx.ocp, { issuerContractId: stockSecurity1.capTableContractId, issuerParty: ctx.issuerParty, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), }); events = await ctx.ocp.ledger.getEventsByContractId({ contractId: stockSecurity2.capTableContractId }); @@ -380,7 +381,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity2.capTableContractId, - capTableContractDetails: finalCapTableDetails, + ...withCapTableContractDetails(finalCapTableDetails), actAs: [ctx.issuerParty], }); diff --git a/test/integration/production/productionDataRoundtrip.integration.test.ts b/test/integration/production/productionDataRoundtrip.integration.test.ts index 4908fb2e..1e231202 100644 --- a/test/integration/production/productionDataRoundtrip.integration.test.ts +++ b/test/integration/production/productionDataRoundtrip.integration.test.ts @@ -36,6 +36,7 @@ import { setupTestIssuer, setupTestStakeholder, setupWarrantSecurity, + withCapTableContractDetails, } from '../utils'; /** @@ -119,7 +120,7 @@ async function createStockPlanPrerequisite( } ) { const stockPlanData = createTestStockPlanData({ - id: params.stockPlanId, + ...(params.stockPlanId === undefined ? {} : { id: params.stockPlanId }), stock_class_ids: [params.stockClassId], }); @@ -495,14 +496,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -538,14 +539,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -581,14 +582,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -746,14 +747,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: convertibleSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: convertibleSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -848,14 +849,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: eqCompSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: eqCompSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -893,14 +894,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: eqCompSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: eqCompSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1137,14 +1138,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1185,14 +1186,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1227,14 +1228,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1308,14 +1309,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1351,14 +1352,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1397,14 +1398,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: convertibleSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: convertibleSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1441,14 +1442,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: convertibleSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: convertibleSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1485,14 +1486,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: eqCompSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: eqCompSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1527,14 +1528,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: eqCompSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: eqCompSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1569,14 +1570,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: eqCompSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: eqCompSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1691,14 +1692,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: warrantSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: warrantSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1733,14 +1734,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: warrantSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: warrantSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1775,14 +1776,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: warrantSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: warrantSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1856,14 +1857,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: warrantSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: warrantSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1900,14 +1901,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1942,14 +1943,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); diff --git a/test/integration/utils/setupTestData.ts b/test/integration/utils/setupTestData.ts index 2a63c798..92d25d72 100644 --- a/test/integration/utils/setupTestData.ts +++ b/test/integration/utils/setupTestData.ts @@ -83,6 +83,13 @@ export function generateTestId(prefix: string): string { return `${prefix}-${timestamp}-${random}`; } +/** Omit optional disclosed-contract input rather than materializing it as `undefined`. */ +export function withCapTableContractDetails( + capTableContractDetails: T | undefined +): { capTableContractDetails?: T } { + return capTableContractDetails === undefined ? {} : { capTableContractDetails }; +} + /** Get updated CapTable contract details after a batch operation. */ export async function getCapTableDetails( ocp: OcpClient, @@ -823,7 +830,7 @@ export async function setupTestStakeholder( const cmd = buildUpdateCapTableCommand( { capTableContractId: options.issuerContractId, - capTableContractDetails: options.capTableContractDetails, + ...withCapTableContractDetails(options.capTableContractDetails), }, { creates: [{ type: 'stakeholder', data: stakeholderData }] } ); @@ -1087,7 +1094,7 @@ export async function setupStockSecurity( const batch1 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result1 = await batch1.create('stakeholder', stakeholderData).execute(); @@ -1113,7 +1120,7 @@ export async function setupStockSecurity( const batch2 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result2 = await batch2.create('stockClass', stockClassData).execute(); @@ -1140,7 +1147,7 @@ export async function setupStockSecurity( const batch3 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result3 = await batch3.create('stockIssuance', stockIssuanceData).execute(); @@ -1185,7 +1192,7 @@ export async function setupWarrantSecurity( const batch1 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result1 = await batch1.create('stakeholder', stakeholderData).execute(); @@ -1211,7 +1218,7 @@ export async function setupWarrantSecurity( const batch2 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result2 = await batch2.create('warrantIssuance', warrantIssuanceData).execute(); @@ -1257,7 +1264,7 @@ export async function setupEquityCompensationSecurity( const batch1 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result1 = await batch1.create('stakeholder', stakeholderData).execute(); @@ -1283,7 +1290,7 @@ export async function setupEquityCompensationSecurity( const batch2 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result2 = await batch2.create('stockClass', stockClassData).execute(); @@ -1310,7 +1317,7 @@ export async function setupEquityCompensationSecurity( const batch3 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result3 = await batch3.create('equityCompensationIssuance', eqCompIssuanceData).execute(); @@ -1358,7 +1365,7 @@ export async function setupConvertibleSecurity( const batch1 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result1 = await batch1.create('stakeholder', stakeholderData).execute(); @@ -1384,7 +1391,7 @@ export async function setupConvertibleSecurity( const batch2 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result2 = await batch2.create('convertibleIssuance', convertibleIssuanceData).execute(); diff --git a/test/mocks/fairmint-canton-node-sdk.ts b/test/mocks/fairmint-canton-node-sdk.ts index 7ea5e85d..1ffd7afc 100644 --- a/test/mocks/fairmint-canton-node-sdk.ts +++ b/test/mocks/fairmint-canton-node-sdk.ts @@ -4,7 +4,7 @@ import type { ClientConfig } from '@fairmint/canton-node-sdk'; import type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; export class LedgerJsonApiClient { - private readonly config?: ClientConfig; + private readonly config: ClientConfig | undefined; public static __instances: LedgerJsonApiClient[] = []; public lastAuthToken?: string; private __getAuthToken?: () => Promise | string; @@ -151,7 +151,7 @@ export class Canton { public readonly ledger: LedgerJsonApiClient; public readonly validator: ValidatorApiClient; public readonly scan: unknown; - private partyId?: string; + private partyId: string | undefined; constructor(public readonly config: ClientConfig) { this.ledger = new LedgerJsonApiClient(config); diff --git a/test/observability/observability.test.ts b/test/observability/observability.test.ts index 39d2a322..e6d0011d 100644 --- a/test/observability/observability.test.ts +++ b/test/observability/observability.test.ts @@ -532,7 +532,7 @@ describe('observability helpers', () => { fieldPath: 'commandContext[0].traceContext.metadata.tenant', }) ); - expect(() => mergeCommandContext({ workflowId: undefined })).toThrow( + expect(() => mergeCommandContext({ workflowId: undefined } as never)).toThrow( expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'commandContext[0].workflowId' }) ); }); diff --git a/test/schemaAlignment/conversionTriggerShapes.test.ts b/test/schemaAlignment/conversionTriggerShapes.test.ts new file mode 100644 index 00000000..b997ec6b --- /dev/null +++ b/test/schemaAlignment/conversionTriggerShapes.test.ts @@ -0,0 +1,102 @@ +import { OcpValidationError } from '../../src/errors'; +import { parseOcfObject } from '../../src/utils/ocfZodSchemas'; +import { requireFirst } from '../../src/utils/requireDefined'; +import { loadProductionFixture } from '../utils/productionFixtures'; + +const convertibleFixture = loadProductionFixture>('convertibleIssuance', 'safe-post-money'); +const fixtureTrigger = requireFirst( + convertibleFixture.conversion_triggers as Array>, + 'fixture conversion trigger' +); +const commonTrigger = { + trigger_id: fixtureTrigger.trigger_id, + nickname: fixtureTrigger.nickname, + trigger_description: fixtureTrigger.trigger_description, + conversion_right: fixtureTrigger.conversion_right, +}; + +const validTriggers: Array<{ name: string; trigger: Record }> = [ + { + name: 'automatic condition', + trigger: { ...commonTrigger, type: 'AUTOMATIC_ON_CONDITION', trigger_condition: 'Qualified financing closes' }, + }, + { + name: 'automatic date', + trigger: { ...commonTrigger, type: 'AUTOMATIC_ON_DATE', trigger_date: '2027-01-01' }, + }, + { + name: 'elective range', + trigger: { + ...commonTrigger, + type: 'ELECTIVE_IN_RANGE', + start_date: '2027-01-01', + end_date: '2027-12-31', + }, + }, + { + name: 'elective condition', + trigger: { ...commonTrigger, type: 'ELECTIVE_ON_CONDITION', trigger_condition: 'Holder elects' }, + }, + { name: 'elective at will', trigger: { ...commonTrigger, type: 'ELECTIVE_AT_WILL' } }, + { name: 'unspecified', trigger: { ...commonTrigger, type: 'UNSPECIFIED' } }, +]; + +const invalidTriggers: Array<{ name: string; trigger: Record }> = [ + { + name: 'condition without trigger_condition', + trigger: { ...commonTrigger, type: 'AUTOMATIC_ON_CONDITION' }, + }, + { + name: 'condition with a trigger_date', + trigger: { + ...commonTrigger, + type: 'ELECTIVE_ON_CONDITION', + trigger_condition: 'Holder elects', + trigger_date: '2027-01-01', + }, + }, + { + name: 'date without trigger_date', + trigger: { ...commonTrigger, type: 'AUTOMATIC_ON_DATE' }, + }, + { + name: 'date with a trigger_condition', + trigger: { + ...commonTrigger, + type: 'AUTOMATIC_ON_DATE', + trigger_date: '2027-01-01', + trigger_condition: 'Not valid for this variant', + }, + }, + { + name: 'range without end_date', + trigger: { ...commonTrigger, type: 'ELECTIVE_IN_RANGE', start_date: '2027-01-01' }, + }, + { + name: 'at will with range fields', + trigger: { + ...commonTrigger, + type: 'ELECTIVE_AT_WILL', + start_date: '2027-01-01', + end_date: '2027-12-31', + }, + }, + { + name: 'unspecified with a trigger condition', + trigger: { ...commonTrigger, type: 'UNSPECIFIED', trigger_condition: 'Not valid for this variant' }, + }, +]; + +function withTrigger(trigger: Record): Record { + return { ...convertibleFixture, conversion_triggers: [trigger] }; +} + +describe('pinned OCF conversion-trigger shapes', () => { + it.each(validTriggers)('accepts $name', ({ trigger }) => { + expect(parseOcfObject(withTrigger(trigger))).toBeDefined(); + }); + + it.each(invalidTriggers)('rejects $name', ({ trigger }) => { + expect(() => parseOcfObject(withTrigger(trigger))).toThrow(OcpValidationError); + }); +}); diff --git a/test/schemaAlignment/schemaConformanceHarness.ts b/test/schemaAlignment/schemaConformanceHarness.ts index 77bfae9a..d52bdd4d 100644 --- a/test/schemaAlignment/schemaConformanceHarness.ts +++ b/test/schemaAlignment/schemaConformanceHarness.ts @@ -628,7 +628,12 @@ function collectRuntimeTestTargets(sourceFile: ts.SourceFile): RuntimeTestTarget modifierStatus === 'active' && call.runner !== 'describe' && callback === undefined ? 'incomplete' : modifierStatus; - recordTarget(firstArgument.text, { callback, modifiers, runner: call.runner, status: localStatus }); + recordTarget(firstArgument.text, { + ...(callback === undefined ? {} : { callback }), + modifiers, + runner: call.runner, + status: localStatus, + }); if (call.runner !== 'describe') continue; if (!callback || !ts.isBlock(callback.body)) continue; diff --git a/test/schemaAlignment/schemaConformanceRegistry.ts b/test/schemaAlignment/schemaConformanceRegistry.ts index 02e3abd6..3cef9f5d 100644 --- a/test/schemaAlignment/schemaConformanceRegistry.ts +++ b/test/schemaAlignment/schemaConformanceRegistry.ts @@ -76,7 +76,8 @@ export const OCF_CONDITIONAL_COVERAGE: ConditionalCoverageRegistration[] = [ ...alternatives('schema/objects/transactions/change_event/StakeholderRelationshipChangeEvent.schema.json#/anyOf', 2), ...alternatives( 'schema/objects/transactions/issuance/ConvertibleIssuance.schema.json#/properties/conversion_triggers/items/anyOf', - 6 + 6, + 'conversion-trigger-semantic-integrity' ), ...alternatives('schema/objects/transactions/issuance/EquityCompensationIssuance.schema.json#/anyOf', 6), ...alternatives( @@ -85,7 +86,8 @@ export const OCF_CONDITIONAL_COVERAGE: ConditionalCoverageRegistration[] = [ ), ...alternatives( 'schema/objects/transactions/issuance/WarrantIssuance.schema.json#/properties/exercise_triggers/items/anyOf', - 6 + 6, + 'conversion-trigger-semantic-integrity' ), ...alternatives( 'schema/primitives/types/conversion_rights/ConversionRight.schema.json#/properties/conversion_mechanism/oneOf', @@ -139,6 +141,31 @@ export const OCF_CONDITIONAL_COVERAGE: ConditionalCoverageRegistration[] = [ * SDK contract independently so either side changing requires review. */ export const EXPECTED_SEMANTIC_REFINEMENTS: SemanticRefinement[] = [ + { + coverage: [ + { + file: 'test/utils/conversionSemanticRefinements.test.ts', + kind: 'runtime', + target: 'rejects schema-valid duplicate conversion trigger IDs at typed boundaries', + }, + { + file: 'test/utils/conversionSemanticRefinements.test.ts', + kind: 'runtime', + target: 'rejects schema-valid reversed elective conversion ranges at typed boundaries', + }, + ], + expectedSdkContract: + 'Typed convertible and warrant inputs require non-empty trigger IDs unique within the parent list and ELECTIVE_IN_RANGE end_date on or after start_date.', + id: 'conversion-trigger-semantic-integrity', + rationale: + 'The pinned schemas describe trigger IDs as unique and date ranges as ordered, but draft-07 validates neither cross-item uniqueness nor date ordering.', + schemaPaths: [ + 'schema/objects/transactions/issuance/ConvertibleIssuance.schema.json', + 'schema/objects/transactions/issuance/WarrantIssuance.schema.json', + 'schema/primitives/types/conversion_triggers/ConversionTrigger.schema.json', + 'schema/types/conversion_triggers/ElectiveConversionInDateRangeTrigger.schema.json', + ], + }, { coverage: [ { diff --git a/test/schemaAlignment/stockClassStorageInvariants.test.ts b/test/schemaAlignment/stockClassStorageInvariants.test.ts new file mode 100644 index 00000000..2deb77e0 --- /dev/null +++ b/test/schemaAlignment/stockClassStorageInvariants.test.ts @@ -0,0 +1,64 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const SRC_ROOT = path.resolve(__dirname, '../../src'); +const WARRANT_READER = path.join( + SRC_ROOT, + 'functions', + 'OpenCapTable', + 'warrantIssuance', + 'getWarrantIssuanceAsOcf.ts' +); +const STORAGE_BOUNDARY = path.join(SRC_ROOT, 'functions', 'OpenCapTable', 'shared', 'stockClassRightStorage.ts'); + +function parse(file: string): ts.SourceFile { + return ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); +} + +describe('stock-class storage source invariants', () => { + test('keeps nested warrant storage validation behind the shared boundary', () => { + const reader = parse(WARRANT_READER); + const duplicateHelpers: string[] = []; + let sharedBoundaryCalls = 0; + + function visitReader(node: ts.Node): void { + if ( + ts.isFunctionDeclaration(node) && + node.name !== undefined && + ['assertNestedStockClassTrigger', 'nestedStockClassTargetFromDaml'].includes(node.name.text) + ) { + duplicateHelpers.push(node.name.text); + } + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'assertStockClassStorageTrigger' + ) { + sharedBoundaryCalls += 1; + } + ts.forEachChild(node, visitReader); + } + visitReader(reader); + + const boundary = parse(STORAGE_BOUNDARY); + let placeholderValidationCalls = 0; + function visitBoundary(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'assertPlaceholderRight' + ) { + placeholderValidationCalls += 1; + } + ts.forEachChild(node, visitBoundary); + } + visitBoundary(boundary); + + expect({ duplicateHelpers, sharedBoundaryCalls, placeholderValidationCalls }).toEqual({ + duplicateHelpers: [], + sharedBoundaryCalls: 1, + placeholderValidationCalls: 1, + }); + }); +}); diff --git a/test/types/conversionTriggers.types.ts b/test/types/conversionTriggers.types.ts new file mode 100644 index 00000000..be44efb0 --- /dev/null +++ b/test/types/conversionTriggers.types.ts @@ -0,0 +1,137 @@ +/** Compile-time contracts for the six exact canonical conversion-trigger variants. */ + +import type { + ConvertibleConversionRight, + ConvertibleConversionTrigger, + WarrantConversionRight, + WarrantExerciseTrigger, +} from '../../src'; + +const convertibleRight: ConvertibleConversionRight = { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: '100', + }, +}; + +const warrantRight: WarrantConversionRight = { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: '100', + }, +}; + +const convertibleTriggers: ConvertibleConversionTrigger[] = [ + { + type: 'AUTOMATIC_ON_CONDITION', + trigger_id: 'automatic-condition', + trigger_condition: 'Qualified financing closes', + conversion_right: convertibleRight, + }, + { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'automatic-date', + trigger_date: '2027-01-01', + conversion_right: convertibleRight, + }, + { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'elective-range', + start_date: '2027-01-01', + end_date: '2027-12-31', + conversion_right: convertibleRight, + }, + { + type: 'ELECTIVE_ON_CONDITION', + trigger_id: 'elective-condition', + trigger_condition: 'Holder elects after a liquidity event', + conversion_right: convertibleRight, + }, + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'elective-at-will', + conversion_right: convertibleRight, + }, + { + type: 'UNSPECIFIED', + trigger_id: 'unspecified', + conversion_right: convertibleRight, + }, +]; + +const warrantTrigger: WarrantExerciseTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'warrant-date', + trigger_date: '2027-06-01', + conversion_right: warrantRight, +}; + +// Inclusive single-day ranges are declaration-valid; ordering is enforced at runtime. +const equalDateRangeTrigger: WarrantExerciseTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'single-day-range', + start_date: '2027-06-01', + end_date: '2027-06-01', + conversion_right: warrantRight, +}; + +// @ts-expect-error condition variants require trigger_condition +const missingCondition: ConvertibleConversionTrigger = { + type: 'AUTOMATIC_ON_CONDITION', + trigger_id: 'missing-condition', + conversion_right: convertibleRight, +}; + +// @ts-expect-error condition variants forbid date fields +const conditionWithDate: ConvertibleConversionTrigger = { + type: 'ELECTIVE_ON_CONDITION', + trigger_id: 'condition-with-date', + trigger_condition: 'Qualified financing closes', + trigger_date: '2027-01-01', + conversion_right: convertibleRight, +}; + +// @ts-expect-error automatic date variants require trigger_date +const missingTriggerDate: ConvertibleConversionTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'missing-date', + conversion_right: convertibleRight, +}; + +// @ts-expect-error automatic date variants forbid conditions +const dateWithCondition: WarrantExerciseTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'date-with-condition', + trigger_date: '2027-01-01', + trigger_condition: 'Not valid on this variant', + conversion_right: warrantRight, +}; + +// @ts-expect-error range variants require both boundaries +const incompleteRange: ConvertibleConversionTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'incomplete-range', + start_date: '2027-01-01', + conversion_right: convertibleRight, +}; + +// @ts-expect-error payloadless variants forbid every timing field +const atWillWithRange: WarrantExerciseTrigger = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'at-will-with-range', + start_date: '2027-01-01', + end_date: '2027-12-31', + conversion_right: warrantRight, +}; + +void convertibleTriggers; +void warrantTrigger; +void equalDateRangeTrigger; +void missingCondition; +void conditionWithDate; +void missingTriggerDate; +void dateWithCondition; +void incompleteRange; +void atWillWithRange; diff --git a/test/utils/conversionSemanticRefinements.test.ts b/test/utils/conversionSemanticRefinements.test.ts index cf7c74a2..b9cb56a7 100644 --- a/test/utils/conversionSemanticRefinements.test.ts +++ b/test/utils/conversionSemanticRefinements.test.ts @@ -1,4 +1,4 @@ -import { OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { parseOcfEntityInput, parseOcfObject } from '../../src/utils/ocfZodSchemas'; const BASE_WARRANT = { @@ -12,6 +12,29 @@ const BASE_WARRANT = { security_law_exemptions: [], }; +const BASE_CONVERTIBLE = { + object_type: 'TX_CONVERTIBLE_ISSUANCE' as const, + id: 'convertible-parser-1', + date: '2026-01-01', + security_id: 'security-1', + custom_id: 'CN-1', + stakeholder_id: 'stakeholder-1', + investment_amount: { amount: '100', currency: 'USD' }, + convertible_type: 'SAFE' as const, + seniority: 1, + security_law_exemptions: [], +}; + +const CONVERTIBLE_RIGHT = { + type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, + conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION' as const, converts_to_quantity: '10' }, +}; + +const WARRANT_RIGHT = { + type: 'WARRANT_CONVERSION_RIGHT' as const, + conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION' as const, converts_to_quantity: '10' }, +}; + function warrantWithRight(right: Record): Record { return { ...BASE_WARRANT, @@ -41,6 +64,98 @@ describe('conversion semantic parser refinements', () => { custom_conversion_description: 'Custom terms', }; + it('rejects schema-valid duplicate conversion trigger IDs at typed boundaries', () => { + const cases = [ + { + entityType: 'convertibleIssuance' as const, + field: 'conversion_triggers', + input: { + ...BASE_CONVERTIBLE, + conversion_triggers: [ + { type: 'ELECTIVE_AT_WILL', trigger_id: 'duplicate-id', conversion_right: CONVERTIBLE_RIGHT }, + { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'duplicate-id', + trigger_date: '2027-01-01', + conversion_right: CONVERTIBLE_RIGHT, + }, + ], + }, + }, + { + entityType: 'warrantIssuance' as const, + field: 'exercise_triggers', + input: { + ...BASE_WARRANT, + exercise_triggers: [ + { type: 'ELECTIVE_AT_WILL', trigger_id: 'duplicate-id', conversion_right: WARRANT_RIGHT }, + { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'duplicate-id', + trigger_date: '2027-01-01', + conversion_right: WARRANT_RIGHT, + }, + ], + }, + }, + ]; + + for (const { entityType, field, input } of cases) { + expect(() => parseOcfObject(input)).not.toThrow(); + expect(captureValidationError(() => parseOcfEntityInput(entityType, input))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${field}.1.trigger_id`, + receivedValue: 'duplicate-id', + }); + } + }); + + it('rejects schema-valid reversed elective conversion ranges at typed boundaries', () => { + const cases = [ + { + entityType: 'convertibleIssuance' as const, + field: 'conversion_triggers', + input: { + ...BASE_CONVERTIBLE, + conversion_triggers: [ + { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'reversed-range', + start_date: '2027-12-31', + end_date: '2027-01-01', + conversion_right: CONVERTIBLE_RIGHT, + }, + ], + }, + }, + { + entityType: 'warrantIssuance' as const, + field: 'exercise_triggers', + input: { + ...BASE_WARRANT, + exercise_triggers: [ + { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'reversed-range', + start_date: '2027-12-31', + end_date: '2027-01-01', + conversion_right: WARRANT_RIGHT, + }, + ], + }, + }, + ]; + + for (const { entityType, field, input } of cases) { + expect(() => parseOcfObject(input)).not.toThrow(); + expect(captureValidationError(() => parseOcfEntityInput(entityType, input))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${field}.0.end_date`, + receivedValue: '2027-01-01', + }); + } + }); + test.each([ { name: 'ACTUAL valuation without its ledger amount', diff --git a/test/validation/boundaries.test.ts b/test/validation/boundaries.test.ts index 708ac168..9bc0d11c 100644 --- a/test/validation/boundaries.test.ts +++ b/test/validation/boundaries.test.ts @@ -194,13 +194,14 @@ describe('Boundary Condition Tests', () => { describe('Null vs Undefined Handling', () => { test('OCF inputs reject explicit undefined while omitted DAML optionals use null', () => { - const explicitUndefined: OcfStakeholder = { + // Emulate an unchecked JavaScript caller; exact optional property types reject this at compile time. + const explicitUndefined = { id: 'sh-null-test', object_type: 'STAKEHOLDER', name: { legal_name: 'Test' }, stakeholder_type: 'INDIVIDUAL', issuer_assigned_id: undefined, - }; + } as unknown as OcfStakeholder; expect(() => stakeholderDataToDaml(explicitUndefined)).toThrow( expect.objectContaining({ name: OcpValidationError.name, diff --git a/tsconfig.exact-source.json b/tsconfig.exact-source.json deleted file mode 100644 index b2c49bea..00000000 --- a/tsconfig.exact-source.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "exactOptionalPropertyTypes": true, - "noEmit": true - }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"] -} diff --git a/tsconfig.json b/tsconfig.json index 0d965d86..7a964bba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "outDir": "./dist", "rootDir": "./src", "strict": true, + "exactOptionalPropertyTypes": true, "noImplicitReturns": true, "noImplicitOverride": true, "noUnusedLocals": true,