diff --git a/src/OcpClient.ts b/src/OcpClient.ts index be7e4d35..c7947fa6 100644 --- a/src/OcpClient.ts +++ b/src/OcpClient.ts @@ -133,18 +133,13 @@ import type { OcfStockAcceptanceOutput, OcfStockCancellationOutput, OcfStockClassAuthorizedSharesAdjustmentOutput, - OcfStockClassConversionRatioAdjustmentOutput, OcfStockClassOutput, - OcfStockClassSplitOutput, - OcfStockConsolidationOutput, OcfStockConversionOutput, OcfStockIssuanceOutput, OcfStockLegendTemplateOutput, OcfStockPlanOutput, OcfStockPlanPoolAdjustmentOutput, OcfStockPlanReturnToPoolOutput, - OcfStockReissuanceOutput, - OcfStockRepurchaseOutput, OcfStockRetractionOutput, OcfStockTransferOutput, OcfValuationOutput, @@ -1133,15 +1128,15 @@ interface OpenCapTableMethods { // Adjustments issuerAuthorizedSharesAdjustment: EntityReader; stockClassAuthorizedSharesAdjustment: EntityReader; - stockClassConversionRatioAdjustment: EntityReader; - stockClassSplit: EntityReader; + stockClassConversionRatioAdjustment: EntityReader>; + stockClassSplit: EntityReader>; stockPlanPoolAdjustment: EntityReader; stockPlanReturnToPool: EntityReader; // Other transactions - stockRepurchase: EntityReader; - stockConsolidation: EntityReader; - stockReissuance: EntityReader; + stockRepurchase: EntityReader>; + stockConsolidation: EntityReader>; + stockReissuance: EntityReader>; equityCompensationRelease: EntityReader; equityCompensationRepricing: EntityReader; diff --git a/src/functions/OpenCapTable/capTable/damlEntityData.ts b/src/functions/OpenCapTable/capTable/damlEntityData.ts index a2f38905..d49ed47d 100644 --- a/src/functions/OpenCapTable/capTable/damlEntityData.ts +++ b/src/functions/OpenCapTable/capTable/damlEntityData.ts @@ -1,10 +1,15 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError } from '../../../errors'; -import { extractGeneratedCreateArgumentData, requireGeneratedRecord } from '../../../utils/generatedDamlValidation'; +import { + assertSafeGeneratedDamlJson, + extractGeneratedCreateArgumentData, + requireGeneratedRecord, +} from '../../../utils/generatedDamlValidation'; import { initialSharesAuthorizedFromDaml } from '../../../utils/typeConversions'; import { projectDamlIssuerDataToNative } from '../issuer/getIssuerAsOcf'; import { parseDamlSafeInteger } from '../shared/damlIntegers'; -import { assertCanonicalJsonGraph, requireDecimalString } from '../shared/ocfValues'; +import { requireGeneratedDamlNumeric10 } from '../shared/generatedDamlValues'; +import { assertCanonicalJsonGraph } from '../shared/ocfValues'; import { damlOptionalStakeholderRelationshipToNative } from '../stakeholderRelationshipChangeEvent/damlToOcf'; import { extractAndDecodeAcceptanceData, isAcceptanceEntityType } from './acceptanceContractData'; import { @@ -28,6 +33,10 @@ import { isComplexIssuanceEntityType, validateComplexIssuanceDamlDataInput, } from './issuanceContractData'; +import { + extractAndDecodeStockCorporateActionData, + isStockCorporateActionEntityType, +} from './stockCorporateActionContractData'; import { extractAndDecodeTransferData, isTransferEntityType, @@ -97,7 +106,7 @@ function preflightSemanticDamlEntityData(entityType: OcfEntityType, input: unkno hasOwnField(input, 'quantity_converted') && input.quantity_converted !== null ) { - requireDecimalString(input.quantity_converted, 'convertibleConversion.quantity_converted'); + requireGeneratedDamlNumeric10(input.quantity_converted, 'convertibleConversion.quantity_converted'); } } @@ -106,6 +115,11 @@ function createEntityDataDecoder( codec: EntityDataCodec> ): EntityDataDecoder> { return (input) => { + // Preserve the established corporate-action diagnostic root while the + // common lossless boundary below owns detachment, freezing, and decoding. + if (isStockCorporateActionEntityType(entityType) && input !== undefined) { + assertSafeGeneratedDamlJson(input, `damlToOcf.${entityType}`); + } const options = { rootPath: entityType, description: entityType, @@ -396,5 +410,9 @@ export function extractAndDecodeDamlEntityData( return extractAndDecodeVestingData(entityType, createArgument); } + if (isStockCorporateActionEntityType(entityType)) { + return extractAndDecodeStockCorporateActionData(entityType, createArgument); + } + return decodeDamlEntityData(entityType, extractEntityData(entityType, createArgument)); } diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index 758d9ec1..3dee25e0 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -52,7 +52,6 @@ import { damlStockCancellationToNative } from '../stockCancellation/damlToOcf'; import { damlStockClassDataToNative } from '../stockClass/getStockClassAsOcf'; import { damlStockClassAuthorizedSharesAdjustmentDataToNative } from '../stockClassAuthorizedSharesAdjustment/getStockClassAuthorizedSharesAdjustmentAsOcf'; import { damlStockClassConversionRatioAdjustmentToNative } from '../stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; -import { decodeStockClassConversionRatioAdjustmentCreateArgument } from '../stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf'; import { damlStockClassSplitToNative } from '../stockClassSplit/damlToStockClassSplit'; import { damlStockConsolidationToNative } from '../stockConsolidation/damlToStockConsolidation'; import { damlStockConversionToNative } from '../stockConversion/damlToOcf'; @@ -210,6 +209,27 @@ export function convertToOcf( return damlWarrantExerciseToNative(data as Parameters[0]); } + // Corporate-action converters own generated-codec losslessness plus their + // schema-specific cardinality and Numeric checks. Keep every public read path + // on the same decoder-backed boundary before the generic JSON guard. + if (type === 'stockClassConversionRatioAdjustment') { + return damlStockClassConversionRatioAdjustmentToNative( + data as Parameters[0] + ); + } + if (type === 'stockClassSplit') { + return damlStockClassSplitToNative(data as Parameters[0]); + } + if (type === 'stockConsolidation') { + return damlStockConsolidationToNative(data as Parameters[0]); + } + if (type === 'stockReissuance') { + return damlStockReissuanceToNative(data as Parameters[0]); + } + if (type === 'stockRepurchase') { + return damlStockRepurchaseToNative(data as Parameters[0]); + } + assertCanonicalJsonGraph(data, type); switch (type) { // ===== Core objects ===== @@ -238,16 +258,6 @@ export function convertToOcf( case 'warrantAcceptance': return damlWarrantAcceptanceToNative(data as Parameters[0]); - // Stock class adjustments (with converters from entity folders) - case 'stockClassConversionRatioAdjustment': - return damlStockClassConversionRatioAdjustmentToNative( - data as Parameters[0] - ); - case 'stockClassSplit': - return damlStockClassSplitToNative(data as Parameters[0]); - case 'stockConsolidation': - return damlStockConsolidationToNative(data as Parameters[0]); - // Valuation and vesting (with converters from entity folders) case 'valuation': return damlValuationToNative(data); @@ -257,8 +267,6 @@ export function convertToOcf( return damlStockRetractionToNative(data as Parameters[0]); case 'stockPlanReturnToPool': return damlStockPlanReturnToPoolToNative(data as Parameters[0]); - case 'stockReissuance': - return damlStockReissuanceToNative(data as Parameters[0]); case 'warrantRetraction': return damlWarrantRetractionToNative(data as Parameters[0]); case 'convertibleRetraction': @@ -286,10 +294,6 @@ export function convertToOcf( case 'convertibleCancellation': return damlConvertibleCancellationToNative(data as Parameters[0]); - // Repurchase (with converter from entity folder) - case 'stockRepurchase': - return damlStockRepurchaseToNative(data as Parameters[0]); - // Stakeholder events (with converters from entity folders) case 'stakeholderRelationshipChangeEvent': return damlStakeholderRelationshipChangeEventToNative( @@ -369,10 +373,6 @@ export async function getEntityAsOcf( // Convert DAML data to native OCF format const nativeData = convertToOcf(entityType, decodedEntityData); - if (entityType === 'stockClassConversionRatioAdjustment') { - decodeStockClassConversionRatioAdjustmentCreateArgument(createArgument, `damlToOcf.${entityType}.createArgument`); - } - return Object.freeze({ data: nativeData, contractId, diff --git a/src/functions/OpenCapTable/capTable/entityTypes.ts b/src/functions/OpenCapTable/capTable/entityTypes.ts index 8c14b93f..408657c6 100644 --- a/src/functions/OpenCapTable/capTable/entityTypes.ts +++ b/src/functions/OpenCapTable/capTable/entityTypes.ts @@ -128,9 +128,14 @@ export type ImmutableOcfReadEntityType = | 'equityCompensationTransfer' | 'issuerAuthorizedSharesAdjustment' | 'stockClassAuthorizedSharesAdjustment' + | 'stockClassConversionRatioAdjustment' + | 'stockClassSplit' + | 'stockConsolidation' | 'stockConversion' - | 'stockTransfer' | 'stockPlanPoolAdjustment' + | 'stockReissuance' + | 'stockRepurchase' + | 'stockTransfer' | 'warrantExercise' | 'warrantTransfer'; diff --git a/src/functions/OpenCapTable/capTable/ocfToDaml.ts b/src/functions/OpenCapTable/capTable/ocfToDaml.ts index 04cba7c3..1d076128 100644 --- a/src/functions/OpenCapTable/capTable/ocfToDaml.ts +++ b/src/functions/OpenCapTable/capTable/ocfToDaml.ts @@ -150,6 +150,18 @@ function convertEntityToDaml( parseOcfEntityInput(type, data); return converted; } + if (type === 'stockClassSplit') { + return stockClassSplitDataToDaml(data as OcfDataTypeFor<'stockClassSplit'>); + } + if (type === 'stockConsolidation') { + return stockConsolidationDataToDaml(data as OcfDataTypeFor<'stockConsolidation'>); + } + if (type === 'stockReissuance') { + return stockReissuanceDataToDaml(data as OcfDataTypeFor<'stockReissuance'>); + } + if (type === 'stockRepurchase') { + return stockRepurchaseDataToDaml(data as OcfDataTypeFor<'stockRepurchase'>); + } if (type === 'convertibleConversion') { const converted = convertibleConversionDataToDaml(data as OcfDataTypeFor<'convertibleConversion'>); parseOcfEntityInput(type, data); @@ -203,8 +215,6 @@ function convertEntityToDaml( return stockPlanDataToDaml(d as OcfDataTypeFor<'stockPlan'>); case 'stockCancellation': return stockCancellationDataToDaml(d as OcfDataTypeFor<'stockCancellation'>); - case 'stockRepurchase': - return stockRepurchaseDataToDaml(d as OcfDataTypeFor<'stockRepurchase'>); case 'issuer': return issuerDataToDaml(d as OcfDataTypeFor<'issuer'>, { skipSchemaParse: true }); case 'equityCompensationCancellation': @@ -219,12 +229,6 @@ function convertEntityToDaml( return stockAcceptanceDataToDaml(d as OcfDataTypeFor<'stockAcceptance'>); case 'stockRetraction': return stockRetractionDataToDaml(d as OcfDataTypeFor<'stockRetraction'>); - case 'stockReissuance': - return stockReissuanceDataToDaml(d as OcfDataTypeFor<'stockReissuance'>); - case 'stockConsolidation': - return stockConsolidationDataToDaml(d as OcfDataTypeFor<'stockConsolidation'>); - case 'stockClassSplit': - return stockClassSplitDataToDaml(d as OcfDataTypeFor<'stockClassSplit'>); case 'stockPlanReturnToPool': return stockPlanReturnToPoolDataToDaml(d as OcfDataTypeFor<'stockPlanReturnToPool'>); case 'valuation': diff --git a/src/functions/OpenCapTable/capTable/stockCorporateActionContractData.ts b/src/functions/OpenCapTable/capTable/stockCorporateActionContractData.ts new file mode 100644 index 00000000..2e14e041 --- /dev/null +++ b/src/functions/OpenCapTable/capTable/stockCorporateActionContractData.ts @@ -0,0 +1,175 @@ +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { toSafeDiagnosticText } from '../../../errors/OcpError'; +import { + assertSafeGeneratedDamlJson, + extractGeneratedCreateArgumentData, +} from '../../../utils/generatedDamlValidation'; +import { validatePartyId } from '../../../utils/validation'; +import { ENTITY_DATA_FIELD_MAP, ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor, type OcfEntityType } from './batchTypes'; +import { assertLosslessGeneratedDamlRoundTrip } from './damlCodecLosslessness'; + +export type StockCorporateActionEntityType = Extract< + OcfEntityType, + | 'stockClassConversionRatioAdjustment' + | 'stockClassSplit' + | 'stockConsolidation' + | 'stockReissuance' + | 'stockRepurchase' +>; + +export function isStockCorporateActionEntityType( + entityType: OcfEntityType +): entityType is StockCorporateActionEntityType { + return ( + entityType === 'stockClassConversionRatioAdjustment' || + entityType === 'stockClassSplit' || + entityType === 'stockConsolidation' || + entityType === 'stockReissuance' || + entityType === 'stockRepurchase' + ); +} + +interface GeneratedContextWrapper { + readonly context: { + readonly issuer: string; + readonly system_operator: string; + }; +} + +interface DecoderError { + readonly at: string; + readonly message: string; +} + +interface GeneratedCreateArgumentCodec { + readonly decoder: { + run( + input: unknown + ): { readonly ok: true; readonly result: T } | { readonly ok: false; readonly error: DecoderError }; + }; + encode(value: T): unknown; +} + +type StockCorporateActionCreateArgumentDecoderMap = { + readonly [EntityType in StockCorporateActionEntityType]: (createArgument: unknown) => DamlDataTypeFor; +}; + +function createArgumentError( + entityType: StockCorporateActionEntityType, + source: string, + message: string, + context: Readonly> +): OcpParseError { + return new OcpParseError(message, { + source, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_create_argument', + context: { + entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], + ...context, + }, + }); +} + +function createStockCorporateActionCreateArgumentDecoder< + const EntityType extends StockCorporateActionEntityType, + CreateArgument extends GeneratedContextWrapper, +>( + entityType: EntityType, + codec: GeneratedCreateArgumentCodec, + selectData: (value: CreateArgument) => DamlDataTypeFor +): (createArgument: unknown) => DamlDataTypeFor { + return (createArgument) => { + const rootPath = `damlToOcf.${entityType}.createArgument`; + // This descriptor-only bounded pass runs before generated code can touch a + // property, so proxies, accessors, cycles, and oversized inputs stay inert. + assertSafeGeneratedDamlJson(createArgument, rootPath); + const rawData = extractGeneratedCreateArgumentData(createArgument, rootPath, { + dataField: ENTITY_DATA_FIELD_MAP[entityType], + }); + + const decoded = codec.decoder.run(createArgument); + if (!decoded.ok) { + const { at: decoderPath, message: decoderMessage } = decoded.error; + const decoderSource = decoderPath === 'input' ? rootPath : `${rootPath}${decoderPath.slice('input'.length)}`; + throw createArgumentError( + entityType, + decoderSource, + `Invalid generated DAML create argument for ${entityType} at ${decoderPath}: ${decoderMessage}`, + { decoderPath, decoderMessage } + ); + } + + let encoded: unknown; + try { + encoded = codec.encode(decoded.result); + } catch (error) { + throw createArgumentError( + entityType, + rootPath, + `Unable to encode generated DAML create argument for ${entityType}: ${toSafeDiagnosticText(error)}`, + { phase: 'encode' } + ); + } + + assertSafeGeneratedDamlJson(encoded, `${rootPath}.__encoded`); + assertLosslessGeneratedDamlRoundTrip(createArgument, encoded, { + rootPath, + description: `${entityType} create argument`, + decodeSource: rootPath, + context: { + entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], + }, + }); + + validatePartyId(decoded.result.context.issuer, `${rootPath}.context.issuer`); + validatePartyId(decoded.result.context.system_operator, `${rootPath}.context.system_operator`); + // Preserve the exact raw payload for the correlated entity-data decoder. + // That second boundary owns field-level losslessness, while this full-wrapper + // decoder proves the generated template/context shape and Party semantics. + void selectData(decoded.result); + return rawData as DamlDataTypeFor; + }; +} + +const STOCK_CORPORATE_ACTION_CREATE_ARGUMENT_DECODER_MAP = { + stockClassConversionRatioAdjustment: createStockCorporateActionCreateArgumentDecoder( + 'stockClassConversionRatioAdjustment', + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment, + (value) => value.adjustment_data + ), + stockClassSplit: createStockCorporateActionCreateArgumentDecoder( + 'stockClassSplit', + Fairmint.OpenCapTable.OCF.StockClassSplit.StockClassSplit, + (value) => value.split_data + ), + stockConsolidation: createStockCorporateActionCreateArgumentDecoder( + 'stockConsolidation', + Fairmint.OpenCapTable.OCF.StockConsolidation.StockConsolidation, + (value) => value.consolidation_data + ), + stockReissuance: createStockCorporateActionCreateArgumentDecoder( + 'stockReissuance', + Fairmint.OpenCapTable.OCF.StockReissuance.StockReissuance, + (value) => value.reissuance_data + ), + stockRepurchase: createStockCorporateActionCreateArgumentDecoder( + 'stockRepurchase', + Fairmint.OpenCapTable.OCF.StockRepurchase.StockRepurchase, + (value) => value.repurchase_data + ), +} as const satisfies StockCorporateActionCreateArgumentDecoderMap; + +/** Decode one exact full generated wrapper and return its correlated corporate-action payload. */ +export function extractAndDecodeStockCorporateActionData( + entityType: EntityType, + createArgument: unknown +): DamlDataTypeFor { + const decoder = STOCK_CORPORATE_ACTION_CREATE_ARGUMENT_DECODER_MAP[entityType] as ( + value: unknown + ) => DamlDataTypeFor; + return decoder(createArgument); +} diff --git a/src/functions/OpenCapTable/shared/generatedDamlValues.ts b/src/functions/OpenCapTable/shared/generatedDamlValues.ts index cdbd6749..5630e803 100644 --- a/src/functions/OpenCapTable/shared/generatedDamlValues.ts +++ b/src/functions/OpenCapTable/shared/generatedDamlValues.ts @@ -25,7 +25,7 @@ function invalidType(fieldPath: string, expectedType: string, receivedValue: unk }); } -/** Decode and canonicalize a generated DAML Numeric(10) string with exact range handling. */ +/** Decode and canonicalize a generated DAML Numeric(10) string with exact range and exponent handling. */ export function requireGeneratedDamlNumeric10( value: unknown, fieldPath: string, @@ -38,9 +38,12 @@ export function requireGeneratedDamlNumeric10( ? 'nonnegative DAML Numeric(10) string' : 'DAML Numeric(10) string'; - if (value === undefined) requiredValue(fieldPath, expectedType, value); + if (value === null || value === undefined) requiredValue(fieldPath, expectedType, value); if (typeof value !== 'string') invalidType(fieldPath, expectedType, value); + // The pinned generated @daml/types Numeric(10) codec accepts scientific + // notation and preserves it on encode. Canonical OCF output remains fixed + // point, while OCF writers continue to reject exponent syntax. const numeric = canonicalizeNumeric10(value, { allowExponent: true }); if (!numeric.ok) { throw new OcpValidationError(fieldPath, numeric.message, { @@ -115,7 +118,7 @@ export function optionalGeneratedDamlTimeToDateString(value: unknown, fieldPath: function requireGeneratedCurrencyCode(value: unknown, fieldPath: string): string { const expectedType = 'three-letter uppercase ISO 4217 currency code'; - if (value === undefined) requiredValue(fieldPath, expectedType, value); + if (value === null || value === undefined) requiredValue(fieldPath, expectedType, value); if (typeof value !== 'string') invalidType(fieldPath, expectedType, value); if (!/^[A-Z]{3}$/.test(value)) { throw new OcpValidationError(fieldPath, 'Currency must be exactly three uppercase ASCII letters', { diff --git a/src/functions/OpenCapTable/shared/ocfWriterValidation.ts b/src/functions/OpenCapTable/shared/ocfWriterValidation.ts index 37d95cdb..8133bca4 100644 --- a/src/functions/OpenCapTable/shared/ocfWriterValidation.ts +++ b/src/functions/OpenCapTable/shared/ocfWriterValidation.ts @@ -1,6 +1,8 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; +import { isRecord } from '../../../utils/typeConversions'; import type { OcfDataTypeFor, OcfEntityType } from '../capTable/entityTypes'; +import { assertCanonicalJsonGraph, assertExactObjectFields, assertNotRuntimeProxy } from './ocfValues'; import { assertPlainDataValue, PlainDataValidationError } from './plainDataValidation'; function pathFor(parent: string, key: string | number): string { @@ -40,6 +42,32 @@ export function requirePlainWriterInput(value: unknown, fieldPath: string): Reco return value as Record; } +/** Require one exact, trap-free canonical writer object before any property is read. */ +export function requireExactWriterInput( + value: unknown, + fieldPath: string, + allowedFields: readonly string[] +): Record { + if (value === undefined) { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'plain OCF object', + receivedValue: value, + }); + } + assertNotRuntimeProxy(value, fieldPath, 'plain OCF object'); + if (!isRecord(value)) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a plain object`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'plain OCF object', + receivedValue: value, + }); + } + assertCanonicalJsonGraph(value, fieldPath, { rejectUndefined: true }); + assertExactObjectFields(value, allowedFields, fieldPath); + return value; +} + /** Require a dense plain array before a writer maps it. */ export function requireWriterArray(value: unknown, fieldPath: string): readonly unknown[] { if (value === undefined) { diff --git a/src/functions/OpenCapTable/shared/stockCorporateActionValues.ts b/src/functions/OpenCapTable/shared/stockCorporateActionValues.ts new file mode 100644 index 00000000..839a1bc0 --- /dev/null +++ b/src/functions/OpenCapTable/shared/stockCorporateActionValues.ts @@ -0,0 +1,134 @@ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import type { DeepReadonly } from '../../../types/common'; +import type { + OcfStockClassConversionRatioAdjustment, + OcfStockClassSplit, + OcfStockConsolidation, + OcfStockReissuance, + OcfStockRepurchase, +} from '../../../types/native'; +import { requireStringArray } from './ocfValues'; + +type StockCorporateAction = + | OcfStockClassConversionRatioAdjustment + | OcfStockClassSplit + | OcfStockConsolidation + | OcfStockReissuance + | OcfStockRepurchase; + +function required(fieldPath: string, expectedType: string, receivedValue: unknown): never { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); +} + +function invalidType(fieldPath: string, expectedType: string, receivedValue: unknown): never { + throw new OcpValidationError(fieldPath, `${fieldPath} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); +} + +/** Enforce the non-empty DAML Text refinements shared by stock corporate actions. */ +export function requireStockCorporateActionText(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) required(fieldPath, 'non-empty string', value); + if (typeof value !== 'string') invalidType(fieldPath, 'non-empty string', value); + if (value.length === 0) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be non-empty`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + return value; +} + +/** Decode/encode an optional Text whose present DAML value must be non-empty. */ +export function optionalStockCorporateActionText(value: unknown, fieldPath: string): string | undefined { + if (value === undefined || value === null) return undefined; + return requireStockCorporateActionText(value, fieldPath); +} + +/** Encode a canonical optional Text as the exact generated DAML Optional representation. */ +export function optionalStockCorporateActionTextToDaml(value: unknown, fieldPath: string): string | null { + return optionalStockCorporateActionText(value, fieldPath) ?? null; +} + +/** Validate comments: the list may be empty, but every DAML Text element must be non-empty. */ +export function requireStockCorporateActionComments(value: unknown, fieldPath: string): string[] { + return requireStringArray(value, fieldPath).map((comment, index) => + requireStockCorporateActionText(comment, `${fieldPath}[${index}]`) + ); +} + +/** Encode optional canonical comments using the exact required generated DAML list. */ +export function stockCorporateActionCommentsToDaml(value: unknown, fieldPath: string): string[] { + if (value === undefined) return []; + return requireStockCorporateActionComments(value, fieldPath); +} + +/** + * Validate a required identifier list against OCF cardinality and pinned DAML uniqueness/text refinements. + */ +export function requireStockCorporateActionIdentifiers(value: unknown, fieldPath: string): [string, ...string[]] { + const identifiers = requireStringArray(value, fieldPath).map((identifier, index) => + requireStockCorporateActionText(identifier, `${fieldPath}[${index}]`) + ); + if (identifiers.length === 0) { + throw new OcpValidationError(fieldPath, `${fieldPath} must contain at least one identifier`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'non-empty array of unique, non-empty strings', + receivedValue: identifiers, + }); + } + + const firstIndexByIdentifier = new Map(); + for (let index = 0; index < identifiers.length; index += 1) { + const identifier = identifiers[index]; + if (identifier === undefined) throw new TypeError('Validated corporate-action identifier disappeared'); + const duplicateOfIndex = firstIndexByIdentifier.get(identifier); + if (duplicateOfIndex !== undefined) { + throw new OcpValidationError(`${fieldPath}[${index}]`, `${fieldPath} identifiers must be unique`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'unique identifier', + receivedValue: identifier, + context: { duplicateIndex: index, duplicateOfIndex }, + }); + } + firstIndexByIdentifier.set(identifier, index); + } + + const [first, ...remaining] = identifiers; + if (first === undefined) throw new TypeError('Non-empty corporate-action identifier validation returned empty'); + return [first, ...remaining]; +} + +function cloneAndFreeze(value: Value): DeepReadonly { + if (value === null || typeof value !== 'object') return value as DeepReadonly; + if (Array.isArray(value)) { + return Object.freeze(value.map((item) => cloneAndFreeze(item))) as DeepReadonly; + } + + const clone: Record = {}; + for (const key of Object.keys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !('value' in descriptor)) { + throw new TypeError('Constructed corporate-action event must contain only data properties'); + } + Object.defineProperty(clone, key, { + configurable: false, + enumerable: true, + value: cloneAndFreeze(descriptor.value), + writable: false, + }); + } + return Object.freeze(clone) as DeepReadonly; +} + +/** Return a detached, recursively frozen stock corporate-action read snapshot. */ +export function freezeStockCorporateActionEvent(event: T): DeepReadonly { + return cloneAndFreeze(event); +} diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts index 3c1bd5b1..9c3c0693 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts @@ -1,20 +1,19 @@ /** DAML to OCF converter for StockClassConversionRatioAdjustment. */ -import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; -import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; +import type { DamlDataTypeFor, OcfReadDataTypeFor } from '../capTable/batchTypes'; +import { decodeDamlEntityData, type ReadonlyDamlDataTypeFor } from '../capTable/damlEntityData'; +import { requireGeneratedDamlMonetary, requireGeneratedDamlNumeric10 } from '../shared/generatedDamlValues'; import { - assertCanonicalJsonGraph, - requireDenseArray, - requireMonetary, - requirePositiveDecimal, -} from '../shared/ocfValues'; + freezeStockCorporateActionEvent, + requireStockCorporateActionComments, + requireStockCorporateActionText, +} from '../shared/stockCorporateActionValues'; /** Exact generated ledger representation accepted by the direct reader. */ -export type DamlStockClassConversionRatioAdjustmentData = - Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustmentOcfData; +export type DamlStockClassConversionRatioAdjustmentData = DamlDataTypeFor<'stockClassConversionRatioAdjustment'>; function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { return new OcpValidationError(field, `${field} is required`, { @@ -32,35 +31,14 @@ function invalidType(field: string, expectedType: string, receivedValue: unknown }); } -function invalidFormat(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { - return new OcpValidationError(field, `${field} has an invalid format`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType, - receivedValue, - }); -} - function requireRecord(value: unknown, field: string): Record { if (value === null || value === undefined) throw requiredMissing(field, 'object', value); if (!isRecord(value)) throw invalidType(field, 'object', value); return value; } -function requireNonEmptyString(value: unknown, field: string): string { - if (value === null || value === undefined) throw requiredMissing(field, 'non-empty string', value); - if (typeof value !== 'string') throw invalidType(field, 'non-empty string', value); - if (value.length === 0) throw invalidFormat(field, 'non-empty string', value); - return value; -} - -function requireString(value: unknown, field: string): string { - if (value === null || value === undefined) throw requiredMissing(field, 'string', value); - if (typeof value !== 'string') throw invalidType(field, 'string', value); - return value; -} - function roundingTypeFromDaml(value: unknown, field: string): 'NORMAL' | 'CEILING' | 'FLOOR' { - const constructor = requireNonEmptyString(value, field); + const constructor = requireStockCorporateActionText(value, field); switch (constructor) { case 'OcfRoundingNormal': return 'NORMAL'; @@ -76,61 +54,39 @@ function roundingTypeFromDaml(value: unknown, field: string): 'NORMAL' | 'CEILIN } } -function commentsFromDaml(value: unknown): string[] | undefined { - if (value === null || value === undefined) return undefined; - const comments = requireDenseArray(value, 'stockClassConversionRatioAdjustment.comments').map((comment, index) => - requireString(comment, `stockClassConversionRatioAdjustment.comments.${index}`) - ); - return comments.length === 0 ? undefined : comments; -} - /** Convert exact generated DAML adjustment data to canonical OCF. */ export function damlStockClassConversionRatioAdjustmentToNative( - value: DamlStockClassConversionRatioAdjustmentData -): OcfStockClassConversionRatioAdjustment { - assertCanonicalJsonGraph(value, 'stockClassConversionRatioAdjustment'); - const data = requireRecord(value, 'stockClassConversionRatioAdjustment'); + input: ReadonlyDamlDataTypeFor<'stockClassConversionRatioAdjustment'> +): OcfReadDataTypeFor<'stockClassConversionRatioAdjustment'> { + const data = decodeDamlEntityData('stockClassConversionRatioAdjustment', input); const mechanismField = 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism'; const mechanism = requireRecord(data.new_ratio_conversion_mechanism, mechanismField); const ratio = requireRecord(mechanism.ratio, `${mechanismField}.ratio`); - const comments = commentsFromDaml(data.comments); + const comments = requireStockCorporateActionComments(data.comments, 'stockClassConversionRatioAdjustment.comments'); const native: OcfStockClassConversionRatioAdjustment = { object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', - id: requireNonEmptyString(data.id, 'stockClassConversionRatioAdjustment.id'), + id: requireStockCorporateActionText(data.id, 'stockClassConversionRatioAdjustment.id'), date: damlTimeToDateString(data.date, 'stockClassConversionRatioAdjustment.date'), - stock_class_id: requireNonEmptyString(data.stock_class_id, 'stockClassConversionRatioAdjustment.stock_class_id'), + stock_class_id: requireStockCorporateActionText( + data.stock_class_id, + 'stockClassConversionRatioAdjustment.stock_class_id' + ), new_ratio_conversion_mechanism: { type: 'RATIO_CONVERSION', - conversion_price: requireMonetary(mechanism.conversion_price, `${mechanismField}.conversion_price`), + conversion_price: requireGeneratedDamlMonetary(mechanism.conversion_price, `${mechanismField}.conversion_price`), ratio: { - numerator: requirePositiveDecimal(ratio.numerator, `${mechanismField}.ratio.numerator`), - denominator: requirePositiveDecimal(ratio.denominator, `${mechanismField}.ratio.denominator`), + numerator: requireGeneratedDamlNumeric10(ratio.numerator, `${mechanismField}.ratio.numerator`, 'positive'), + denominator: requireGeneratedDamlNumeric10( + ratio.denominator, + `${mechanismField}.ratio.denominator`, + 'positive' + ), }, rounding_type: roundingTypeFromDaml(mechanism.rounding_type, `${mechanismField}.rounding_type`), }, - ...(comments !== undefined ? { comments } : {}), + ...(comments.length > 0 ? { comments } : {}), }; - decodeLosslessGeneratedDamlValue( - Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustmentOcfData, - data, - { - rootPath: 'stockClassConversionRatioAdjustment', - description: 'stockClassConversionRatioAdjustment', - decodeSource: 'getStockClassConversionRatioAdjustmentAsOcf', - allowUndefinedOptional: true, - allowNullishEmptyArray: true, - context: { - entityType: 'stockClassConversionRatioAdjustment', - expectedTemplateId: - Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment.templateId, - }, - }, - { - decodeInput: data.comments === null || data.comments === undefined ? { ...data, comments: [] } : data, - } - ); - - return native; + return freezeStockCorporateActionEvent(native); } diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts index 89a9d93c..c3c4eb28 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts @@ -1,64 +1,27 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; -import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; -import { - decodeGeneratedDaml, - extractGeneratedCreateArgumentData, - type ReadonlyGeneratedDaml, -} from '../../../utils/generatedDamlValidation'; +import { ENTITY_TEMPLATE_ID_MAP, type OcfReadDataTypeFor } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; import { damlStockClassConversionRatioAdjustmentToNative } from './damlToStockClassConversionRatioAdjustment'; -export type OcfStockClassConversionRatioAdjustmentEvent = OcfStockClassConversionRatioAdjustment; +export type OcfStockClassConversionRatioAdjustmentEvent = OcfReadDataTypeFor<'stockClassConversionRatioAdjustment'>; export type GetStockClassConversionRatioAdjustmentAsOcfParams = GetByContractIdParams; export interface GetStockClassConversionRatioAdjustmentAsOcfResult { - event: OcfStockClassConversionRatioAdjustmentEvent; - contractId: string; -} - -/** Type alias for DAML StockClassConversionRatioAdjustment contract createArgument */ -type StockClassConversionRatioAdjustmentCreateArgument = - Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment; - -/** Validate the complete generated template wrapper before exposing its adjustment data. */ -export function decodeStockClassConversionRatioAdjustmentCreateArgument( - createArgument: unknown, - source: string -): ReadonlyGeneratedDaml { - extractGeneratedCreateArgumentData(createArgument, source, { dataField: 'adjustment_data' }); - const template = Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment; - return decodeGeneratedDaml( - createArgument, - { - decode: (value) => template.decoder.runWithException(value), - encode: (value) => template.encode(value), - }, - source, - { - classification: 'invalid_generated_create_argument', - context: { expectedTemplateId: template.templateId }, - } - ); + readonly event: OcfStockClassConversionRatioAdjustmentEvent; + readonly contractId: string; } export async function getStockClassConversionRatioAdjustmentAsOcf( client: LedgerJsonApiClient, params: GetStockClassConversionRatioAdjustmentAsOcfParams ): Promise { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getStockClassConversionRatioAdjustmentAsOcf', - expectedTemplateId: - Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment.templateId, - }); - const argumentPath = 'StockClassConversionRatioAdjustment.createArgument'; - const data = extractGeneratedCreateArgumentData(createArgument, argumentPath, { - dataField: 'adjustment_data', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockClassConversionRatioAdjustment, }); - const event: OcfStockClassConversionRatioAdjustmentEvent = damlStockClassConversionRatioAdjustmentToNative( - data as StockClassConversionRatioAdjustmentCreateArgument['adjustment_data'] - ); - decodeStockClassConversionRatioAdjustmentCreateArgument(createArgument, argumentPath); - return { event, contractId: params.contractId }; + const data = extractAndDecodeDamlEntityData('stockClassConversionRatioAdjustment', createArgument); + const event = damlStockClassConversionRatioAdjustmentToNative(data); + return Object.freeze({ event, contractId }); } diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts index aec947cc..f51f63fd 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts @@ -8,11 +8,15 @@ import { assertCanonicalJsonGraph, assertExactObjectFields, assertNotRuntimeProxy, - optionalStringArrayToDaml, requireCurrencyCode, requireNonnegativeOcfDecimal, requirePositiveOcfDecimal, } from '../shared/ocfValues'; +import { validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; +import { + requireStockCorporateActionText, + stockCorporateActionCommentsToDaml, +} from '../shared/stockCorporateActionValues'; type DamlRatioAdjustment = Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustmentOcfData; @@ -45,14 +49,6 @@ function invalidType(field: string, expectedType: string, receivedValue: unknown }); } -function invalidFormat(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { - return new OcpValidationError(field, `${field} has an invalid format`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType, - receivedValue, - }); -} - function requireRecord(value: unknown, field: string): Record { if (value === undefined) throw requiredMissing(field, 'object', value); assertNotRuntimeProxy(value, field, 'plain OCF object'); @@ -66,12 +62,6 @@ function requireString(value: unknown, field: string): string { return value; } -function requireNonEmptyString(value: unknown, field: string): string { - const text = requireString(value, field); - if (text.length === 0) throw invalidFormat(field, 'non-empty string', value); - return text; -} - function requiredDateToDaml(value: unknown, fieldPath: string): string { if (value === undefined) { throw requiredMissing(fieldPath, 'YYYY-MM-DD or RFC 3339 date-time string', value); @@ -161,10 +151,10 @@ export function stockClassConversionRatioAdjustmentDataToDaml( assertExactObjectFields(ratio, RATIO_FIELDS, ratioField); assertCanonicalJsonGraph(input, field, { rejectUndefined: true }); - return { - id: requireNonEmptyString(data.id, `${field}.id`), + const result = { + id: requireStockCorporateActionText(data.id, `${field}.id`), date: requiredDateToDaml(data.date, `${field}.date`), - stock_class_id: requireNonEmptyString(data.stock_class_id, `${field}.stock_class_id`), + stock_class_id: requireStockCorporateActionText(data.stock_class_id, `${field}.stock_class_id`), new_ratio_conversion_mechanism: { conversion_price: requiredMonetary(monetary, monetaryField), ratio: { @@ -173,6 +163,14 @@ export function stockClassConversionRatioAdjustmentDataToDaml( }, rounding_type: roundingTypeToDaml(mechanism.rounding_type), }, - comments: optionalStringArrayToDaml(data.comments, `${field}.comments`), - }; + comments: stockCorporateActionCommentsToDaml(data.comments, `${field}.comments`), + } satisfies DamlRatioAdjustment; + + validateCanonicalWriterInput( + 'stockClassConversionRatioAdjustment', + 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + data, + field + ); + return result; } diff --git a/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts b/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts index 65a32abf..c776a2b6 100644 --- a/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts +++ b/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts @@ -3,40 +3,47 @@ */ import type { OcfStockClassSplit } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString } from '../../../utils/typeConversions'; +import type { DamlDataTypeFor, OcfReadDataTypeFor } from '../capTable/batchTypes'; +import { decodeDamlEntityData, type ReadonlyDamlDataTypeFor } from '../capTable/damlEntityData'; +import { requireGeneratedDamlNumeric10 } from '../shared/generatedDamlValues'; +import { + freezeStockCorporateActionEvent, + requireStockCorporateActionComments, + requireStockCorporateActionText, +} from '../shared/stockCorporateActionValues'; /** DAML StockClassSplitOcfData structure */ -export interface DamlStockClassSplitData { - id: string; - date: string; - stock_class_id: string; - split_ratio: { - numerator: string | number; - denominator: string | number; - }; - comments: string[]; -} +export type DamlStockClassSplitData = DamlDataTypeFor<'stockClassSplit'>; /** * Convert DAML StockClassSplit data to native OCF format. * * Handles the nested OcfRatio structure and normalizes numeric strings. */ -export function damlStockClassSplitToNative(d: DamlStockClassSplitData): OcfStockClassSplit { - const numeratorStr = - typeof d.split_ratio.numerator === 'number' ? d.split_ratio.numerator.toString() : d.split_ratio.numerator; - const denominatorStr = - typeof d.split_ratio.denominator === 'number' ? d.split_ratio.denominator.toString() : d.split_ratio.denominator; - - return { +export function damlStockClassSplitToNative( + input: ReadonlyDamlDataTypeFor<'stockClassSplit'> +): OcfReadDataTypeFor<'stockClassSplit'> { + const data = decodeDamlEntityData('stockClassSplit', input); + const comments = requireStockCorporateActionComments(data.comments, 'stockClassSplit.comments'); + const event: OcfStockClassSplit = { object_type: 'TX_STOCK_CLASS_SPLIT', - id: d.id, - date: damlTimeToDateString(d.date, 'stockClassSplit.date'), - stock_class_id: d.stock_class_id, + id: requireStockCorporateActionText(data.id, 'stockClassSplit.id'), + date: damlTimeToDateString(data.date, 'stockClassSplit.date'), + stock_class_id: requireStockCorporateActionText(data.stock_class_id, 'stockClassSplit.stock_class_id'), split_ratio: { - numerator: normalizeNumericString(numeratorStr), - denominator: normalizeNumericString(denominatorStr), + numerator: requireGeneratedDamlNumeric10( + data.split_ratio.numerator, + 'stockClassSplit.split_ratio.numerator', + 'positive' + ), + denominator: requireGeneratedDamlNumeric10( + data.split_ratio.denominator, + 'stockClassSplit.split_ratio.denominator', + 'positive' + ), }, - ...(Array.isArray(d.comments) && d.comments.length ? { comments: d.comments } : {}), + ...(comments.length > 0 ? { comments } : {}), }; + return freezeStockCorporateActionEvent(event); } diff --git a/src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf.ts b/src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf.ts index b291ae19..d60a5957 100644 --- a/src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf.ts +++ b/src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf.ts @@ -1,56 +1,27 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { ENTITY_TEMPLATE_ID_MAP, type OcfReadDataTypeFor } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlStockClassSplitToNative } from './damlToStockClassSplit'; -export interface OcfStockClassSplitEvent { - object_type: 'TX_STOCK_CLASS_SPLIT'; - id: string; - date: string; - stock_class_id: string; - split_ratio: { numerator: string; denominator: string }; - comments?: string[]; -} +export type OcfStockClassSplitEvent = OcfReadDataTypeFor<'stockClassSplit'>; export type GetStockClassSplitAsOcfParams = GetByContractIdParams; export interface GetStockClassSplitAsOcfResult { - event: OcfStockClassSplitEvent; - contractId: string; + readonly event: OcfStockClassSplitEvent; + readonly contractId: string; } -/** Type alias for DAML StockClassSplit contract createArgument */ -type StockClassSplitCreateArgument = Fairmint.OpenCapTable.OCF.StockClassSplit.StockClassSplit; - export async function getStockClassSplitAsOcf( client: LedgerJsonApiClient, params: GetStockClassSplitAsOcfParams ): Promise { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getStockClassSplitAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockClassSplit, }); - const contract = createArgument as StockClassSplitCreateArgument; - const data = contract.split_data; - - // Extract numerator and denominator from the split_ratio (OcfRatio type) - const splitRatioNumerator = data.split_ratio.numerator as string | number; - const splitRatioNumeratorStr = - typeof splitRatioNumerator === 'number' ? splitRatioNumerator.toString() : splitRatioNumerator; - - const splitRatioDenominator = data.split_ratio.denominator as string | number; - const splitRatioDenominatorStr = - typeof splitRatioDenominator === 'number' ? splitRatioDenominator.toString() : splitRatioDenominator; - - const event: OcfStockClassSplitEvent = { - object_type: 'TX_STOCK_CLASS_SPLIT', - id: data.id, - date: damlTimeToDateString(data.date, 'stockClassSplit.date'), - stock_class_id: data.stock_class_id, - split_ratio: { - numerator: normalizeNumericString(splitRatioNumeratorStr), - denominator: normalizeNumericString(splitRatioDenominatorStr), - }, - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; - return { event, contractId: params.contractId }; + const data = extractAndDecodeDamlEntityData('stockClassSplit', createArgument); + const event = damlStockClassSplitToNative(data); + return Object.freeze({ event, contractId }); } diff --git a/src/functions/OpenCapTable/stockClassSplit/stockClassSplitDataToDaml.ts b/src/functions/OpenCapTable/stockClassSplit/stockClassSplitDataToDaml.ts index 95f2b3b8..5710635b 100644 --- a/src/functions/OpenCapTable/stockClassSplit/stockClassSplitDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClassSplit/stockClassSplitDataToDaml.ts @@ -2,33 +2,41 @@ * OCF to DAML converter for StockClassSplit. */ -import { OcpValidationError } from '../../../errors'; import type { OcfStockClassSplit } from '../../../types/native'; -import { cleanComments, dateStringToDAMLTime, normalizeNumericString } from '../../../utils/typeConversions'; +import { dateStringToDAMLTime } from '../../../utils/typeConversions'; +import type { DamlDataTypeFor } from '../capTable/batchTypes'; +import { requirePositiveDecimal } from '../shared/ocfValues'; +import { requireExactWriterInput, validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; +import { + requireStockCorporateActionText, + stockCorporateActionCommentsToDaml, +} from '../shared/stockCorporateActionValues'; + +const ROOT_FIELDS = ['comments', 'date', 'id', 'object_type', 'split_ratio', 'stock_class_id'] as const; +const RATIO_FIELDS = ['denominator', 'numerator'] as const; /** * Convert native OCF StockClassSplit data to DAML format. * * Both canonical OCF and DAML represent split_ratio as an OcfRatio object. */ -export function stockClassSplitDataToDaml(d: OcfStockClassSplit): Record { - if (!d.id) { - throw new OcpValidationError('stockClassSplit.id', 'Required field is missing or empty', { - expectedType: 'string', - receivedValue: d.id, - }); - } - const splitRatio = d.split_ratio; - const normalizedSplitRatio = { - numerator: normalizeNumericString(splitRatio.numerator), - denominator: normalizeNumericString(splitRatio.denominator), - }; +export function stockClassSplitDataToDaml(d: OcfStockClassSplit): DamlDataTypeFor<'stockClassSplit'> { + const path = 'stockClassSplit'; + const input = requireExactWriterInput(d, path, ROOT_FIELDS); + const ratioPath = `${path}.split_ratio`; + const ratio = requireExactWriterInput(input.split_ratio, ratioPath, RATIO_FIELDS); + + const result = { + id: requireStockCorporateActionText(input.id, `${path}.id`), + date: dateStringToDAMLTime(input.date, `${path}.date`), + stock_class_id: requireStockCorporateActionText(input.stock_class_id, `${path}.stock_class_id`), + split_ratio: { + numerator: requirePositiveDecimal(ratio.numerator, `${ratioPath}.numerator`), + denominator: requirePositiveDecimal(ratio.denominator, `${ratioPath}.denominator`), + }, + comments: stockCorporateActionCommentsToDaml(input.comments, `${path}.comments`), + } satisfies DamlDataTypeFor<'stockClassSplit'>; - return { - id: d.id, - date: dateStringToDAMLTime(d.date, 'stockClassSplit.date'), - stock_class_id: d.stock_class_id, - split_ratio: normalizedSplitRatio, - comments: cleanComments(d.comments), - }; + validateCanonicalWriterInput('stockClassSplit', 'TX_STOCK_CLASS_SPLIT', input, path); + return result; } diff --git a/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts b/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts index e85901bb..11c34fd6 100644 --- a/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts +++ b/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts @@ -1,58 +1,40 @@ -/** - * DAML to OCF converter for StockConsolidation. - */ +/** DAML to OCF converter for StockConsolidation. */ import type { OcfStockConsolidation } from '../../../types/native'; +import { damlTimeToDateString } from '../../../utils/typeConversions'; +import type { DamlDataTypeFor, OcfReadDataTypeFor } from '../capTable/batchTypes'; +import { decodeDamlEntityData, type ReadonlyDamlDataTypeFor } from '../capTable/damlEntityData'; import { - assertSafeGeneratedDamlJson, - rejectUnknownGeneratedFields, - requireGeneratedRecord, - requireGeneratedString, - requireGeneratedStringArray, -} from '../../../utils/generatedDamlValidation'; -import { damlTimeToDateString, toNonEmptyStringArray } from '../../../utils/typeConversions'; + freezeStockCorporateActionEvent, + optionalStockCorporateActionText, + requireStockCorporateActionComments, + requireStockCorporateActionIdentifiers, + requireStockCorporateActionText, +} from '../shared/stockCorporateActionValues'; -/** DAML StockConsolidationOcfData structure */ -export interface DamlStockConsolidationData { - id: string; - date: string; - security_ids: string[]; - resulting_security_id: string; // DAML has singular - reason_text: string | null; - comments: string[]; -} +/** Exact generated DAML StockConsolidation payload. */ +export type DamlStockConsolidationData = DamlDataTypeFor<'stockConsolidation'>; -/** - * Convert DAML StockConsolidation data to native OCF format. - * - * Converts DAML StockConsolidation data to canonical OCF format. - */ -export function damlStockConsolidationToNative(d: DamlStockConsolidationData): OcfStockConsolidation { +/** Convert exact generated DAML StockConsolidation data to canonical OCF. */ +export function damlStockConsolidationToNative( + input: ReadonlyDamlDataTypeFor<'stockConsolidation'> +): OcfReadDataTypeFor<'stockConsolidation'> { const rootPath = 'stockConsolidation'; - assertSafeGeneratedDamlJson(d, rootPath); - const data = requireGeneratedRecord(d, rootPath); - rejectUnknownGeneratedFields(data, rootPath, [ - 'comments', - 'date', - 'id', - 'reason_text', - 'resulting_security_id', - 'security_ids', - ]); - const id = requireGeneratedString(data.id, `${rootPath}.id`); - const date = requireGeneratedString(data.date, `${rootPath}.date`); - const resultingSecurityId = requireGeneratedString(data.resulting_security_id, `${rootPath}.resulting_security_id`); - const comments = requireGeneratedStringArray(data.comments, `${rootPath}.comments`); - const reasonText = - data.reason_text === null ? null : requireGeneratedString(data.reason_text, `${rootPath}.reason_text`); + const data = decodeDamlEntityData('stockConsolidation', input); + const reasonText = optionalStockCorporateActionText(data.reason_text, `${rootPath}.reason_text`); + const comments = requireStockCorporateActionComments(data.comments, `${rootPath}.comments`); - return { + const event: OcfStockConsolidation = { object_type: 'TX_STOCK_CONSOLIDATION', - id, - date: damlTimeToDateString(date, `${rootPath}.date`), - security_ids: toNonEmptyStringArray(data.security_ids, `${rootPath}.security_ids`, { uniqueItems: true }), - resulting_security_id: resultingSecurityId, - ...(reasonText ? { reason_text: reasonText } : {}), + id: requireStockCorporateActionText(data.id, `${rootPath}.id`), + date: damlTimeToDateString(data.date, `${rootPath}.date`), + security_ids: requireStockCorporateActionIdentifiers(data.security_ids, `${rootPath}.security_ids`), + resulting_security_id: requireStockCorporateActionText( + data.resulting_security_id, + `${rootPath}.resulting_security_id` + ), + ...(reasonText !== undefined ? { reason_text: reasonText } : {}), ...(comments.length > 0 ? { comments } : {}), }; + return freezeStockCorporateActionEvent(event); } diff --git a/src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf.ts b/src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf.ts index 04e5f6e1..56b79e54 100644 --- a/src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf.ts +++ b/src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf.ts @@ -1,29 +1,27 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; -import type { OcfStockConsolidation } from '../../../types/native'; +import { ENTITY_TEMPLATE_ID_MAP, type OcfReadDataTypeFor } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; import { damlStockConsolidationToNative } from './damlToStockConsolidation'; -export type OcfStockConsolidationEvent = OcfStockConsolidation; +export type OcfStockConsolidationEvent = OcfReadDataTypeFor<'stockConsolidation'>; export type GetStockConsolidationAsOcfParams = GetByContractIdParams; export interface GetStockConsolidationAsOcfResult { - event: OcfStockConsolidationEvent; - contractId: string; + readonly event: OcfStockConsolidationEvent; + readonly contractId: string; } -/** Type alias for DAML StockConsolidation contract createArgument */ -type StockConsolidationCreateArgument = Fairmint.OpenCapTable.OCF.StockConsolidation.StockConsolidation; - export async function getStockConsolidationAsOcf( client: LedgerJsonApiClient, params: GetStockConsolidationAsOcfParams ): Promise { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getStockConsolidationAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockConsolidation, }); - const contract = createArgument as StockConsolidationCreateArgument; - const event = damlStockConsolidationToNative(contract.consolidation_data); - return { event, contractId: params.contractId }; + const data = extractAndDecodeDamlEntityData('stockConsolidation', createArgument); + const event = damlStockConsolidationToNative(data); + return Object.freeze({ event, contractId }); } diff --git a/src/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml.ts b/src/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml.ts index fd003803..e6096729 100644 --- a/src/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml.ts +++ b/src/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml.ts @@ -2,63 +2,47 @@ * OCF to DAML converter for StockConsolidation. */ -import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { OcfStockConsolidation } from '../../../types/native'; +import { dateStringToDAMLTime } from '../../../utils/typeConversions'; +import type { DamlDataTypeFor } from '../capTable/batchTypes'; +import { requireExactWriterInput, validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; import { - cleanComments, - dateStringToDAMLTime, - isRecord, - optionalString, - toNonEmptyStringArray, -} from '../../../utils/typeConversions'; -import { assertCanonicalJsonGraph, assertExactObjectFields } from '../shared/ocfValues'; + optionalStockCorporateActionTextToDaml, + requireStockCorporateActionIdentifiers, + requireStockCorporateActionText, + stockCorporateActionCommentsToDaml, +} from '../shared/stockCorporateActionValues'; + +const ROOT_FIELDS = [ + 'comments', + 'date', + 'id', + 'object_type', + 'reason_text', + 'resulting_security_id', + 'security_ids', +] as const; /** * Convert native OCF StockConsolidation data to DAML format. * * Both canonical OCF and DAML use resulting_security_id (singular). */ -export function stockConsolidationDataToDaml(d: OcfStockConsolidation): Record { - const runtimeValue: unknown = d; - assertCanonicalJsonGraph(runtimeValue, 'stockConsolidation', { rejectUndefined: true }); - if (!isRecord(runtimeValue)) { - throw new OcpValidationError('stockConsolidation', 'Stock consolidation must be an object', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'object', - receivedValue: runtimeValue, - }); - } - assertExactObjectFields( - runtimeValue, - ['comments', 'date', 'id', 'object_type', 'reason_text', 'resulting_security_id', 'security_ids'], - 'stockConsolidation' - ); - if (!d.id) { - throw new OcpValidationError('stockConsolidation.id', 'Required field is missing or empty', { - expectedType: 'string', - receivedValue: d.id, - }); - } - const resultingSecurityId: unknown = d.resulting_security_id; - if (typeof resultingSecurityId !== 'string') { - throw new OcpValidationError('stockConsolidation.resulting_security_id', 'resulting_security_id must be a string', { - code: - resultingSecurityId === undefined || resultingSecurityId === null - ? OcpErrorCodes.REQUIRED_FIELD_MISSING - : OcpErrorCodes.INVALID_TYPE, - expectedType: 'string', - receivedValue: resultingSecurityId, - }); - } - const securityIds = toNonEmptyStringArray(d.security_ids, 'stockConsolidation.security_ids', { - uniqueItems: true, - }); - return { - id: d.id, - date: dateStringToDAMLTime(d.date, 'stockConsolidation.date'), - security_ids: securityIds, - resulting_security_id: resultingSecurityId, - reason_text: optionalString(d.reason_text), - comments: cleanComments(d.comments), - }; +export function stockConsolidationDataToDaml(d: OcfStockConsolidation): DamlDataTypeFor<'stockConsolidation'> { + const path = 'stockConsolidation'; + const input = requireExactWriterInput(d, path, ROOT_FIELDS); + const result = { + id: requireStockCorporateActionText(input.id, `${path}.id`), + date: dateStringToDAMLTime(input.date, `${path}.date`), + security_ids: requireStockCorporateActionIdentifiers(input.security_ids, `${path}.security_ids`), + resulting_security_id: requireStockCorporateActionText( + input.resulting_security_id, + `${path}.resulting_security_id` + ), + reason_text: optionalStockCorporateActionTextToDaml(input.reason_text, `${path}.reason_text`), + comments: stockCorporateActionCommentsToDaml(input.comments, `${path}.comments`), + } satisfies DamlDataTypeFor<'stockConsolidation'>; + + validateCanonicalWriterInput('stockConsolidation', 'TX_STOCK_CONSOLIDATION', input, path); + return result; } diff --git a/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts b/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts index b524831f..0865cfbf 100644 --- a/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts +++ b/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts @@ -4,30 +4,44 @@ import type { OcfStockReissuance } from '../../../types/native'; import { damlTimeToDateString } from '../../../utils/typeConversions'; +import type { DamlDataTypeFor, OcfReadDataTypeFor } from '../capTable/batchTypes'; +import { decodeDamlEntityData, type ReadonlyDamlDataTypeFor } from '../capTable/damlEntityData'; +import { + freezeStockCorporateActionEvent, + optionalStockCorporateActionText, + requireStockCorporateActionComments, + requireStockCorporateActionIdentifiers, + requireStockCorporateActionText, +} from '../shared/stockCorporateActionValues'; /** DAML StockReissuanceOcfData structure */ -export interface DamlStockReissuanceData { - id: string; - date: string; - security_id: string; - resulting_security_ids: string[]; - reason_text: string | null; - split_transaction_id: string | null; - comments: string[]; -} +export type DamlStockReissuanceData = DamlDataTypeFor<'stockReissuance'>; /** * Convert DAML StockReissuance data to native OCF format. */ -export function damlStockReissuanceToNative(d: DamlStockReissuanceData): OcfStockReissuance { - return { +export function damlStockReissuanceToNative( + input: ReadonlyDamlDataTypeFor<'stockReissuance'> +): OcfReadDataTypeFor<'stockReissuance'> { + const data = decodeDamlEntityData('stockReissuance', input); + const reasonText = optionalStockCorporateActionText(data.reason_text, 'stockReissuance.reason_text'); + const splitTransactionId = optionalStockCorporateActionText( + data.split_transaction_id, + 'stockReissuance.split_transaction_id' + ); + const comments = requireStockCorporateActionComments(data.comments, 'stockReissuance.comments'); + const event: OcfStockReissuance = { object_type: 'TX_STOCK_REISSUANCE', - id: d.id, - date: damlTimeToDateString(d.date, 'stockReissuance.date'), - security_id: d.security_id, - resulting_security_ids: d.resulting_security_ids, - ...(d.reason_text ? { reason_text: d.reason_text } : {}), - ...(d.split_transaction_id ? { split_transaction_id: d.split_transaction_id } : {}), - ...(Array.isArray(d.comments) && d.comments.length ? { comments: d.comments } : {}), + id: requireStockCorporateActionText(data.id, 'stockReissuance.id'), + date: damlTimeToDateString(data.date, 'stockReissuance.date'), + security_id: requireStockCorporateActionText(data.security_id, 'stockReissuance.security_id'), + resulting_security_ids: requireStockCorporateActionIdentifiers( + data.resulting_security_ids, + 'stockReissuance.resulting_security_ids' + ), + ...(reasonText !== undefined ? { reason_text: reasonText } : {}), + ...(splitTransactionId !== undefined ? { split_transaction_id: splitTransactionId } : {}), + ...(comments.length > 0 ? { comments } : {}), }; + return freezeStockCorporateActionEvent(event); } diff --git a/src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf.ts b/src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf.ts index ae77a40a..d4e8a1db 100644 --- a/src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf.ts @@ -1,48 +1,27 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; -import { damlTimeToDateString } from '../../../utils/typeConversions'; +import { ENTITY_TEMPLATE_ID_MAP, type OcfReadDataTypeFor } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlStockReissuanceToNative } from './damlToStockReissuance'; -export interface OcfStockReissuanceEvent { - object_type: 'TX_STOCK_REISSUANCE'; - id: string; - date: string; - security_id: string; - resulting_security_ids: string[]; - reason_text?: string; - split_transaction_id?: string; - comments?: string[]; -} +export type OcfStockReissuanceEvent = OcfReadDataTypeFor<'stockReissuance'>; export type GetStockReissuanceAsOcfParams = GetByContractIdParams; export interface GetStockReissuanceAsOcfResult { - event: OcfStockReissuanceEvent; - contractId: string; + readonly event: OcfStockReissuanceEvent; + readonly contractId: string; } -/** Type alias for DAML StockReissuance contract createArgument */ -type StockReissuanceCreateArgument = Fairmint.OpenCapTable.OCF.StockReissuance.StockReissuance; - export async function getStockReissuanceAsOcf( client: LedgerJsonApiClient, params: GetStockReissuanceAsOcfParams ): Promise { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getStockReissuanceAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockReissuance, }); - const contract = createArgument as StockReissuanceCreateArgument; - const data = contract.reissuance_data; - - const event: OcfStockReissuanceEvent = { - object_type: 'TX_STOCK_REISSUANCE', - id: data.id, - date: damlTimeToDateString(data.date, 'stockReissuance.date'), - security_id: data.security_id, - resulting_security_ids: data.resulting_security_ids, - ...(data.reason_text ? { reason_text: data.reason_text } : {}), - ...(data.split_transaction_id ? { split_transaction_id: data.split_transaction_id } : {}), - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; - return { event, contractId: params.contractId }; + const data = extractAndDecodeDamlEntityData('stockReissuance', createArgument); + const event = damlStockReissuanceToNative(data); + return Object.freeze({ event, contractId }); } diff --git a/src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml.ts b/src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml.ts index 0dc6218c..e3b39e31 100644 --- a/src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml.ts +++ b/src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml.ts @@ -2,27 +2,50 @@ * OCF to DAML converter for StockReissuance. */ -import { OcpValidationError } from '../../../errors'; import type { OcfStockReissuance } from '../../../types/native'; -import { cleanComments, dateStringToDAMLTime, optionalString } from '../../../utils/typeConversions'; +import { dateStringToDAMLTime } from '../../../utils/typeConversions'; +import type { DamlDataTypeFor } from '../capTable/batchTypes'; +import { requireExactWriterInput, validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; +import { + optionalStockCorporateActionTextToDaml, + requireStockCorporateActionIdentifiers, + requireStockCorporateActionText, + stockCorporateActionCommentsToDaml, +} from '../shared/stockCorporateActionValues'; + +const ROOT_FIELDS = [ + 'comments', + 'date', + 'id', + 'object_type', + 'reason_text', + 'resulting_security_ids', + 'security_id', + 'split_transaction_id', +] as const; /** * Convert native OCF StockReissuance data to DAML format. */ -export function stockReissuanceDataToDaml(d: OcfStockReissuance): Record { - if (!d.id) { - throw new OcpValidationError('stockReissuance.id', 'Required field is missing or empty', { - expectedType: 'string', - receivedValue: d.id, - }); - } - return { - id: d.id, - date: dateStringToDAMLTime(d.date, 'stockReissuance.date'), - security_id: d.security_id, - resulting_security_ids: d.resulting_security_ids, - reason_text: optionalString(d.reason_text ?? null), - split_transaction_id: optionalString(d.split_transaction_id ?? null), - comments: cleanComments(d.comments), - }; +export function stockReissuanceDataToDaml(d: OcfStockReissuance): DamlDataTypeFor<'stockReissuance'> { + const path = 'stockReissuance'; + const input = requireExactWriterInput(d, path, ROOT_FIELDS); + const result = { + id: requireStockCorporateActionText(input.id, `${path}.id`), + date: dateStringToDAMLTime(input.date, `${path}.date`), + security_id: requireStockCorporateActionText(input.security_id, `${path}.security_id`), + resulting_security_ids: requireStockCorporateActionIdentifiers( + input.resulting_security_ids, + `${path}.resulting_security_ids` + ), + reason_text: optionalStockCorporateActionTextToDaml(input.reason_text, `${path}.reason_text`), + split_transaction_id: optionalStockCorporateActionTextToDaml( + input.split_transaction_id, + `${path}.split_transaction_id` + ), + comments: stockCorporateActionCommentsToDaml(input.comments, `${path}.comments`), + } satisfies DamlDataTypeFor<'stockReissuance'>; + + validateCanonicalWriterInput('stockReissuance', 'TX_STOCK_REISSUANCE', input, path); + return result; } diff --git a/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts b/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts index 65c0450c..59df997a 100644 --- a/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts +++ b/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts @@ -3,22 +3,22 @@ */ import type { OcfStockRepurchase } from '../../../types'; -import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString } from '../../../utils/typeConversions'; +import type { DamlDataTypeFor, OcfReadDataTypeFor } from '../capTable/batchTypes'; +import { decodeDamlEntityData, type ReadonlyDamlDataTypeFor } from '../capTable/damlEntityData'; +import { requireGeneratedDamlMonetary, requireGeneratedDamlNumeric10 } from '../shared/generatedDamlValues'; +import { + freezeStockCorporateActionEvent, + optionalStockCorporateActionText, + requireStockCorporateActionComments, + requireStockCorporateActionText, +} from '../shared/stockCorporateActionValues'; /** * DAML StockRepurchase data structure. * This matches the shape of data returned from DAML contracts. */ -export interface DamlStockRepurchaseData { - id: string; - date: string; - security_id: string; - quantity: string; - price: { amount: string; currency: string }; - balance_security_id?: string; - consideration_text?: string; - comments?: string[]; -} +export type DamlStockRepurchaseData = DamlDataTypeFor<'stockRepurchase'>; /** * Convert DAML StockRepurchase data to native OCF format. @@ -26,16 +26,29 @@ export interface DamlStockRepurchaseData { * @param d - The DAML stock repurchase data object * @returns The native OCF StockRepurchase object */ -export function damlStockRepurchaseToNative(d: DamlStockRepurchaseData): OcfStockRepurchase { - return { +export function damlStockRepurchaseToNative( + input: ReadonlyDamlDataTypeFor<'stockRepurchase'> +): OcfReadDataTypeFor<'stockRepurchase'> { + const data = decodeDamlEntityData('stockRepurchase', input); + const balanceSecurityId = optionalStockCorporateActionText( + data.balance_security_id, + 'stockRepurchase.balance_security_id' + ); + const considerationText = optionalStockCorporateActionText( + data.consideration_text, + 'stockRepurchase.consideration_text' + ); + const comments = requireStockCorporateActionComments(data.comments, 'stockRepurchase.comments'); + const event: OcfStockRepurchase = { object_type: 'TX_STOCK_REPURCHASE', - id: d.id, - date: damlTimeToDateString(d.date, 'stockRepurchase.date'), - security_id: d.security_id, - quantity: normalizeNumericString(d.quantity), - price: damlMonetaryToNative(d.price), - ...(d.balance_security_id ? { balance_security_id: d.balance_security_id } : {}), - ...(d.consideration_text ? { consideration_text: d.consideration_text } : {}), - ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}), + id: requireStockCorporateActionText(data.id, 'stockRepurchase.id'), + date: damlTimeToDateString(data.date, 'stockRepurchase.date'), + security_id: requireStockCorporateActionText(data.security_id, 'stockRepurchase.security_id'), + quantity: requireGeneratedDamlNumeric10(data.quantity, 'stockRepurchase.quantity', 'positive'), + price: requireGeneratedDamlMonetary(data.price, 'stockRepurchase.price'), + ...(balanceSecurityId !== undefined ? { balance_security_id: balanceSecurityId } : {}), + ...(considerationText !== undefined ? { consideration_text: considerationText } : {}), + ...(comments.length > 0 ? { comments } : {}), }; + return freezeStockCorporateActionEvent(event); } diff --git a/src/functions/OpenCapTable/stockRepurchase/getStockRepurchaseAsOcf.ts b/src/functions/OpenCapTable/stockRepurchase/getStockRepurchaseAsOcf.ts index 3679fae1..cb5e19ab 100644 --- a/src/functions/OpenCapTable/stockRepurchase/getStockRepurchaseAsOcf.ts +++ b/src/functions/OpenCapTable/stockRepurchase/getStockRepurchaseAsOcf.ts @@ -1,54 +1,32 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; -import type { OcfStockRepurchase } from '../../../types/native'; -import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { ENTITY_TEMPLATE_ID_MAP, type OcfReadDataTypeFor } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlStockRepurchaseToNative } from './damlToOcf'; /** * OCF Stock Repurchase Event with object_type discriminator OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/repurchase/StockRepurchase.schema.json */ -export interface OcfStockRepurchaseEvent extends Omit { - object_type: 'TX_STOCK_REPURCHASE'; - /** Quantity as string for OCF JSON serialization */ - quantity: string; -} +export type OcfStockRepurchaseEvent = OcfReadDataTypeFor<'stockRepurchase'>; export type GetStockRepurchaseAsOcfParams = GetByContractIdParams; export interface GetStockRepurchaseAsOcfResult { - event: OcfStockRepurchaseEvent; - contractId: string; + readonly event: OcfStockRepurchaseEvent; + readonly contractId: string; } -/** Type alias for DAML StockRepurchase contract createArgument */ -type StockRepurchaseCreateArgument = Fairmint.OpenCapTable.OCF.StockRepurchase.StockRepurchase; - export async function getStockRepurchaseAsOcf( client: LedgerJsonApiClient, params: GetStockRepurchaseAsOcfParams ): Promise { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getStockRepurchaseAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockRepurchase, }); - const contract = createArgument as StockRepurchaseCreateArgument; - const data = contract.repurchase_data; - - // Convert quantity to string for normalization (DAML Numeric may come as number at runtime) - const quantity = data.quantity as string | number; - const quantityStr = typeof quantity === 'number' ? quantity.toString() : quantity; - - const event: OcfStockRepurchaseEvent = { - object_type: 'TX_STOCK_REPURCHASE', - id: data.id, - date: damlTimeToDateString(data.date, 'stockRepurchase.date'), - security_id: data.security_id, - quantity: normalizeNumericString(quantityStr), - price: damlMonetaryToNative(data.price), - ...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}), - ...(data.consideration_text ? { consideration_text: data.consideration_text } : {}), - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; - return { event, contractId: params.contractId }; + const data = extractAndDecodeDamlEntityData('stockRepurchase', createArgument); + const event = damlStockRepurchaseToNative(data); + return Object.freeze({ event, contractId }); } diff --git a/src/functions/OpenCapTable/stockRepurchase/stockRepurchaseDataToDaml.ts b/src/functions/OpenCapTable/stockRepurchase/stockRepurchaseDataToDaml.ts index 66526015..35d5d472 100644 --- a/src/functions/OpenCapTable/stockRepurchase/stockRepurchaseDataToDaml.ts +++ b/src/functions/OpenCapTable/stockRepurchase/stockRepurchaseDataToDaml.ts @@ -1,35 +1,43 @@ -import { OcpValidationError } from '../../../errors'; import type { OcfStockRepurchase } from '../../../types'; +import { dateStringToDAMLTime } from '../../../utils/typeConversions'; +import type { DamlDataTypeFor } from '../capTable/batchTypes'; +import { requireMonetary, requirePositiveDecimal } from '../shared/ocfValues'; +import { requireExactWriterInput, validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; import { - cleanComments, - dateStringToDAMLTime, - monetaryToDaml, - normalizeNumericString, - optionalString, -} from '../../../utils/typeConversions'; + optionalStockCorporateActionTextToDaml, + requireStockCorporateActionText, + stockCorporateActionCommentsToDaml, +} from '../shared/stockCorporateActionValues'; -export function stockRepurchaseDataToDaml(d: OcfStockRepurchase): Record { - if (!d.id) { - throw new OcpValidationError('stockRepurchase.id', 'Required field is missing or empty', { - expectedType: 'string', - receivedValue: d.id, - }); - } - if (!d.security_id) { - throw new OcpValidationError('stockRepurchase.security_id', 'Required field is missing or empty', { - expectedType: 'string', - receivedValue: d.security_id, - }); - } +const ROOT_FIELDS = [ + 'balance_security_id', + 'comments', + 'consideration_text', + 'date', + 'id', + 'object_type', + 'price', + 'quantity', + 'security_id', +] as const; - return { - id: d.id, - date: dateStringToDAMLTime(d.date, 'stockRepurchase.date'), - security_id: d.security_id, - quantity: normalizeNumericString(d.quantity), - price: monetaryToDaml(d.price), - balance_security_id: optionalString(d.balance_security_id), - consideration_text: optionalString(d.consideration_text), - comments: cleanComments(d.comments), - }; +export function stockRepurchaseDataToDaml(d: OcfStockRepurchase): DamlDataTypeFor<'stockRepurchase'> { + const path = 'stockRepurchase'; + const input = requireExactWriterInput(d, path, ROOT_FIELDS); + const result = { + id: requireStockCorporateActionText(input.id, `${path}.id`), + date: dateStringToDAMLTime(input.date, `${path}.date`), + security_id: requireStockCorporateActionText(input.security_id, `${path}.security_id`), + quantity: requirePositiveDecimal(input.quantity, `${path}.quantity`), + price: requireMonetary(input.price, `${path}.price`), + balance_security_id: optionalStockCorporateActionTextToDaml( + input.balance_security_id, + `${path}.balance_security_id` + ), + consideration_text: optionalStockCorporateActionTextToDaml(input.consideration_text, `${path}.consideration_text`), + comments: stockCorporateActionCommentsToDaml(input.comments, `${path}.comments`), + } satisfies DamlDataTypeFor<'stockRepurchase'>; + + validateCanonicalWriterInput('stockRepurchase', 'TX_STOCK_REPURCHASE', input, path); + return result; } diff --git a/src/types/native.ts b/src/types/native.ts index d80049e1..ea80d1cc 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -1813,7 +1813,7 @@ export interface OcfStockReissuance extends OcfObjectBase<'TX_STOCK_REISSUANCE'> /** Identifier for the security being reissued */ security_id: string; /** Array of identifiers for new securities resulting from the reissuance */ - resulting_security_ids: string[]; + resulting_security_ids: NonEmptyArray; /** Reason for the reissuance */ reason_text?: string; /** Reference to a related split transaction */ diff --git a/src/types/output.ts b/src/types/output.ts index e130fafc..824a8b04 100644 --- a/src/types/output.ts +++ b/src/types/output.ts @@ -228,13 +228,10 @@ export type OcfIssuerAuthorizedSharesAdjustmentOutput = DeepReadonly; /** Stock Class Conversion Ratio Adjustment output */ -export type OcfStockClassConversionRatioAdjustmentOutput = WithObjectType< - OcfStockClassConversionRatioAdjustment, - 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT' ->; +export type OcfStockClassConversionRatioAdjustmentOutput = DeepReadonly; /** Stock Class Split output */ -export type OcfStockClassSplitOutput = WithObjectType; +export type OcfStockClassSplitOutput = DeepReadonly; /** Stock Plan Pool Adjustment output */ export type OcfStockPlanPoolAdjustmentOutput = DeepReadonly; @@ -245,13 +242,13 @@ export type OcfStockPlanReturnToPoolOutput = WithObjectType; +export type OcfStockRepurchaseOutput = DeepReadonly; /** Stock Consolidation output */ -export type OcfStockConsolidationOutput = WithObjectType; +export type OcfStockConsolidationOutput = DeepReadonly; /** Stock Reissuance output */ -export type OcfStockReissuanceOutput = WithObjectType; +export type OcfStockReissuanceOutput = DeepReadonly; // ===== Transaction Types (vesting) ===== diff --git a/test/batch/stockCorporateActionOperations.test.ts b/test/batch/stockCorporateActionOperations.test.ts new file mode 100644 index 00000000..a377cd86 --- /dev/null +++ b/test/batch/stockCorporateActionOperations.test.ts @@ -0,0 +1,323 @@ +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { CapTableBatch } from '../../src/functions/OpenCapTable/capTable/CapTableBatch'; +import { + ENTITY_TAG_MAP, + type OcfCreateArguments, + type OcfCreateOperation, + type OcfDataTypeFor, + type OcfEditOperation, + type OcfEntityType, +} from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { + buildOcfCreateData, + buildOcfCreateDataFromOperation, + buildOcfEditData, + buildOcfEditDataFromOperation, +} from '../../src/functions/OpenCapTable/capTable/generatedBatchOperations'; +import { convertOperationToDaml, convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; + +type CorporateActionType = Extract< + OcfEntityType, + | 'stockClassConversionRatioAdjustment' + | 'stockClassSplit' + | 'stockConsolidation' + | 'stockReissuance' + | 'stockRepurchase' +>; + +interface CorporateActionCase { + readonly entityType: CorporateActionType; + readonly data: OcfDataTypeFor; + readonly expected: Readonly>; +} + +const corporateActionCases = [ + { + entityType: 'stockClassConversionRatioAdjustment', + data: { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'ratio-adjustment-1', + date: '2026-07-10', + stock_class_id: 'preferred-1', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '1.2500000000', currency: 'USD' }, + ratio: { numerator: '2.0000000000', denominator: '1.0000000000' }, + rounding_type: 'FLOOR', + }, + comments: ['repriced'], + }, + expected: { + id: 'ratio-adjustment-1', + stock_class_id: 'preferred-1', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '1.25', currency: 'USD' }, + ratio: { numerator: '2', denominator: '1' }, + rounding_type: 'OcfRoundingFloor', + }, + comments: ['repriced'], + }, + }, + { + entityType: 'stockClassSplit', + data: { + object_type: 'TX_STOCK_CLASS_SPLIT', + id: 'split-1', + date: '2026-07-10', + stock_class_id: 'common-1', + split_ratio: { numerator: '4.0000000000', denominator: '1.0000000000' }, + comments: ['split'], + }, + expected: { + id: 'split-1', + stock_class_id: 'common-1', + split_ratio: { numerator: '4', denominator: '1' }, + comments: ['split'], + }, + }, + { + entityType: 'stockConsolidation', + data: { + object_type: 'TX_STOCK_CONSOLIDATION', + id: 'consolidation-1', + date: '2026-07-10', + security_ids: ['security-old-1'], + resulting_security_id: 'security-new-1', + reason_text: 'cleanup', + comments: ['consolidated'], + }, + expected: { + id: 'consolidation-1', + security_ids: ['security-old-1'], + resulting_security_id: 'security-new-1', + reason_text: 'cleanup', + comments: ['consolidated'], + }, + }, + { + entityType: 'stockReissuance', + data: { + object_type: 'TX_STOCK_REISSUANCE', + id: 'reissuance-1', + date: '2026-07-10', + security_id: 'security-old-1', + resulting_security_ids: ['security-new-1', 'security-new-2'], + reason_text: 'replacement', + split_transaction_id: 'split-1', + comments: ['reissued'], + }, + expected: { + id: 'reissuance-1', + security_id: 'security-old-1', + resulting_security_ids: ['security-new-1', 'security-new-2'], + reason_text: 'replacement', + split_transaction_id: 'split-1', + comments: ['reissued'], + }, + }, + { + entityType: 'stockRepurchase', + data: { + object_type: 'TX_STOCK_REPURCHASE', + id: 'repurchase-1', + date: '2026-07-10', + security_id: 'security-1', + quantity: '12.5000000000', + price: { amount: '1.2500000000', currency: 'USD' }, + balance_security_id: 'security-balance-1', + consideration_text: 'cash', + comments: ['repurchased'], + }, + expected: { + id: 'repurchase-1', + security_id: 'security-1', + quantity: '12.5', + price: { amount: '1.25', currency: 'USD' }, + balance_security_id: 'security-balance-1', + consideration_text: 'cash', + comments: ['repurchased'], + }, + }, +] as const satisfies readonly CorporateActionCase[]; + +function argsFor(testCase: CorporateActionCase): OcfCreateArguments { + return [testCase.entityType, testCase.data] as OcfCreateArguments; +} + +function createOperationFor(testCase: CorporateActionCase): OcfCreateOperation { + return { type: testCase.entityType, data: testCase.data } as OcfCreateOperation; +} + +function editOperationFor(testCase: CorporateActionCase): OcfEditOperation { + return { type: testCase.entityType, data: testCase.data } as OcfEditOperation; +} + +function captureError(action: () => unknown): unknown { + try { + action(); + } catch (error: unknown) { + return error; + } + throw new Error('Expected action to throw'); +} + +describe('stock corporate-action operation boundaries', () => { + it.each(corporateActionCases)( + '$entityType preserves exact Text, Numeric, and cardinality semantics through every writer path', + (testCase) => { + const args = argsFor(testCase); + const createOperation = createOperationFor(testCase); + const editOperation = editOperationFor(testCase); + const create = buildOcfCreateData(...args); + const createFromOperation = buildOcfCreateDataFromOperation(createOperation); + const edit = buildOcfEditData(...args); + const editFromOperation = buildOcfEditDataFromOperation(editOperation); + + for (const data of [ + convertToDaml(...args), + convertOperationToDaml(createOperation), + create.value, + createFromOperation.value, + edit.value, + editFromOperation.value, + ]) { + expect(data).toMatchObject(testCase.expected); + } + + expect(create.tag).toBe(ENTITY_TAG_MAP[testCase.entityType].create); + expect(createFromOperation.tag).toBe(ENTITY_TAG_MAP[testCase.entityType].create); + expect(edit.tag).toBe(ENTITY_TAG_MAP[testCase.entityType].edit); + expect(editFromOperation.tag).toBe(ENTITY_TAG_MAP[testCase.entityType].edit); + + const batch = new CapTableBatch({ capTableContractId: 'cap-table-corporate-actions', actAs: ['issuer::party'] }); + batch.create(...args).editOperation(editOperation); + const { command } = batch.build(); + if (!('ExerciseCommand' in command)) throw new Error('Expected an ExerciseCommand'); + const choiceArgument = command.ExerciseCommand.choiceArgument as { + creates: Array<{ tag: string; value: Record }>; + edits: Array<{ tag: string; value: Record }>; + }; + expect(choiceArgument.creates[0]).toMatchObject({ + tag: ENTITY_TAG_MAP[testCase.entityType].create, + value: testCase.expected, + }); + expect(choiceArgument.edits[0]).toMatchObject({ + tag: ENTITY_TAG_MAP[testCase.entityType].edit, + value: testCase.expected, + }); + } + ); + + it('enforces consolidation minItems and uniqueItems through tuple and operation writers', () => { + const base = corporateActionCases[2]; + for (const securityIds of [[], ['duplicate', 'duplicate']] as const) { + const testCase: CorporateActionCase = { + ...base, + data: { ...base.data, security_ids: [...securityIds] } as unknown as OcfDataTypeFor, + }; + for (const action of [ + () => convertToDaml(...argsFor(testCase)), + () => convertOperationToDaml(createOperationFor(testCase)), + () => buildOcfCreateData(...argsFor(testCase)), + () => buildOcfEditDataFromOperation(editOperationFor(testCase)), + ]) { + const error = captureError(action); + expect(error).toBeInstanceOf(OcpValidationError); + const validationError = error as OcpValidationError; + expect(validationError.fieldPath).toBe( + securityIds.length === 0 ? 'stockConsolidation.security_ids' : 'stockConsolidation.security_ids[1]' + ); + expect(validationError.code).toBe( + securityIds.length === 0 ? OcpErrorCodes.OUT_OF_RANGE : OcpErrorCodes.INVALID_FORMAT + ); + } + } + }); + + it('rejects an empty reissuance result list across tuple and operation writers', () => { + const base = corporateActionCases[3]; + const testCase: CorporateActionCase = { + ...base, + data: { ...base.data, resulting_security_ids: [] } as unknown as OcfDataTypeFor, + }; + for (const action of [ + () => convertToDaml(...argsFor(testCase)), + () => convertOperationToDaml(createOperationFor(testCase)), + () => buildOcfCreateData(...argsFor(testCase)), + () => buildOcfEditDataFromOperation(editOperationFor(testCase)), + ]) { + const error = captureError(action) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.code).toBe(OcpErrorCodes.OUT_OF_RANGE); + expect(error.fieldPath).toBe('stockReissuance.resulting_security_ids'); + } + }); + + it('rejects non-positive writer Numeric values at their exact paths', () => { + const cases = [ + { base: corporateActionCases[0], fieldPath: 'new_ratio_conversion_mechanism.ratio.numerator' }, + { base: corporateActionCases[1], fieldPath: 'split_ratio.numerator' }, + { base: corporateActionCases[4], fieldPath: 'quantity' }, + ] as const; + + for (const { base, fieldPath } of cases) { + const parts = fieldPath.split('.'); + const data = JSON.parse(JSON.stringify(base.data)) as Record; + let target = data; + for (const key of parts.slice(0, -1)) target = target[key] as Record; + const field = parts[parts.length - 1]; + if (field === undefined) throw new Error('Expected a Numeric field'); + target[field] = '0'; + const testCase = { ...base, data: data as unknown as OcfDataTypeFor }; + + const error = captureError(() => convertToDaml(...argsFor(testCase))) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.code).toBe(OcpErrorCodes.OUT_OF_RANGE); + expect(error.fieldPath).toBe(`${base.entityType}.${fieldPath}`); + } + }); + + it.each(corporateActionCases)( + '$entityType rejects proxied and accessor-backed writer payloads without traps', + (testCase) => { + let trapCalls = 0; + const failTrap = (): never => { + trapCalls += 1; + throw new Error('writer proxy trap must not run'); + }; + const proxiedCase: CorporateActionCase = { + ...testCase, + data: new Proxy(testCase.data, { + get: failTrap, + getOwnPropertyDescriptor: failTrap, + getPrototypeOf: failTrap, + has: failTrap, + ownKeys: failTrap, + }), + }; + const proxyError = captureError(() => buildOcfCreateData(...argsFor(proxiedCase))); + expect(proxyError).toBeInstanceOf(OcpValidationError); + expect((proxyError as OcpValidationError).code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(trapCalls).toBe(0); + + let getterCalls = 0; + const accessorData = { ...testCase.data } as Record; + Object.defineProperty(accessorData, 'id', { + configurable: true, + enumerable: true, + get() { + getterCalls += 1; + throw new Error('writer getter must not run'); + }, + }); + const accessorCase: CorporateActionCase = { + ...testCase, + data: accessorData as unknown as OcfDataTypeFor, + }; + const accessorError = captureError(() => buildOcfEditDataFromOperation(editOperationFor(accessorCase))); + expect(accessorError).toBeInstanceOf(OcpValidationError); + expect((accessorError as OcpValidationError).code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(getterCalls).toBe(0); + } + ); +}); diff --git a/test/client/conversionExerciseReaders.test.ts b/test/client/conversionExerciseReaders.test.ts index 417e1c99..e096e037 100644 --- a/test/client/conversionExerciseReaders.test.ts +++ b/test/client/conversionExerciseReaders.test.ts @@ -206,4 +206,23 @@ describe('OcpClient conversion and exercise readers', () => { } } ); + + it.each([ + [clientReaderCases[0], 'quantity_converted'], + [clientReaderCases[1], 'quantity_converted'], + [clientReaderCases[2], 'quantity'], + [clientReaderCases[3], 'quantity'], + ] as const)( + '$0.entityType canonicalizes generated exponent syntax at the public client boundary', + async (testCase, field) => { + const ocp = new OcpClient({ ledger: ledgerFor(testCase, { ...testCase.data, [field]: '1e3' }) }); + const namespaceData = await namespaceRead(ocp, testCase); + const objectTypeData = ( + await ocp.OpenCapTable.getByObjectType({ objectType: testCase.objectType, contractId: testCase.contractId }) + ).data as unknown as Record; + const expected = testCase.entityType === 'warrantExercise' ? undefined : '1000'; + expect(namespaceData[field]).toBe(expected); + expect(objectTypeData[field]).toBe(expected); + } + ); }); diff --git a/test/client/stockCorporateActionReaders.test.ts b/test/client/stockCorporateActionReaders.test.ts new file mode 100644 index 00000000..8d527b2c --- /dev/null +++ b/test/client/stockCorporateActionReaders.test.ts @@ -0,0 +1,273 @@ +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { OcpClient } from '../../src/OcpClient'; +import { OcpValidationError } from '../../src/errors'; +import { + ENTITY_DATA_FIELD_MAP, + ENTITY_TEMPLATE_ID_MAP, + type OcfEntityType, +} from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { stockClassConversionRatioAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml'; +import { stockClassSplitDataToDaml } from '../../src/functions/OpenCapTable/stockClassSplit/stockClassSplitDataToDaml'; +import { stockConsolidationDataToDaml } from '../../src/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml'; +import { stockReissuanceDataToDaml } from '../../src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml'; +import { stockRepurchaseDataToDaml } from '../../src/functions/OpenCapTable/stockRepurchase/stockRepurchaseDataToDaml'; +import { createLedgerJsonApiClient } from '../utils/cantonNodeSdkCompat'; + +type CorporateActionType = Extract< + OcfEntityType, + | 'stockClassConversionRatioAdjustment' + | 'stockClassSplit' + | 'stockConsolidation' + | 'stockReissuance' + | 'stockRepurchase' +>; + +interface ClientReaderCase { + readonly entityType: CorporateActionType; + readonly objectType: + | 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT' + | 'TX_STOCK_CLASS_SPLIT' + | 'TX_STOCK_CONSOLIDATION' + | 'TX_STOCK_REISSUANCE' + | 'TX_STOCK_REPURCHASE'; + readonly contractId: string; + readonly data: Record; + readonly expected: Readonly>; +} + +const clientReaderCases = [ + { + entityType: 'stockClassConversionRatioAdjustment', + objectType: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + contractId: 'client-ratio-adjustment', + data: stockClassConversionRatioAdjustmentDataToDaml({ + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'ratio-adjustment-1', + date: '2026-07-10', + stock_class_id: 'preferred-1', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '1.2500000000', currency: 'USD' }, + ratio: { numerator: '2.0000000000', denominator: '1.0000000000' }, + rounding_type: 'CEILING', + }, + comments: ['repriced'], + }), + expected: { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'ratio-adjustment-1', + stock_class_id: 'preferred-1', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '1.25', currency: 'USD' }, + ratio: { numerator: '2', denominator: '1' }, + rounding_type: 'CEILING', + }, + comments: ['repriced'], + }, + }, + { + entityType: 'stockClassSplit', + objectType: 'TX_STOCK_CLASS_SPLIT', + contractId: 'client-stock-class-split', + data: stockClassSplitDataToDaml({ + object_type: 'TX_STOCK_CLASS_SPLIT', + id: 'split-1', + date: '2026-07-10', + stock_class_id: 'common-1', + split_ratio: { numerator: '4.0000000000', denominator: '1.0000000000' }, + comments: ['split'], + }), + expected: { + object_type: 'TX_STOCK_CLASS_SPLIT', + id: 'split-1', + stock_class_id: 'common-1', + split_ratio: { numerator: '4', denominator: '1' }, + comments: ['split'], + }, + }, + { + entityType: 'stockConsolidation', + objectType: 'TX_STOCK_CONSOLIDATION', + contractId: 'client-stock-consolidation', + data: stockConsolidationDataToDaml({ + object_type: 'TX_STOCK_CONSOLIDATION', + id: 'consolidation-1', + date: '2026-07-10', + security_ids: ['security-old-1'], + resulting_security_id: 'security-new-1', + reason_text: 'cleanup', + comments: ['consolidated'], + }), + expected: { + object_type: 'TX_STOCK_CONSOLIDATION', + id: 'consolidation-1', + security_ids: ['security-old-1'], + resulting_security_id: 'security-new-1', + reason_text: 'cleanup', + comments: ['consolidated'], + }, + }, + { + entityType: 'stockReissuance', + objectType: 'TX_STOCK_REISSUANCE', + contractId: 'client-stock-reissuance', + data: stockReissuanceDataToDaml({ + object_type: 'TX_STOCK_REISSUANCE', + id: 'reissuance-1', + date: '2026-07-10', + security_id: 'security-old-1', + resulting_security_ids: ['security-new-1', 'security-new-2'], + reason_text: 'replacement', + split_transaction_id: 'split-1', + comments: ['reissued'], + }), + expected: { + object_type: 'TX_STOCK_REISSUANCE', + id: 'reissuance-1', + security_id: 'security-old-1', + resulting_security_ids: ['security-new-1', 'security-new-2'], + reason_text: 'replacement', + split_transaction_id: 'split-1', + comments: ['reissued'], + }, + }, + { + entityType: 'stockRepurchase', + objectType: 'TX_STOCK_REPURCHASE', + contractId: 'client-stock-repurchase', + data: stockRepurchaseDataToDaml({ + object_type: 'TX_STOCK_REPURCHASE', + id: 'repurchase-1', + date: '2026-07-10', + security_id: 'security-1', + quantity: '12.5000000000', + price: { amount: '1.2500000000', currency: 'USD' }, + balance_security_id: 'security-balance-1', + consideration_text: 'cash', + comments: ['repurchased'], + }), + expected: { + object_type: 'TX_STOCK_REPURCHASE', + id: 'repurchase-1', + security_id: 'security-1', + quantity: '12.5', + price: { amount: '1.25', currency: 'USD' }, + balance_security_id: 'security-balance-1', + consideration_text: 'cash', + comments: ['repurchased'], + }, + }, +] as const satisfies readonly ClientReaderCase[]; + +function ledgerFor(testCase: ClientReaderCase, data: unknown = testCase.data): LedgerJsonApiClient { + const ledger = createLedgerJsonApiClient({ network: 'devnet' }); + Object.defineProperty(ledger, 'getEventsByContractId', { + value: jest.fn(async ({ contractId }: { contractId: string }) => { + await Promise.resolve(); + return { + created: { + createdEvent: { + contractId, + templateId: ENTITY_TEMPLATE_ID_MAP[testCase.entityType], + createArgument: { + context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: data, + }, + }, + }, + }; + }), + configurable: true, + enumerable: true, + writable: true, + }); + return ledger; +} + +async function namespaceRead(ocp: OcpClient, testCase: ClientReaderCase): Promise> { + const namespace = ocp.OpenCapTable[testCase.entityType] as unknown as { + get(params: { contractId: string }): Promise<{ data: Record }>; + }; + return (await namespace.get({ contractId: testCase.contractId })).data; +} + +function clonePlain(value: unknown): unknown { + if (Array.isArray(value)) return value.map(clonePlain); + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clonePlain(item)])); + } + return value; +} + +describe('OcpClient stock corporate-action readers', () => { + it.each(clientReaderCases)( + '$entityType preserves canonical data through namespace and object-type reads', + async (testCase) => { + const ocp = new OcpClient({ ledger: ledgerFor(testCase) }); + const namespaceData = await namespaceRead(ocp, testCase); + const objectTypeData = ( + await ocp.OpenCapTable.getByObjectType({ + objectType: testCase.objectType, + contractId: testCase.contractId, + }) + ).data; + + for (const data of [namespaceData, objectTypeData]) { + expect(data).toMatchObject(testCase.expected); + expect(Object.isFrozen(data)).toBe(true); + } + } + ); + + it('enforces consolidation and reissuance cardinality at both public reader surfaces', async () => { + const consolidation = clientReaderCases[2]; + const consolidationClient = new OcpClient({ + ledger: ledgerFor(consolidation, { ...consolidation.data, security_ids: [] }), + }); + await expect(namespaceRead(consolidationClient, consolidation)).rejects.toBeInstanceOf(OcpValidationError); + + const reissuance = clientReaderCases[3]; + const reissuanceClient = new OcpClient({ + ledger: ledgerFor(reissuance, { ...reissuance.data, resulting_security_ids: [] }), + }); + await expect(namespaceRead(reissuanceClient, reissuance)).rejects.toBeInstanceOf(OcpValidationError); + await expect( + reissuanceClient.OpenCapTable.getByObjectType({ + objectType: reissuance.objectType, + contractId: reissuance.contractId, + }) + ).rejects.toBeInstanceOf(OcpValidationError); + }); + + it.each([ + [clientReaderCases[0], ['new_ratio_conversion_mechanism', 'ratio', 'numerator']], + [clientReaderCases[1], ['split_ratio', 'numerator']], + [clientReaderCases[4], ['quantity']], + ] as const)( + '$0.entityType accepts and canonicalizes generated Numeric exponents at the public client boundary', + async (testCase, path) => { + const data = clonePlain(testCase.data) as Record; + let target = data; + for (const key of path.slice(0, -1)) target = target[key] as Record; + const finalKey = path[path.length - 1]; + if (finalKey === undefined) throw new Error('Numeric path must not be empty'); + target[finalKey] = '1e3'; + const ocp = new OcpClient({ ledger: ledgerFor(testCase, data) }); + + let result: unknown = await namespaceRead(ocp, testCase); + for (const key of path) result = (result as Record)[key]; + expect(result).toBe('1000'); + } + ); + + it.each(clientReaderCases)('$entityType freezes the public ContractResult wrapper', async (testCase) => { + const ocp = new OcpClient({ ledger: ledgerFor(testCase) }); + const namespace = ocp.OpenCapTable[testCase.entityType] as unknown as { + get(params: { contractId: string }): Promise<{ data: Record }>; + }; + + const result = await namespace.get({ contractId: testCase.contractId }); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.data)).toBe(true); + }); +}); diff --git a/test/converters/conversionDescriptorBoundaries.test.ts b/test/converters/conversionDescriptorBoundaries.test.ts index e44ca1bd..41cbc2d8 100644 --- a/test/converters/conversionDescriptorBoundaries.test.ts +++ b/test/converters/conversionDescriptorBoundaries.test.ts @@ -147,6 +147,19 @@ function generatedIssuanceEntityForPath(fieldPath: string): 'convertibleIssuance } function expectDamlReaderBoundary(action: () => unknown, fieldPath: string): void { + if ( + fieldPath === 'stockClassConversionRatioAdjustment' || + fieldPath.startsWith('stockClassConversionRatioAdjustment.') + ) { + const error = captureError(action); + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlToOcf.${fieldPath}`, + }); + return; + } const entityType = generatedIssuanceEntityForPath(fieldPath); if (entityType === undefined) { expectBoundaryError(action, fieldPath); @@ -156,6 +169,14 @@ function expectDamlReaderBoundary(action: () => unknown, fieldPath: string): voi } function expectDamlReaderProxyBoundary(action: () => unknown, fieldPath: string, fixture: ProxyFixture): void { + if ( + fieldPath === 'stockClassConversionRatioAdjustment' || + fieldPath.startsWith('stockClassConversionRatioAdjustment.') + ) { + expectDamlReaderBoundary(action, fieldPath); + expect(fixture.trapCalls()).toBe(0); + return; + } const entityType = generatedIssuanceEntityForPath(fieldPath); if (entityType === undefined) { expectProxyBoundary(action, fieldPath, fixture); diff --git a/test/converters/conversionWriterBoundaries.test.ts b/test/converters/conversionWriterBoundaries.test.ts index 56f98ca5..40430b18 100644 --- a/test/converters/conversionWriterBoundaries.test.ts +++ b/test/converters/conversionWriterBoundaries.test.ts @@ -550,7 +550,7 @@ describe.each([ ratio: { numerator: '+003.0000000000', denominator: '02.0000000000' }, rounding_type: 'FLOOR', }, - comments: ['', 'kept verbatim'], + comments: ['kept verbatim'], }); expect(daml).toMatchObject({ @@ -559,20 +559,16 @@ describe.each([ ratio: { numerator: '3', denominator: '2' }, rounding_type: 'OcfRoundingFloor', }, - comments: ['', 'kept verbatim'], + comments: ['kept verbatim'], }); - expect( - damlStockClassConversionRatioAdjustmentToNative( - daml as Parameters[0] - ) - ).toMatchObject({ + expect(damlStockClassConversionRatioAdjustmentToNative(daml)).toMatchObject({ new_ratio_conversion_mechanism: { type: 'RATIO_CONVERSION', conversion_price: { amount: '0', currency: 'USD' }, ratio: { numerator: '3', denominator: '2' }, rounding_type: 'FLOOR', }, - comments: ['', 'kept verbatim'], + comments: ['kept verbatim'], }); }); diff --git a/test/converters/genericConversionReadBoundaries.test.ts b/test/converters/genericConversionReadBoundaries.test.ts index b03ecbd3..e0edba2a 100644 --- a/test/converters/genericConversionReadBoundaries.test.ts +++ b/test/converters/genericConversionReadBoundaries.test.ts @@ -52,6 +52,15 @@ function captureError(action: () => unknown): unknown { throw new Error('Expected action to throw'); } +async function captureRejection(promise: Promise): Promise { + try { + await promise; + } catch (error: unknown) { + return error; + } + throw new Error('Expected promise to reject'); +} + const ISSUER_DAML = issuerDataToDaml({ object_type: 'ISSUER', id: 'issuer-lossless', @@ -843,7 +852,16 @@ describe('lossless direct and dedicated generated DAML readers', () => { getStockClassConversionRatioAdjustmentAsOcf(mockLedger('stockClassConversionRatioAdjustment', data), { contractId: 'contract-id', }) - ).rejects.toMatchObject(expected); + ).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source: + 'damlToOcf.stockClassConversionRatioAdjustment.createArgument.adjustment_data.new_ratio_conversion_mechanism.future', + context: { + decoderPath: 'input.adjustment_data.new_ratio_conversion_mechanism.future', + }, + }); }); it.each([ @@ -905,21 +923,26 @@ describe('lossless direct and dedicated generated DAML readers', () => { OcpErrorCodes.INVALID_TYPE, 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.numerator', ], - ] as const)('classifies ratio-adjustment %s before projection', (_name, data, code, fieldPath) => { - expect(captureError(() => damlStockClassConversionRatioAdjustmentToNative(data as never))).toMatchObject({ - name: 'OcpValidationError', - code, - fieldPath, - }); + ] as const)('classifies ratio-adjustment %s before projection', (_name, data, _code, fieldPath) => { + const error = captureError(() => damlStockClassConversionRatioAdjustmentToNative(data as never)) as { + readonly code: string; + readonly context?: Readonly>; + readonly name: string; + }; + expect(error.name).toBe('OcpParseError'); + expect(error.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(JSON.stringify(error.context)).toContain(fieldPath.split('.').pop() ?? fieldPath); }); it.each([null, undefined])('classifies a nullish ratio-adjustment direct root %p', (value) => { - expect(captureError(() => damlStockClassConversionRatioAdjustmentToNative(value as never))).toMatchObject({ - name: 'OcpValidationError', - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'stockClassConversionRatioAdjustment', - receivedValue: value, - }); + const error = captureError(() => damlStockClassConversionRatioAdjustmentToNative(value as never)) as { + readonly code: string; + readonly context?: Readonly>; + readonly name: string; + }; + expect(error.name).toBe('OcpParseError'); + expect(error.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(error.context?.entityType).toBe('stockClassConversionRatioAdjustment'); }); it('uses the same exact ratio-adjustment numeric diagnostics through the dedicated getter', async () => { @@ -934,16 +957,18 @@ describe('lossless direct and dedicated generated DAML readers', () => { }, comments: [], }; - await expect( + const error = (await captureRejection( getStockClassConversionRatioAdjustmentAsOcf(mockLedger('stockClassConversionRatioAdjustment', data), { contractId: 'contract-id', }) - ).rejects.toMatchObject({ - name: 'OcpValidationError', - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.numerator', - receivedValue: false, - }); + )) as { + readonly code: string; + readonly context?: Readonly>; + readonly name: string; + }; + expect(error.name).toBe('OcpParseError'); + expect(error.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(JSON.stringify(error.context)).toContain('numerator'); }); it('rejects a missing dedicated ratio-adjustment payload with a structured parse error', async () => { @@ -960,13 +985,12 @@ describe('lossless direct and dedicated generated DAML readers', () => { }, }), } as unknown as LedgerJsonApiClient; - await expect( + const error = (await captureRejection( getStockClassConversionRatioAdjustmentAsOcf(client, { contractId: 'contract-id' }) - ).rejects.toMatchObject({ - name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'StockClassConversionRatioAdjustment.createArgument.adjustment_data', - }); + )) as { readonly code: string; readonly name: string; readonly source?: string }; + expect(error.name).toBe('OcpParseError'); + expect(error.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(error.source).toBe('damlToOcf.stockClassConversionRatioAdjustment.createArgument.adjustment_data'); }); it.each([null, undefined])('classifies a nullish ConvertibleConversion direct root %p', (value) => { diff --git a/test/converters/schemaCardinalityBoundaries.test.ts b/test/converters/schemaCardinalityBoundaries.test.ts index 5bf01b44..ad32884d 100644 --- a/test/converters/schemaCardinalityBoundaries.test.ts +++ b/test/converters/schemaCardinalityBoundaries.test.ts @@ -21,6 +21,15 @@ function captureValidationError(action: () => unknown): OcpValidationError { throw new Error('Expected schema cardinality validation to fail'); } +function captureError(action: () => unknown): unknown { + try { + action(); + } catch (error: unknown) { + return error; + } + throw new Error('Expected action to throw'); +} + const QUANTITY_DAML = { balance_security_id: null, comments: [], @@ -205,32 +214,43 @@ describe('schema-cardinality stock consolidation boundaries', () => { invoke: (securityIds: unknown) => damlStockConsolidationToNative({ ...CONSOLIDATION_DAML, security_ids: securityIds } as never), label: 'direct reader', + reader: true, }, { invoke: (securityIds: unknown) => convertToOcf('stockConsolidation', { ...CONSOLIDATION_DAML, security_ids: securityIds } as never), label: 'generic reader', + reader: true, }, { invoke: (securityIds: unknown) => stockConsolidationDataToDaml({ ...CONSOLIDATION_OCF, security_ids: securityIds } as never), label: 'writer', + reader: false, }, - ])('$label rejects empty, duplicate, and wrong-type security_ids', ({ invoke }) => { + ])('$label rejects empty, duplicate, and wrong-type security_ids', ({ invoke, reader }) => { expect(captureValidationError(() => invoke([]))).toMatchObject({ code: OcpErrorCodes.OUT_OF_RANGE, fieldPath: 'stockConsolidation.security_ids', }); expect(captureValidationError(() => invoke(['duplicate', 'duplicate']))).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'stockConsolidation.security_ids.1', + fieldPath: 'stockConsolidation.security_ids[1]', receivedValue: 'duplicate', }); - expect(captureValidationError(() => invoke(['valid', 42]))).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'stockConsolidation.security_ids.1', - receivedValue: 42, - }); + const wrongTypeError = captureError(() => invoke(['valid', 42])); + if (reader) { + expect(wrongTypeError).toBeInstanceOf(OcpParseError); + const parseError = wrongTypeError as OcpParseError; + expect(parseError.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(JSON.stringify(parseError.context)).toContain('security_ids'); + } else { + expect(wrongTypeError).toBeInstanceOf(OcpValidationError); + const validationError = wrongTypeError as OcpValidationError; + expect(validationError.code).toBe(OcpErrorCodes.INVALID_TYPE); + expect(validationError.fieldPath).toBe('stockConsolidation.security_ids[1]'); + expect(validationError.receivedValue).toBe(42); + } }); it('reader requires canonical singular resulting_security_id and rejects legacy plural data', () => { @@ -239,10 +259,10 @@ describe('schema-cardinality stock consolidation boundaries', () => { try { damlStockConsolidationToNative(withoutSingular as never); } catch (error) { - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - source: 'stockConsolidation.resulting_security_id', - }); + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(JSON.stringify(parseError.context)).toContain('resulting_security_id'); } expect(() => @@ -257,7 +277,10 @@ describe('schema-cardinality stock consolidation boundaries', () => { resulting_security_ids: ['legacy-result'], } as never); } catch (error) { - expect(error).toMatchObject({ source: 'stockConsolidation.resulting_security_ids' }); + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(JSON.stringify(parseError.context)).toContain('resulting_security_ids'); } }); diff --git a/test/converters/stockClassAdjustmentConverters.test.ts b/test/converters/stockClassAdjustmentConverters.test.ts index 499f7045..4a03efb0 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -19,7 +19,10 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { getEntityAsOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; -import { damlStockClassConversionRatioAdjustmentToNative } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import { + type DamlStockClassConversionRatioAdjustmentData, + damlStockClassConversionRatioAdjustmentToNative, +} from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; import { getStockClassConversionRatioAdjustmentAsOcf } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf'; import { stockClassConversionRatioAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml'; import { damlStockClassSplitToNative } from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; @@ -35,6 +38,24 @@ import type { const GENERATED_CONTEXT = { issuer: 'issuer::party', system_operator: 'system-operator::party' } as const; +async function captureRejection(promise: Promise): Promise { + try { + await promise; + } catch (error: unknown) { + return error; + } + throw new Error('Expected promise to reject'); +} + +function captureError(action: () => unknown): unknown { + try { + action(); + } catch (error: unknown) { + return error; + } + throw new Error('Expected action to throw'); +} + describe('Stock Class Adjustment Converters', () => { describe('OCF to DAML (ocfToDaml)', () => { describe('stockClassSplit', () => { @@ -107,10 +128,8 @@ describe('Stock Class Adjustment Converters', () => { expect(result.comments).toEqual(['2-for-1 stock split', 'Approved by board']); }); - test('throws error when id is missing', () => { - const invalidData = { ...baseData, id: '' }; - - expect(() => convertToDaml('stockClassSplit', invalidData)).toThrow('stockClassSplit.id'); + test('rejects an empty id required by DAML', () => { + expect(() => convertToDaml('stockClassSplit', { ...baseData, id: '' })).toThrow(OcpValidationError); }); }); @@ -231,11 +250,9 @@ describe('Stock Class Adjustment Converters', () => { expect(result.comments).toEqual(['Ratio adjusted for anti-dilution']); }); - test('throws error when id is missing', () => { - const invalidData = { ...baseData, id: '' }; - - expect(() => convertToDaml('stockClassConversionRatioAdjustment', invalidData)).toThrow( - 'stockClassConversionRatioAdjustment.id' + test('rejects an empty id required by DAML', () => { + expect(() => convertToDaml('stockClassConversionRatioAdjustment', { ...baseData, id: '' })).toThrow( + OcpValidationError ); }); @@ -390,10 +407,8 @@ describe('Stock Class Adjustment Converters', () => { expect(result.resulting_security_id).toBe('new-sec-001'); }); - test('throws error when id is missing', () => { - const invalidData = { ...baseData, id: '' }; - - expect(() => convertToDaml('stockConsolidation', invalidData)).toThrow('stockConsolidation.id'); + test('rejects an empty id required by DAML', () => { + expect(() => convertToDaml('stockConsolidation', { ...baseData, id: '' })).toThrow(OcpValidationError); }); }); @@ -432,7 +447,7 @@ describe('Stock Class Adjustment Converters', () => { }); test('handles single resulting security', () => { - const dataWithSingleResult = { + const dataWithSingleResult: OcfStockReissuance = { ...baseData, resulting_security_ids: ['sec-new-single'], }; @@ -442,10 +457,8 @@ describe('Stock Class Adjustment Converters', () => { expect(result.resulting_security_ids).toEqual(['sec-new-single']); }); - test('throws error when id is missing', () => { - const invalidData = { ...baseData, id: '' }; - - expect(() => convertToDaml('stockReissuance', invalidData)).toThrow('stockReissuance.id'); + test('rejects an empty id required by DAML', () => { + expect(() => convertToDaml('stockReissuance', { ...baseData, id: '' })).toThrow(OcpValidationError); }); }); }); @@ -509,7 +522,7 @@ describe('Stock Class Adjustment Converters', () => { rounding_type: 'OcfRoundingNormal' as const, }, comments: ['Anti-dilution adjustment'], - }; + } satisfies DamlStockClassConversionRatioAdjustmentData; const result = damlStockClassConversionRatioAdjustmentToNative(damlData); @@ -544,17 +557,15 @@ describe('Stock Class Adjustment Converters', () => { comments: [], }; - expect(() => + const error = captureError(() => damlStockClassConversionRatioAdjustmentToNative( damlData as unknown as Parameters[0] ) - ).toThrow( - expect.objectContaining({ - name: OcpParseError.name, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.rounding_type', - }) ); + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(JSON.stringify(parseError.context)).toContain('rounding_type'); }); test.each([ @@ -562,21 +573,21 @@ describe('Stock Class Adjustment Converters', () => { 'malformed price amount', { amount: 'not-a-number', currency: 'USD' }, 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.amount', - 'DAML Numeric(10) decimal string', + 'nonnegative DAML Numeric(10) string', 'not-a-number', ], [ 'price amount beyond Numeric 10 scale', { amount: '1.12345678901', currency: 'USD' }, 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.amount', - 'DAML Numeric(10) decimal string', + 'nonnegative DAML Numeric(10) string', '1.12345678901', ], [ 'malformed currency', { amount: '1', currency: 'usd' }, 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.currency', - 'three-letter uppercase currency code', + 'three-letter uppercase ISO 4217 currency code', 'usd', ], ] as const)( @@ -637,7 +648,7 @@ describe('Stock Class Adjustment Converters', () => { ], ] as const)( 'direct reader rejects %s before nested dereference', - (_case, mechanism, fieldPath, code, expectedType, receivedValue) => { + (_case, mechanism, fieldPath, _code, _expectedType, _receivedValue) => { const damlData = { id: 'adj-invalid-shape', date: '2024-02-01T00:00:00.000Z', @@ -646,15 +657,11 @@ describe('Stock Class Adjustment Converters', () => { comments: [], } as unknown as Parameters[0]; - expect(() => damlStockClassConversionRatioAdjustmentToNative(damlData)).toThrow( - expect.objectContaining({ - name: OcpValidationError.name, - code, - fieldPath, - expectedType, - receivedValue, - }) - ); + const error = captureError(() => damlStockClassConversionRatioAdjustmentToNative(damlData)); + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(JSON.stringify(parseError.context)).toContain(fieldPath.split('.').pop() ?? fieldPath); } ); @@ -684,15 +691,16 @@ describe('Stock Class Adjustment Converters', () => { }, }); - await expect( + const error = await captureRejection( getStockClassConversionRatioAdjustmentAsOcf({ getEventsByContractId } as unknown as LedgerJsonApiClient, { contractId: 'adj-cid', }) - ).rejects.toMatchObject({ - name: OcpParseError.name, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.rounding_type', - }); + ); + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(parseError.context?.entityType).toBe('stockClassConversionRatioAdjustment'); + expect(JSON.stringify(parseError.context)).toContain('rounding_type'); }); test('dedicated and generic readers decode the complete generated template wrapper', async () => { @@ -732,7 +740,7 @@ describe('Stock Class Adjustment Converters', () => { }); const { decoder } = Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment; - const decodeSpy = jest.spyOn(decoder, 'runWithException'); + const decodeSpy = jest.spyOn(decoder, 'run'); try { await expect(read({ getEventsByContractId } as unknown as LedgerJsonApiClient)).resolves.toBeDefined(); expect(decodeSpy).toHaveBeenCalledWith(createArgument); @@ -755,16 +763,16 @@ describe('Stock Class Adjustment Converters', () => { }, }); - await expect( + const error = await captureRejection( getStockClassConversionRatioAdjustmentAsOcf({ getEventsByContractId } as unknown as LedgerJsonApiClient, { contractId: 'adj-missing-wrapper', }) - ).rejects.toMatchObject({ - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'StockClassConversionRatioAdjustment.createArgument.adjustment_data', - classification: 'invalid_generated_create_argument', - }); + ); + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(parseError.source).toBe('damlToOcf.stockClassConversionRatioAdjustment.createArgument.adjustment_data'); + expect(parseError.classification).toBe('invalid_generated_create_argument'); }); test.each([ @@ -783,7 +791,7 @@ describe('Stock Class Adjustment Converters', () => { comments: [], }, }, - 'StockClassConversionRatioAdjustment.createArgument.context', + 'damlToOcf.stockClassConversionRatioAdjustment.createArgument.context', 'invalid_generated_create_argument', ], [ @@ -803,7 +811,7 @@ describe('Stock Class Adjustment Converters', () => { }, unexpected: true, }, - 'StockClassConversionRatioAdjustment.createArgument.unexpected', + 'damlToOcf.stockClassConversionRatioAdjustment.createArgument.unexpected', 'invalid_generated_daml_json', ], ] as const)( @@ -821,16 +829,16 @@ describe('Stock Class Adjustment Converters', () => { }, }); - await expect( + const error = await captureRejection( getStockClassConversionRatioAdjustmentAsOcf({ getEventsByContractId } as unknown as LedgerJsonApiClient, { contractId: 'adj-invalid-wrapper', }) - ).rejects.toMatchObject({ - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source, - classification, - }); + ); + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(parseError.source).toBe(source); + expect(parseError.classification).toBe(classification); } ); @@ -856,17 +864,16 @@ describe('Stock Class Adjustment Converters', () => { }, }); - await expect( + const error = await captureRejection( getStockClassConversionRatioAdjustmentAsOcf({ getEventsByContractId } as unknown as LedgerJsonApiClient, { contractId: 'adj-null-mechanism', }) - ).rejects.toMatchObject({ - name: OcpValidationError.name, - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', - expectedType: 'object', - receivedValue: null, - }); + ); + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(parseError.context?.entityType).toBe('stockClassConversionRatioAdjustment'); + expect(JSON.stringify(parseError.context)).toContain('new_ratio_conversion_mechanism'); }); }); @@ -919,7 +926,9 @@ describe('Stock Class Adjustment Converters', () => { created: { createdEvent: { contractId, + templateId: Fairmint.OpenCapTable.OCF.StockConsolidation.StockConsolidation.templateId, createArgument: { + context: GENERATED_CONTEXT, consolidation_data: { id: 'consolidation-direct-001', date: '2024-03-01T00:00:00.000Z', @@ -954,15 +963,16 @@ describe('Stock Class Adjustment Converters', () => { }); test('rejects an empty DAML security_ids list at the direct getter boundary', async () => { - await expect( + const error = await captureRejection( getStockConsolidationAsOcf(clientWithSecurityIds([]), { contractId: 'cid-empty-stock-consolidation', }) - ).rejects.toMatchObject({ - code: OcpErrorCodes.OUT_OF_RANGE, - fieldPath: 'stockConsolidation.security_ids', - receivedValue: [], - } satisfies Partial); + ); + expect(error).toBeInstanceOf(OcpValidationError); + const validationError = error as OcpValidationError; + expect(validationError.code).toBe(OcpErrorCodes.OUT_OF_RANGE); + expect(validationError.fieldPath).toBe('stockConsolidation.security_ids'); + expect(validationError.receivedValue).toEqual([]); }); }); diff --git a/test/declarations/stockCorporateActionReaders.types.ts b/test/declarations/stockCorporateActionReaders.types.ts new file mode 100644 index 00000000..d4303c7f --- /dev/null +++ b/test/declarations/stockCorporateActionReaders.types.ts @@ -0,0 +1,236 @@ +/** Built-declaration contracts for stock corporate-action readers and converters. */ + +import type { DamlDataTypeFor, OcfReadDataTypeFor } from '../../dist/functions/OpenCapTable/capTable/batchTypes'; +import type { ReadonlyDamlDataTypeFor } from '../../dist/functions/OpenCapTable/capTable/damlEntityData'; +import type { + DamlStockClassConversionRatioAdjustmentData, + damlStockClassConversionRatioAdjustmentToNative, +} from '../../dist/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import type { GetStockClassConversionRatioAdjustmentAsOcfResult } from '../../dist/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf'; +import type { + DamlStockClassSplitData, + damlStockClassSplitToNative, +} from '../../dist/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; +import type { GetStockClassSplitAsOcfResult } from '../../dist/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf'; +import type { + DamlStockConsolidationData, + damlStockConsolidationToNative, +} from '../../dist/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation'; +import type { GetStockConsolidationAsOcfResult } from '../../dist/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf'; +import type { + DamlStockReissuanceData, + damlStockReissuanceToNative, +} from '../../dist/functions/OpenCapTable/stockReissuance/damlToStockReissuance'; +import type { GetStockReissuanceAsOcfResult } from '../../dist/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf'; +import type { + DamlStockRepurchaseData, + damlStockRepurchaseToNative, +} from '../../dist/functions/OpenCapTable/stockRepurchase/damlToOcf'; +import type { GetStockRepurchaseAsOcfResult } from '../../dist/functions/OpenCapTable/stockRepurchase/getStockRepurchaseAsOcf'; +import type { + OcfStockClassConversionRatioAdjustment, + OcfStockClassSplit, + OcfStockConsolidation, + OcfStockReissuance, + OcfStockRepurchase, +} from '../../dist/types/native'; + +type Assert = T; +type IsAny = 0 extends 1 & T ? true : false; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +type ConversionRatioEvent = GetStockClassConversionRatioAdjustmentAsOcfResult['event']; +type SplitEvent = GetStockClassSplitAsOcfResult['event']; +type ConsolidationEvent = GetStockConsolidationAsOcfResult['event']; +type ReissuanceEvent = GetStockReissuanceAsOcfResult['event']; +type RepurchaseEvent = GetStockRepurchaseAsOcfResult['event']; + +const conversionRatioEventIsExact: Assert< + IsExactly> +> = true; +const splitEventIsExact: Assert>> = true; +const consolidationEventIsExact: Assert>> = true; +const reissuanceEventIsExact: Assert>> = true; +const repurchaseEventIsExact: Assert>> = true; +const conversionRatioResultIsExact: Assert< + IsExactly< + GetStockClassConversionRatioAdjustmentAsOcfResult, + { + readonly event: OcfReadDataTypeFor<'stockClassConversionRatioAdjustment'>; + readonly contractId: string; + } + > +> = true; +const splitResultIsExact: Assert< + IsExactly< + GetStockClassSplitAsOcfResult, + { readonly event: OcfReadDataTypeFor<'stockClassSplit'>; readonly contractId: string } + > +> = true; +const consolidationResultIsExact: Assert< + IsExactly< + GetStockConsolidationAsOcfResult, + { readonly event: OcfReadDataTypeFor<'stockConsolidation'>; readonly contractId: string } + > +> = true; +const reissuanceResultIsExact: Assert< + IsExactly< + GetStockReissuanceAsOcfResult, + { readonly event: OcfReadDataTypeFor<'stockReissuance'>; readonly contractId: string } + > +> = true; +const repurchaseResultIsExact: Assert< + IsExactly< + GetStockRepurchaseAsOcfResult, + { readonly event: OcfReadDataTypeFor<'stockRepurchase'>; readonly contractId: string } + > +> = true; + +const conversionRatioEventIsNotAny: Assert, false>> = true; +const splitEventIsNotAny: Assert, false>> = true; +const consolidationEventIsNotAny: Assert, false>> = true; +const reissuanceEventIsNotAny: Assert, false>> = true; +const repurchaseEventIsNotAny: Assert, false>> = true; + +const conversionRatioDamlIsExact: Assert< + IsExactly> +> = true; +const splitDamlIsExact: Assert>> = true; +const consolidationDamlIsExact: Assert>> = + true; +const reissuanceDamlIsExact: Assert>> = true; +const repurchaseDamlIsExact: Assert>> = true; + +const conversionRatioConverterInputIsExact: Assert< + IsExactly< + Parameters[0], + ReadonlyDamlDataTypeFor<'stockClassConversionRatioAdjustment'> + > +> = true; +const splitConverterInputIsExact: Assert< + IsExactly[0], ReadonlyDamlDataTypeFor<'stockClassSplit'>> +> = true; +const consolidationConverterInputIsExact: Assert< + IsExactly[0], ReadonlyDamlDataTypeFor<'stockConsolidation'>> +> = true; +const reissuanceConverterInputIsExact: Assert< + IsExactly[0], ReadonlyDamlDataTypeFor<'stockReissuance'>> +> = true; +const repurchaseConverterInputIsExact: Assert< + IsExactly[0], ReadonlyDamlDataTypeFor<'stockRepurchase'>> +> = true; +const conversionRatioConverterInputIsNotAny: Assert< + IsExactly[0]>, false> +> = true; +const splitConverterInputIsNotAny: Assert[0]>, false>> = + true; +const consolidationConverterInputIsNotAny: Assert< + IsExactly[0]>, false> +> = true; +const reissuanceConverterInputIsNotAny: Assert< + IsExactly[0]>, false> +> = true; +const repurchaseConverterInputIsNotAny: Assert< + IsExactly[0]>, false> +> = true; + +type ConversionRatioRounding = + OcfStockClassConversionRatioAdjustment['new_ratio_conversion_mechanism']['rounding_type']; +const roundingUnionIsExact: Assert> = true; + +declare const conversionRatioResult: GetStockClassConversionRatioAdjustmentAsOcfResult; +declare const splitResult: GetStockClassSplitAsOcfResult; +declare const consolidationResult: GetStockConsolidationAsOcfResult; +declare const reissuanceResult: GetStockReissuanceAsOcfResult; +declare const repurchaseResult: GetStockRepurchaseAsOcfResult; +declare const conversionRatioDaml: DamlStockClassConversionRatioAdjustmentData; +declare const splitDaml: DamlStockClassSplitData; +declare const consolidationDaml: DamlStockConsolidationData; +declare const reissuanceDaml: DamlStockReissuanceData; +declare const repurchaseDaml: DamlStockRepurchaseData; + +const firstConsolidatedSecurityId: string = consolidationResult.event.security_ids[0]; +const firstReissuedSecurityId: string = reissuanceResult.event.resulting_security_ids[0]; + +// @ts-expect-error built corporate-action result wrappers are readonly +conversionRatioResult.contractId = 'other'; +// @ts-expect-error built corporate-action event fields are readonly +splitResult.event.id = 'other'; +// @ts-expect-error built corporate-action nested objects are recursively readonly +conversionRatioResult.event.new_ratio_conversion_mechanism.ratio.numerator = '4'; +// @ts-expect-error built corporate-action identifier tuples are recursively readonly +consolidationResult.event.security_ids.push('other'); +// @ts-expect-error built reissuance identifier tuples are recursively readonly +reissuanceResult.event.resulting_security_ids[0] = 'other'; +// @ts-expect-error built nested Monetary objects are recursively readonly +repurchaseResult.event.price.amount = '4'; + +// @ts-expect-error built conversion-ratio DAML cannot be passed to the split converter +const wrongSplitDamlInput: Parameters[0] = conversionRatioDaml; +// @ts-expect-error built split DAML cannot be passed to the consolidation converter +const wrongConsolidationDamlInput: Parameters[0] = splitDaml; +// @ts-expect-error built consolidation DAML cannot be passed to the reissuance converter +const wrongReissuanceDamlInput: Parameters[0] = consolidationDaml; +// @ts-expect-error built reissuance DAML cannot be passed to the repurchase converter +const wrongRepurchaseDamlInput: Parameters[0] = reissuanceDaml; +// @ts-expect-error built repurchase DAML cannot be passed to the conversion-ratio converter +const wrongConversionRatioDamlInput: Parameters[0] = + repurchaseDaml; + +// @ts-expect-error built conversion-ratio adjustment cannot be used as a split +const wrongSplit: OcfStockClassSplit = conversionRatioResult.event; +// @ts-expect-error built split cannot be used as a consolidation +const wrongConsolidation: OcfStockConsolidation = splitResult.event; +// @ts-expect-error built consolidation cannot be used as a reissuance +const wrongReissuance: OcfStockReissuance = consolidationResult.event; +// @ts-expect-error built reissuance cannot be used as a repurchase +const wrongRepurchase: OcfStockRepurchase = reissuanceResult.event; +// @ts-expect-error built repurchase cannot be used as a conversion-ratio adjustment +const wrongConversionRatio: OcfStockClassConversionRatioAdjustment = repurchaseResult.event; +// @ts-expect-error built canonical rounding is a closed union +const invalidRounding: ConversionRatioRounding = 'BANKERS'; + +void conversionRatioEventIsExact; +void splitEventIsExact; +void consolidationEventIsExact; +void reissuanceEventIsExact; +void repurchaseEventIsExact; +void conversionRatioResultIsExact; +void splitResultIsExact; +void consolidationResultIsExact; +void reissuanceResultIsExact; +void repurchaseResultIsExact; +void conversionRatioEventIsNotAny; +void splitEventIsNotAny; +void consolidationEventIsNotAny; +void reissuanceEventIsNotAny; +void repurchaseEventIsNotAny; +void conversionRatioDamlIsExact; +void splitDamlIsExact; +void consolidationDamlIsExact; +void reissuanceDamlIsExact; +void repurchaseDamlIsExact; +void conversionRatioConverterInputIsExact; +void splitConverterInputIsExact; +void consolidationConverterInputIsExact; +void reissuanceConverterInputIsExact; +void repurchaseConverterInputIsExact; +void conversionRatioConverterInputIsNotAny; +void splitConverterInputIsNotAny; +void consolidationConverterInputIsNotAny; +void reissuanceConverterInputIsNotAny; +void repurchaseConverterInputIsNotAny; +void roundingUnionIsExact; +void firstConsolidatedSecurityId; +void firstReissuedSecurityId; +void wrongSplitDamlInput; +void wrongConsolidationDamlInput; +void wrongReissuanceDamlInput; +void wrongRepurchaseDamlInput; +void wrongConversionRatioDamlInput; +void wrongSplit; +void wrongConsolidation; +void wrongReissuance; +void wrongRepurchase; +void wrongConversionRatio; +void invalidRounding; diff --git a/test/declarations/stockCorporateActionSurfaces.types.ts b/test/declarations/stockCorporateActionSurfaces.types.ts new file mode 100644 index 00000000..a912af07 --- /dev/null +++ b/test/declarations/stockCorporateActionSurfaces.types.ts @@ -0,0 +1,184 @@ +/** Exact built-declaration contracts for stock corporate-action SDK surfaces. */ + +import type { OcpClient } from '../../dist/OcpClient'; +import type { + DamlDataTypeFor, + OcfCreateDataFor, + OcfEditDataFor, + OcfReadDataTypeFor, +} from '../../dist/functions/OpenCapTable/capTable/batchTypes'; +import type { ReadonlyDamlDataTypeFor } from '../../dist/functions/OpenCapTable/capTable/damlEntityData'; +import { + buildOcfCreateData, + buildOcfCreateDataFromOperation, + buildOcfEditDataFromOperation, +} from '../../dist/functions/OpenCapTable/capTable/generatedBatchOperations'; +import { convertOperationToDaml, convertToDaml } from '../../dist/functions/OpenCapTable/capTable/ocfToDaml'; +import { stockClassConversionRatioAdjustmentDataToDaml } from '../../dist/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml'; +import { stockClassSplitDataToDaml } from '../../dist/functions/OpenCapTable/stockClassSplit/stockClassSplitDataToDaml'; +import { stockConsolidationDataToDaml } from '../../dist/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml'; +import { stockReissuanceDataToDaml } from '../../dist/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml'; +import { stockRepurchaseDataToDaml } from '../../dist/functions/OpenCapTable/stockRepurchase/stockRepurchaseDataToDaml'; +import type { + OcfStockClassConversionRatioAdjustment, + OcfStockClassSplit, + OcfStockConsolidation, + OcfStockReissuance, + OcfStockRepurchase, +} from '../../dist/types/native'; + +type Assert = T; +type IsAny = 0 extends 1 & T ? true : false; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type EveryTrue = Exclude extends never ? true : false; +type DirectNumberKeys = { + [Key in keyof T]-?: Extract extends never ? never : Key; +}[keyof T]; + +declare const ratio: OcfStockClassConversionRatioAdjustment; +declare const split: OcfStockClassSplit; +declare const consolidation: OcfStockConsolidation; +declare const reissuance: OcfStockReissuance; +declare const repurchase: OcfStockRepurchase; +declare const client: OcpClient; + +const directRatio = stockClassConversionRatioAdjustmentDataToDaml(ratio); +const directSplit = stockClassSplitDataToDaml(split); +const directConsolidation = stockConsolidationDataToDaml(consolidation); +const directReissuance = stockReissuanceDataToDaml(reissuance); +const directRepurchase = stockRepurchaseDataToDaml(repurchase); +const genericRatio = convertToDaml('stockClassConversionRatioAdjustment', ratio); +const genericSplit = convertToDaml('stockClassSplit', split); +const genericConsolidation = convertToDaml('stockConsolidation', consolidation); +const genericReissuance = convertToDaml('stockReissuance', reissuance); +const genericRepurchase = convertToDaml('stockRepurchase', repurchase); +const operationRatio = convertOperationToDaml({ type: 'stockClassConversionRatioAdjustment', data: ratio }); +const operationSplit = convertOperationToDaml({ type: 'stockClassSplit', data: split }); +const operationConsolidation = convertOperationToDaml({ type: 'stockConsolidation', data: consolidation }); +const operationReissuance = convertOperationToDaml({ type: 'stockReissuance', data: reissuance }); +const operationRepurchase = convertOperationToDaml({ type: 'stockRepurchase', data: repurchase }); +const createRatio = buildOcfCreateData('stockClassConversionRatioAdjustment', ratio); +const createSplit = buildOcfCreateDataFromOperation({ type: 'stockClassSplit', data: split }); +const createConsolidation = buildOcfCreateData('stockConsolidation', consolidation); +const editReissuance = buildOcfEditDataFromOperation({ type: 'stockReissuance', data: reissuance }); +const editRepurchase = buildOcfEditDataFromOperation({ type: 'stockRepurchase', data: repurchase }); + +const clientRatio = client.OpenCapTable.stockClassConversionRatioAdjustment.get({ contractId: 'contract-id' }); +const clientSplit = client.OpenCapTable.stockClassSplit.get({ contractId: 'contract-id' }); +const clientConsolidation = client.OpenCapTable.getByObjectType({ + objectType: 'TX_STOCK_CONSOLIDATION', + contractId: 'contract-id', +}); +const clientReissuance = client.OpenCapTable.getByObjectType({ + objectType: 'TX_STOCK_REISSUANCE', + contractId: 'contract-id', +}); +const clientRepurchase = client.OpenCapTable.stockRepurchase.get({ contractId: 'contract-id' }); + +const builtWriterTypesAreExact: Assert< + EveryTrue< + [ + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + ] + > +> = true; +const builtBatchTypesAreExact: Assert< + EveryTrue< + [ + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + ] + > +> = true; +const builtClientTypesAreExact: Assert< + EveryTrue< + [ + IsExactly['data'], OcfReadDataTypeFor<'stockClassConversionRatioAdjustment'>>, + IsExactly['data'], OcfReadDataTypeFor<'stockClassSplit'>>, + IsExactly['data'], OcfReadDataTypeFor<'stockConsolidation'>>, + IsExactly['data'], OcfReadDataTypeFor<'stockReissuance'>>, + IsExactly['data'], OcfReadDataTypeFor<'stockRepurchase'>>, + IsExactly['data']>, false>, + ] + > +> = true; +const builtCardinalityAndScalarTypesAreExact: Assert< + EveryTrue< + [ + IsExactly, + IsExactly, + IsExactly, + IsExactly>, never>, + IsExactly>, never>, + IsExactly>, never>, + IsExactly>, never>, + IsExactly>, never>, + ] + > +> = true; + +// @ts-expect-error built reissuance resulting_security_ids are statically non-empty +const emptyReissuanceResults: OcfStockReissuance['resulting_security_ids'] = []; +// @ts-expect-error built consolidation security_ids are statically non-empty +const emptyConsolidationSources: OcfStockConsolidation['security_ids'] = []; +declare const clientRatioData: Awaited['data']; +declare const clientReissuanceData: Awaited['data']; +// @ts-expect-error built generic writers preserve entity/data correlation +convertToDaml('stockClassSplit', consolidation); +// @ts-expect-error built operation writers preserve entity/data correlation +convertOperationToDaml({ type: 'stockRepurchase', data: reissuance }); +// @ts-expect-error built batch writers preserve entity/data correlation +buildOcfCreateData('stockConsolidation', split); +// @ts-expect-error built public namespace reads are recursively readonly +clientRatioData.new_ratio_conversion_mechanism.conversion_price.amount = '2'; +// @ts-expect-error built public object-type reads expose readonly non-empty tuples +clientReissuanceData.resulting_security_ids.push('other'); + +void builtWriterTypesAreExact; +void builtBatchTypesAreExact; +void builtClientTypesAreExact; +void builtCardinalityAndScalarTypesAreExact; +void directRatio; +void directSplit; +void directConsolidation; +void directReissuance; +void directRepurchase; +void genericRatio; +void genericSplit; +void genericConsolidation; +void genericReissuance; +void genericRepurchase; +void operationRatio; +void operationSplit; +void operationConsolidation; +void operationReissuance; +void operationRepurchase; +void createRatio; +void createSplit; +void createConsolidation; +void editReissuance; +void editRepurchase; +void clientRatio; +void clientSplit; +void clientConsolidation; +void clientReissuance; +void clientRepurchase; +void emptyReissuanceResults; +void emptyConsolidationSources; diff --git a/test/functions/conversionExerciseReaders.test.ts b/test/functions/conversionExerciseReaders.test.ts index 4ba20b1a..43fadb21 100644 --- a/test/functions/conversionExerciseReaders.test.ts +++ b/test/functions/conversionExerciseReaders.test.ts @@ -677,21 +677,6 @@ describe('preserved conversion and exercise semantic invariants', () => { expect(result.event.comments?.[0]).not.toBe('mutated-comment'); }); - it.each([ - [0, 'quantity_converted', 'convertibleConversion.quantity_converted'], - [1, 'quantity_converted', 'stockConversion.quantity_converted'], - [2, 'quantity', 'equityCompensationExercise.quantity'], - [3, 'quantity', 'warrantExercise.quantity'], - ] as const)('reader case %i rejects a semantically invalid %s', async (caseIndex, field, fieldPath) => { - const testCase = readerCases[caseIndex]; - if (!testCase) throw new Error(`Missing reader case ${caseIndex}`); - const { client } = createMockClient(testCase, { ...testCase.validData(), [field]: 'NaN' }); - - const error = await captureRejection(async () => testCase.invoke(client)); - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath }); - }); - it.each([ [0, 'quantity_converted', '1e-2', 'quantity_converted', '0.01'], [1, 'quantity_converted', '1e2', 'quantity_converted', '100'], diff --git a/test/functions/generatedDamlNumericReaders.test.ts b/test/functions/generatedDamlNumericReaders.test.ts index c4f058aa..13cf28a5 100644 --- a/test/functions/generatedDamlNumericReaders.test.ts +++ b/test/functions/generatedDamlNumericReaders.test.ts @@ -258,21 +258,25 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { }); it.each([ - ['quantity', { quantity: '1e2' }, 'stockIssuance.quantity'], - ['share price', { share_price: { amount: '-0e20', currency: 'USD' } }, 'stockIssuance.share_price.amount'], - ['cost basis', { cost_basis: { amount: '12.34e-1', currency: 'EUR' } }, 'stockIssuance.cost_basis.amount'], + ['quantity', { quantity: '1e2' }, ['quantity'], '100'], + ['share price', { share_price: { amount: '-0e20', currency: 'USD' } }, ['share_price', 'amount'], '0'], + ['cost basis', { cost_basis: { amount: '12.34e-1', currency: 'EUR' } }, ['cost_basis', 'amount'], '1.234'], [ 'vesting amount', { vestings: [{ date: '2026-02-01T00:00:00Z', amount: '1e-10' }] }, - 'stockIssuance.vestings[0].amount', + ['vestings', '0', 'amount'], + '0.0000000001', ], [ 'share number', { share_numbers_issued: [{ starting_share_number: '1e0', ending_share_number: '2' }] }, - 'stockIssuance.share_numbers_issued[0].starting_share_number', + ['share_numbers_issued', '0', 'starting_share_number'], + '1', ], - ] as const)('accepts and canonicalizes exponent-form stock issuance %s', (_name, override, _fieldPath) => { - expect(() => damlStockIssuanceDataToNative(invalidStockIssuance(override))).not.toThrow(); + ] as const)('canonicalizes generated exponent-form stock issuance %s', (_name, override, path, expected) => { + let value: unknown = damlStockIssuanceDataToNative(invalidStockIssuance(override)); + for (const key of path) value = (value as Record)[key]; + expect(value).toBe(expected); }); it('matches the installed generated Numeric codec exponent identity behavior', () => { diff --git a/test/functions/stockCorporateActionReaders.test.ts b/test/functions/stockCorporateActionReaders.test.ts new file mode 100644 index 00000000..acf09d4d --- /dev/null +++ b/test/functions/stockCorporateActionReaders.test.ts @@ -0,0 +1,1008 @@ +/** Direct ledger-reader contracts for stock corporate-action transactions. */ + +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { + ENTITY_DATA_FIELD_MAP, + ENTITY_TEMPLATE_ID_MAP, + type OcfEntityType, + type OcfReadDataTypeFor, +} from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { convertToOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import { stockClassAuthorizedSharesAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/createStockClassAuthorizedSharesAdjustment'; +import { damlStockClassConversionRatioAdjustmentToNative } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import { getStockClassConversionRatioAdjustmentAsOcf } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf'; +import { stockClassConversionRatioAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml'; +import { damlStockClassSplitToNative } from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; +import { getStockClassSplitAsOcf } from '../../src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf'; +import { stockClassSplitDataToDaml } from '../../src/functions/OpenCapTable/stockClassSplit/stockClassSplitDataToDaml'; +import { damlStockConsolidationToNative } from '../../src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation'; +import { getStockConsolidationAsOcf } from '../../src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf'; +import { stockConsolidationDataToDaml } from '../../src/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml'; +import { damlStockReissuanceToNative } from '../../src/functions/OpenCapTable/stockReissuance/damlToStockReissuance'; +import { getStockReissuanceAsOcf } from '../../src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf'; +import { stockReissuanceDataToDaml } from '../../src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml'; +import { damlStockRepurchaseToNative } from '../../src/functions/OpenCapTable/stockRepurchase/damlToOcf'; +import { getStockRepurchaseAsOcf } from '../../src/functions/OpenCapTable/stockRepurchase/getStockRepurchaseAsOcf'; +import { stockRepurchaseDataToDaml } from '../../src/functions/OpenCapTable/stockRepurchase/stockRepurchaseDataToDaml'; +import type { RoundingType } from '../../src/types/native'; + +type StockCorporateActionEntityType = Extract< + OcfEntityType, + | 'stockClassConversionRatioAdjustment' + | 'stockClassSplit' + | 'stockConsolidation' + | 'stockReissuance' + | 'stockRepurchase' +>; + +type StockCorporateAction = OcfReadDataTypeFor; + +const canonicalContext = { + issuer: 'issuer::party', + system_operator: 'system-operator::party', +} as const; + +function conversionRatioAdjustmentData(roundingType: RoundingType = 'NORMAL'): Record { + return stockClassConversionRatioAdjustmentDataToDaml({ + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'conversion-ratio-adjustment-1', + date: '2026-07-10', + stock_class_id: 'preferred-stock-class-1', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '2.2500', currency: 'USD' }, + ratio: { numerator: '3.0000', denominator: '2.0000' }, + rounding_type: roundingType, + }, + comments: ['anti-dilution adjustment'], + }); +} + +function stockClassSplitData(): Record { + return stockClassSplitDataToDaml({ + object_type: 'TX_STOCK_CLASS_SPLIT', + id: 'stock-class-split-1', + date: '2026-07-10', + stock_class_id: 'common-stock-class-1', + split_ratio: { numerator: '4.0000', denominator: '1.0000' }, + comments: ['four-for-one split'], + }); +} + +function stockConsolidationData(): Record { + return stockConsolidationDataToDaml({ + object_type: 'TX_STOCK_CONSOLIDATION', + id: 'stock-consolidation-1', + date: '2026-07-10', + security_ids: ['security-old-1', 'security-old-2'], + resulting_security_id: 'security-consolidated-1', + reason_text: 'Reverse split cleanup', + comments: ['securities consolidated'], + }); +} + +function stockReissuanceData(): Record { + return stockReissuanceDataToDaml({ + object_type: 'TX_STOCK_REISSUANCE', + id: 'stock-reissuance-1', + date: '2026-07-10', + security_id: 'security-retired-1', + resulting_security_ids: ['security-new-1', 'security-new-2'], + reason_text: 'Certificate replacement', + split_transaction_id: 'stock-class-split-1', + comments: ['security reissued'], + }); +} + +function stockRepurchaseData(): Record { + return stockRepurchaseDataToDaml({ + object_type: 'TX_STOCK_REPURCHASE', + id: 'stock-repurchase-1', + date: '2026-07-10', + security_id: 'security-repurchased-1', + quantity: '12.5000', + price: { amount: '2.2500', currency: 'USD' }, + balance_security_id: 'security-balance-1', + consideration_text: 'Cash repurchase', + comments: ['shares repurchased'], + }); +} + +interface StockCorporateActionReaderCase { + readonly entityType: StockCorporateActionEntityType; + readonly contractId: string; + readonly requiredScalar: string; + readonly validData: () => Record; + readonly expectedEvent: StockCorporateAction; + readonly directRead: (data: Record) => StockCorporateAction; + readonly invoke: ( + client: LedgerJsonApiClient, + readAs?: string[] + ) => Promise<{ readonly event: StockCorporateAction; readonly contractId: string }>; +} + +const conversionRatioAdjustmentCase: StockCorporateActionReaderCase = { + entityType: 'stockClassConversionRatioAdjustment', + contractId: 'conversion-ratio-adjustment-cid', + requiredScalar: 'stock_class_id', + validData: conversionRatioAdjustmentData, + expectedEvent: { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'conversion-ratio-adjustment-1', + date: '2026-07-10', + stock_class_id: 'preferred-stock-class-1', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '2.25', currency: 'USD' }, + ratio: { numerator: '3', denominator: '2' }, + rounding_type: 'NORMAL', + }, + comments: ['anti-dilution adjustment'], + }, + directRead: (data) => + damlStockClassConversionRatioAdjustmentToNative( + data as Parameters[0] + ), + invoke: async (client, readAs) => + getStockClassConversionRatioAdjustmentAsOcf(client, { + contractId: 'conversion-ratio-adjustment-cid', + ...(readAs !== undefined ? { readAs } : {}), + }), +}; + +const stockClassSplitCase: StockCorporateActionReaderCase = { + entityType: 'stockClassSplit', + contractId: 'stock-class-split-cid', + requiredScalar: 'stock_class_id', + validData: stockClassSplitData, + expectedEvent: { + object_type: 'TX_STOCK_CLASS_SPLIT', + id: 'stock-class-split-1', + date: '2026-07-10', + stock_class_id: 'common-stock-class-1', + split_ratio: { numerator: '4', denominator: '1' }, + comments: ['four-for-one split'], + }, + directRead: (data) => damlStockClassSplitToNative(data as Parameters[0]), + invoke: async (client, readAs) => + getStockClassSplitAsOcf(client, { + contractId: 'stock-class-split-cid', + ...(readAs !== undefined ? { readAs } : {}), + }), +}; + +const stockConsolidationCase: StockCorporateActionReaderCase = { + entityType: 'stockConsolidation', + contractId: 'stock-consolidation-cid', + requiredScalar: 'resulting_security_id', + validData: stockConsolidationData, + expectedEvent: { + object_type: 'TX_STOCK_CONSOLIDATION', + id: 'stock-consolidation-1', + date: '2026-07-10', + security_ids: ['security-old-1', 'security-old-2'], + resulting_security_id: 'security-consolidated-1', + reason_text: 'Reverse split cleanup', + comments: ['securities consolidated'], + }, + directRead: (data) => damlStockConsolidationToNative(data as Parameters[0]), + invoke: async (client, readAs) => + getStockConsolidationAsOcf(client, { + contractId: 'stock-consolidation-cid', + ...(readAs !== undefined ? { readAs } : {}), + }), +}; + +const stockReissuanceCase: StockCorporateActionReaderCase = { + entityType: 'stockReissuance', + contractId: 'stock-reissuance-cid', + requiredScalar: 'security_id', + validData: stockReissuanceData, + expectedEvent: { + object_type: 'TX_STOCK_REISSUANCE', + id: 'stock-reissuance-1', + date: '2026-07-10', + security_id: 'security-retired-1', + resulting_security_ids: ['security-new-1', 'security-new-2'], + reason_text: 'Certificate replacement', + split_transaction_id: 'stock-class-split-1', + comments: ['security reissued'], + }, + directRead: (data) => damlStockReissuanceToNative(data as Parameters[0]), + invoke: async (client, readAs) => + getStockReissuanceAsOcf(client, { + contractId: 'stock-reissuance-cid', + ...(readAs !== undefined ? { readAs } : {}), + }), +}; + +const stockRepurchaseCase: StockCorporateActionReaderCase = { + entityType: 'stockRepurchase', + contractId: 'stock-repurchase-cid', + requiredScalar: 'security_id', + validData: stockRepurchaseData, + expectedEvent: { + object_type: 'TX_STOCK_REPURCHASE', + id: 'stock-repurchase-1', + date: '2026-07-10', + security_id: 'security-repurchased-1', + quantity: '12.5', + price: { amount: '2.25', currency: 'USD' }, + balance_security_id: 'security-balance-1', + consideration_text: 'Cash repurchase', + comments: ['shares repurchased'], + }, + directRead: (data) => damlStockRepurchaseToNative(data as Parameters[0]), + invoke: async (client, readAs) => + getStockRepurchaseAsOcf(client, { + contractId: 'stock-repurchase-cid', + ...(readAs !== undefined ? { readAs } : {}), + }), +}; + +const stockCorporateActionCases = [ + conversionRatioAdjustmentCase, + stockClassSplitCase, + stockConsolidationCase, + stockReissuanceCase, + stockRepurchaseCase, +] as const; + +function createMockClient( + testCase: StockCorporateActionReaderCase, + data: unknown, + options: { + readonly createArgument?: unknown; + /** Null deliberately omits ledger template identity. */ + readonly templateId?: string | null; + } = {} +): { readonly client: LedgerJsonApiClient; readonly getEventsByContractId: jest.Mock } { + const createArgument = Object.prototype.hasOwnProperty.call(options, 'createArgument') + ? options.createArgument + : { context: canonicalContext, [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: data }; + const templateId = + options.templateId === undefined ? ENTITY_TEMPLATE_ID_MAP[testCase.entityType] : options.templateId; + const getEventsByContractId = jest.fn().mockResolvedValue({ + created: { + createdEvent: { + contractId: testCase.contractId, + ...(templateId !== null ? { templateId } : {}), + createArgument, + }, + }, + }); + return { + client: { getEventsByContractId } as unknown as LedgerJsonApiClient, + getEventsByContractId, + }; +} + +function expectDecoderFailure(error: unknown, testCase: StockCorporateActionReaderCase, field: string): void { + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(parseError.context?.entityType).toBe(testCase.entityType); + expect(typeof parseError.context?.decoderPath).toBe('string'); + expect(JSON.stringify(parseError.context)).toContain(field); +} + +async function captureRejection(promise: Promise): Promise { + try { + await promise; + } catch (error: unknown) { + return error; + } + throw new Error('Expected promise to reject'); +} + +async function expectDecoderRejection( + testCase: StockCorporateActionReaderCase, + data: unknown, + field: string +): Promise { + const { client } = createMockClient(testCase, data); + try { + await testCase.invoke(client); + throw new Error(`Expected ${testCase.entityType} reader to reject malformed ${field}`); + } catch (error: unknown) { + expectDecoderFailure(error, testCase, field); + } +} + +function setPath(data: Record, path: readonly string[], value: unknown): Record { + const finalKey = path[path.length - 1]; + if (!finalKey) throw new Error('Mutation path must not be empty'); + let target = data; + for (const key of path.slice(0, -1)) { + const nested = target[key]; + if (nested === null || typeof nested !== 'object' || Array.isArray(nested)) { + throw new Error(`Fixture path ${path.join('.')} is not an object`); + } + target = nested as Record; + } + target[finalKey] = value; + return data; +} + +function deletePath(data: Record, path: readonly string[]): Record { + const finalKey = path[path.length - 1]; + if (!finalKey) throw new Error('Deletion path must not be empty'); + let target = data; + for (const key of path.slice(0, -1)) { + const nested = target[key]; + if (nested === null || typeof nested !== 'object' || Array.isArray(nested)) { + throw new Error(`Fixture path ${path.join('.')} is not an object`); + } + target = nested as Record; + } + delete target[finalKey]; + return data; +} + +function getPath(data: unknown, path: readonly string[]): unknown { + let value = data; + for (const key of path) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + value = (value as Record)[key]; + } + return value; +} + +describe('decoder-backed stock corporate-action readers', () => { + it.each(stockCorporateActionCases)( + '$entityType returns the same exact event through direct and dispatcher conversion', + (testCase) => { + const data = testCase.validData(); + expect(testCase.directRead(data)).toEqual(testCase.expectedEvent); + expect(convertToOcf(testCase.entityType, data as never)).toEqual(testCase.expectedEvent); + } + ); + + it.each(stockCorporateActionCases)( + '$entityType returns its exact canonical event and forwards readAs', + async (testCase) => { + const { client, getEventsByContractId } = createMockClient(testCase, testCase.validData()); + + await expect(testCase.invoke(client, ['issuer::reader'])).resolves.toEqual({ + event: testCase.expectedEvent, + contractId: testCase.contractId, + }); + expect(getEventsByContractId).toHaveBeenCalledWith({ + contractId: testCase.contractId, + readAs: ['issuer::reader'], + }); + } + ); + + it.each(stockCorporateActionCases)( + '$entityType rejects fields outside its exact canonical wrapper', + async (testCase) => { + const canonicalData = testCase.validData(); + const { client } = createMockClient(testCase, canonicalData, { + createArgument: { + context: canonicalContext, + ...canonicalData, + id: 'wrong-top-level-id', + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: canonicalData, + }, + }); + + await expect(testCase.invoke(client)).rejects.toBeInstanceOf(OcpParseError); + } + ); + + it.each(stockCorporateActionCases)('$entityType rejects malformed transaction dates', async (testCase) => { + await expectDecoderRejection(testCase, { ...testCase.validData(), date: 17 }, 'date'); + }); + + it.each(stockCorporateActionCases)('$entityType rejects missing transaction dates', async (testCase) => { + const data = testCase.validData(); + delete data.date; + await expectDecoderRejection(testCase, data, 'date'); + }); + + it.each(stockCorporateActionCases)('$entityType rejects a missing transaction id', async (testCase) => { + const data = testCase.validData(); + delete data.id; + await expectDecoderRejection(testCase, data, 'id'); + }); + + it.each(stockCorporateActionCases)('$entityType rejects a malformed transaction id', async (testCase) => { + await expectDecoderRejection(testCase, { ...testCase.validData(), id: 17 }, 'id'); + }); + + it.each(stockCorporateActionCases)('$entityType rejects semantically invalid transaction dates', async (testCase) => { + const { client } = createMockClient(testCase, { ...testCase.validData(), date: '2026-99-99' }); + + const error = await captureRejection(testCase.invoke(client)); + expect(error).toBeInstanceOf(OcpValidationError); + expect((error as OcpValidationError).code).toBe(OcpErrorCodes.INVALID_FORMAT); + }); + + it.each(stockCorporateActionCases)('$entityType rejects malformed comments', async (testCase) => { + await expectDecoderRejection(testCase, { ...testCase.validData(), comments: ['valid', 17] }, 'comments'); + }); + + it.each(stockCorporateActionCases)('$entityType rejects a missing required comments list', async (testCase) => { + const data = testCase.validData(); + delete data.comments; + await expectDecoderRejection(testCase, data, 'comments'); + }); + + it.each(stockCorporateActionCases)('$entityType omits empty comments canonically', async (testCase) => { + const { client } = createMockClient(testCase, { ...testCase.validData(), comments: [] }); + const result = await testCase.invoke(client); + + expect(result.event.comments).toBeUndefined(); + expect('comments' in result.event).toBe(false); + }); + + it.each(stockCorporateActionCases)('$entityType rejects a missing required scalar', async (testCase) => { + const data = testCase.validData(); + delete data[testCase.requiredScalar]; + await expectDecoderRejection(testCase, data, testCase.requiredScalar); + }); + + it.each(stockCorporateActionCases)('$entityType rejects a malformed required scalar', async (testCase) => { + await expectDecoderRejection( + testCase, + { ...testCase.validData(), [testCase.requiredScalar]: 17 }, + testCase.requiredScalar + ); + }); + + it.each(stockCorporateActionCases)( + '$entityType rejects fields discarded by the generated codec', + async (testCase) => { + const { client } = createMockClient(testCase, { ...testCase.validData(), unexpected_field: true }); + + const error = await captureRejection(testCase.invoke(client)); + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(parseError.context).toMatchObject({ + entityType: testCase.entityType, + decoderPath: `input.${ENTITY_DATA_FIELD_MAP[testCase.entityType]}.unexpected_field`, + decoderMessage: 'raw field was discarded by the generated codec', + }); + } + ); + + it.each(stockCorporateActionCases)('$entityType rejects a missing canonical wrapper', async (testCase) => { + const { client } = createMockClient(testCase, testCase.validData(), { + createArgument: { context: canonicalContext }, + }); + + const error = await captureRejection(testCase.invoke(client)); + expect(error).toBeInstanceOf(OcpParseError); + expect((error as OcpParseError).code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect((error as Error).message).toContain(ENTITY_DATA_FIELD_MAP[testCase.entityType]); + }); + + it.each(stockCorporateActionCases)('$entityType rejects whole-createArgument data lookalikes', async (testCase) => { + const { client } = createMockClient(testCase, testCase.validData(), { + createArgument: { context: canonicalContext, ...testCase.validData() }, + }); + + const error = await captureRejection(testCase.invoke(client)); + expect(error).toBeInstanceOf(OcpParseError); + expect((error as OcpParseError).code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + }); + + it.each(stockCorporateActionCases)('$entityType rejects non-object nested data', async (testCase) => { + const { client } = createMockClient(testCase, []); + + const error = await captureRejection(testCase.invoke(client)); + expect(error).toBeInstanceOf(OcpParseError); + expect((error as OcpParseError).code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + }); + + it.each(stockCorporateActionCases)('$entityType rejects a missing ledger template identity', async (testCase) => { + const { client } = createMockClient(testCase, testCase.validData(), { templateId: null }); + + const error = (await captureRejection(testCase.invoke(client))) as OcpParseError; + expect(error.name).toBe('OcpContractError'); + expect(error.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(error.classification).toBe('missing_template_id'); + expect(error.context?.expectedTemplateId).toBe(ENTITY_TEMPLATE_ID_MAP[testCase.entityType]); + }); + + it.each(stockCorporateActionCases)('$entityType rejects a contract from the wrong template', async (testCase) => { + const wrongTemplateId = ENTITY_TEMPLATE_ID_MAP.document; + const { client } = createMockClient(testCase, testCase.validData(), { templateId: wrongTemplateId }); + + const error = (await captureRejection(testCase.invoke(client))) as OcpParseError; + expect(error.name).toBe('OcpContractError'); + expect(error.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(error.classification).toBe('module_entity_mismatch'); + expect(error.context).toMatchObject({ + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[testCase.entityType], + actualTemplateId: wrongTemplateId, + }); + }); + + it.each(stockCorporateActionCases)('$entityType rejects an empty transaction id', async (testCase) => { + const { client } = createMockClient(testCase, { ...testCase.validData(), id: '' }); + + const error = (await captureRejection(testCase.invoke(client))) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.code).toBe(OcpErrorCodes.INVALID_FORMAT); + expect(error.fieldPath).toBe(`${testCase.entityType}.id`); + }); + + it.each(stockCorporateActionCases)('$entityType rejects an empty required identifier', async (testCase) => { + const { client } = createMockClient(testCase, { + ...testCase.validData(), + [testCase.requiredScalar]: '', + }); + + const error = (await captureRejection(testCase.invoke(client))) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.code).toBe(OcpErrorCodes.INVALID_FORMAT); + expect(error.fieldPath).toBe(`${testCase.entityType}.${testCase.requiredScalar}`); + }); + + it.each(stockCorporateActionCases)('$entityType rejects an empty comment item', async (testCase) => { + const { client } = createMockClient(testCase, { ...testCase.validData(), comments: [''] }); + + const error = (await captureRejection(testCase.invoke(client))) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.code).toBe(OcpErrorCodes.INVALID_FORMAT); + expect(error.fieldPath).toBe(`${testCase.entityType}.comments[0]`); + }); + + it.each(stockCorporateActionCases)('$entityType validates both Canton Party IDs', async (testCase) => { + const { client } = createMockClient(testCase, testCase.validData(), { + createArgument: { + context: { issuer: 'issuer party', system_operator: canonicalContext.system_operator }, + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: testCase.validData(), + }, + }); + + const error = (await captureRejection(testCase.invoke(client))) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.fieldPath).toBe(`damlToOcf.${testCase.entityType}.createArgument.context.issuer`); + }); + + it.each(stockCorporateActionCases)( + '$entityType returns a detached, recursively frozen event and result', + async (testCase) => { + const data = testCase.validData(); + const { client } = createMockClient(testCase, data); + const result = await testCase.invoke(client); + + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.event)).toBe(true); + if (result.event.comments !== undefined) { + expect(Object.isFrozen(result.event.comments)).toBe(true); + } + for (const value of Object.values(result.event)) { + if (value !== null && typeof value === 'object') expect(Object.isFrozen(value)).toBe(true); + } + + const rawComments = data.comments as string[]; + rawComments[0] = 'mutated after read'; + expect(result.event.comments?.[0]).toBe(testCase.expectedEvent.comments?.[0]); + } + ); + + it('bounds hostile direct-reader depth before generated decoding', () => { + let nested: Record = { leaf: true }; + for (let depth = 0; depth < 110; depth += 1) nested = { child: nested }; + const data = { ...stockClassSplitData(), hostile: nested }; + + expect(() => stockClassSplitCase.directRead(data)).toThrow(OcpParseError); + }); + + it.each(stockCorporateActionCases)( + '$entityType rejects proxied entity data without invoking traps', + async (testCase) => { + let trapCalls = 0; + const failTrap = (): never => { + trapCalls += 1; + throw new Error('entity-data proxy trap must not run'); + }; + const data = new Proxy(testCase.validData(), { + get: failTrap, + getOwnPropertyDescriptor: failTrap, + getPrototypeOf: failTrap, + has: failTrap, + ownKeys: failTrap, + }); + const { client } = createMockClient(testCase, data); + + const error = await captureRejection(testCase.invoke(client)); + expect(error).toBeInstanceOf(OcpParseError); + expect((error as OcpParseError).code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(trapCalls).toBe(0); + } + ); + + it.each(stockCorporateActionCases)( + '$entityType rejects entity-data accessors without invoking getters', + async (testCase) => { + let getterCalls = 0; + const data = testCase.validData(); + Object.defineProperty(data, 'id', { + configurable: true, + enumerable: true, + get() { + getterCalls += 1; + throw new Error('entity-data getter must not run'); + }, + }); + const { client } = createMockClient(testCase, data); + + const error = await captureRejection(testCase.invoke(client)); + expect(error).toBeInstanceOf(OcpParseError); + expect((error as OcpParseError).code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(getterCalls).toBe(0); + } + ); + + it.each(stockCorporateActionCases)('$entityType does not assimilate hostile response thenables', async (testCase) => { + let thenGetterCalls = 0; + const response = { + created: { + createdEvent: { + contractId: testCase.contractId, + templateId: ENTITY_TEMPLATE_ID_MAP[testCase.entityType], + createArgument: { + context: canonicalContext, + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: testCase.validData(), + }, + }, + }, + }; + Object.defineProperty(response, 'then', { + configurable: true, + enumerable: true, + get() { + thenGetterCalls += 1; + throw new Error('response then getter must not run'); + }, + }); + const client = { + getEventsByContractId: jest.fn(() => response), + } as unknown as LedgerJsonApiClient; + + const error = await captureRejection(testCase.invoke(client)); + expect(error).toBeInstanceOf(OcpParseError); + expect((error as OcpParseError).code).toBe(OcpErrorCodes.INVALID_RESPONSE); + expect(thenGetterCalls).toBe(0); + }); +}); + +describe('numeric and nested stock corporate-action fields', () => { + const numericFields = [ + { + label: 'conversion price amount', + testCase: conversionRatioAdjustmentCase, + path: ['new_ratio_conversion_mechanism', 'conversion_price', 'amount'], + }, + { + label: 'conversion ratio numerator', + testCase: conversionRatioAdjustmentCase, + path: ['new_ratio_conversion_mechanism', 'ratio', 'numerator'], + }, + { + label: 'conversion ratio denominator', + testCase: conversionRatioAdjustmentCase, + path: ['new_ratio_conversion_mechanism', 'ratio', 'denominator'], + }, + { label: 'split numerator', testCase: stockClassSplitCase, path: ['split_ratio', 'numerator'] }, + { label: 'split denominator', testCase: stockClassSplitCase, path: ['split_ratio', 'denominator'] }, + { label: 'repurchase quantity', testCase: stockRepurchaseCase, path: ['quantity'] }, + { label: 'repurchase price amount', testCase: stockRepurchaseCase, path: ['price', 'amount'] }, + ] as const; + + it.each(numericFields)('rejects a numeric primitive for $label', async ({ testCase, path }) => { + await expectDecoderRejection(testCase, setPath(testCase.validData(), path, 17), path[path.length - 1] ?? 'numeric'); + }); + + it.each(numericFields)('accepts generated exponent syntax for $label', async ({ testCase, path }) => { + const { client } = createMockClient(testCase, setPath(testCase.validData(), path, '1e3')); + + const result = await testCase.invoke(client); + expect(getPath(result.event, path)).toBe('1000'); + }); + + it.each(numericFields)('rejects an invalid numeric string for $label', async ({ testCase, path }) => { + const { client } = createMockClient(testCase, setPath(testCase.validData(), path, 'not-a-number')); + + const error = await captureRejection(testCase.invoke(client)); + expect(error).toBeInstanceOf(OcpValidationError); + const validationError = error as OcpValidationError; + expect(validationError.code).toBe(OcpErrorCodes.INVALID_FORMAT); + expect(validationError.fieldPath).toBe(`${testCase.entityType}.${path.join('.')}`); + }); + + const requiredNestedFields = [ + { + label: 'complete conversion mechanism', + testCase: conversionRatioAdjustmentCase, + path: ['new_ratio_conversion_mechanism'], + }, + { + label: 'conversion price', + testCase: conversionRatioAdjustmentCase, + path: ['new_ratio_conversion_mechanism', 'conversion_price'], + }, + { + label: 'conversion price currency', + testCase: conversionRatioAdjustmentCase, + path: ['new_ratio_conversion_mechanism', 'conversion_price', 'currency'], + }, + { + label: 'conversion ratio', + testCase: conversionRatioAdjustmentCase, + path: ['new_ratio_conversion_mechanism', 'ratio'], + }, + { + label: 'conversion ratio denominator', + testCase: conversionRatioAdjustmentCase, + path: ['new_ratio_conversion_mechanism', 'ratio', 'denominator'], + }, + { label: 'split ratio', testCase: stockClassSplitCase, path: ['split_ratio'] }, + { label: 'split ratio numerator', testCase: stockClassSplitCase, path: ['split_ratio', 'numerator'] }, + { label: 'repurchase quantity', testCase: stockRepurchaseCase, path: ['quantity'] }, + { label: 'repurchase price', testCase: stockRepurchaseCase, path: ['price'] }, + { label: 'repurchase price currency', testCase: stockRepurchaseCase, path: ['price', 'currency'] }, + ] as const; + + it.each(requiredNestedFields)('rejects a missing $label', async ({ testCase, path }) => { + await expectDecoderRejection(testCase, deletePath(testCase.validData(), path), path[path.length - 1] ?? 'nested'); + }); + + it.each([ + { + label: 'consolidation security IDs', + testCase: stockConsolidationCase, + field: 'security_ids', + }, + { + label: 'reissuance resulting security IDs', + testCase: stockReissuanceCase, + field: 'resulting_security_ids', + }, + ] as const)('rejects a missing required $label list', async ({ testCase, field }) => { + const data = testCase.validData(); + delete data[field]; + await expectDecoderRejection(testCase, data, field); + }); + + it.each([ + { + label: 'consolidation security IDs', + testCase: stockConsolidationCase, + field: 'security_ids', + }, + { + label: 'reissuance resulting security IDs', + testCase: stockReissuanceCase, + field: 'resulting_security_ids', + }, + ] as const)('rejects malformed nested values in the $label list', async ({ testCase, field }) => { + await expectDecoderRejection(testCase, { ...testCase.validData(), [field]: ['valid-id', 17] }, field); + }); +}); + +describe('conversion-ratio rounding and shared-wrapper isolation', () => { + it.each([ + ['OcfRoundingNormal', 'NORMAL'], + ['OcfRoundingCeiling', 'CEILING'], + ['OcfRoundingFloor', 'FLOOR'], + ] as const)('maps %s to %s', async (damlRoundingType, nativeRoundingType) => { + const data = setPath( + conversionRatioAdjustmentData(), + ['new_ratio_conversion_mechanism', 'rounding_type'], + damlRoundingType + ); + const { client } = createMockClient(conversionRatioAdjustmentCase, data); + + const result = await conversionRatioAdjustmentCase.invoke(client); + if (result.event.object_type !== 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT') { + throw new Error('Expected a conversion-ratio adjustment event'); + } + expect(result.event.new_ratio_conversion_mechanism.rounding_type).toBe(nativeRoundingType); + }); + + it('rejects an unsupported generated rounding variant', async () => { + const data = setPath( + conversionRatioAdjustmentData(), + ['new_ratio_conversion_mechanism', 'rounding_type'], + 'OcfRoundingBankers' + ); + + await expectDecoderRejection(conversionRatioAdjustmentCase, data, 'rounding_type'); + }); + + it('does not confuse another adjustment family sharing adjustment_data', async () => { + const authorizedSharesData = stockClassAuthorizedSharesAdjustmentDataToDaml({ + object_type: 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT', + id: 'authorized-shares-adjustment-1', + date: '2026-07-10', + stock_class_id: 'preferred-stock-class-1', + new_shares_authorized: '1000000', + }); + const { client } = createMockClient(conversionRatioAdjustmentCase, authorizedSharesData, { + templateId: ENTITY_TEMPLATE_ID_MAP.stockClassAuthorizedSharesAdjustment, + }); + + const error = (await captureRejection(conversionRatioAdjustmentCase.invoke(client))) as OcpParseError; + expect(error.name).toBe('OcpContractError'); + expect(error.classification).toBe('module_entity_mismatch'); + expect(error.context).toMatchObject({ + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockClassConversionRatioAdjustment, + actualTemplateId: ENTITY_TEMPLATE_ID_MAP.stockClassAuthorizedSharesAdjustment, + }); + }); + + it('rejects another adjustment family even under the expected template identity', async () => { + const authorizedSharesData = stockClassAuthorizedSharesAdjustmentDataToDaml({ + object_type: 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT', + id: 'authorized-shares-adjustment-1', + date: '2026-07-10', + stock_class_id: 'preferred-stock-class-1', + new_shares_authorized: '1000000', + }); + + await expectDecoderRejection(conversionRatioAdjustmentCase, authorizedSharesData, 'new_ratio_conversion_mechanism'); + }); +}); + +describe('optional stock corporate-action fields', () => { + const optionalFields = [ + { testCase: stockConsolidationCase, field: 'reason_text' }, + { testCase: stockReissuanceCase, field: 'reason_text' }, + { testCase: stockReissuanceCase, field: 'split_transaction_id' }, + { testCase: stockRepurchaseCase, field: 'balance_security_id' }, + { testCase: stockRepurchaseCase, field: 'consideration_text' }, + ] as const; + + it.each(optionalFields)( + '$testCase.entityType accepts omitted $field through generated null defaults', + async ({ testCase, field }) => { + const data = testCase.validData(); + delete data[field]; + const { client } = createMockClient(testCase, data); + + const result = await testCase.invoke(client); + const event = result.event as unknown as Record; + expect(event[field]).toBeUndefined(); + expect(field in event).toBe(false); + } + ); + + it.each(optionalFields)('$testCase.entityType omits null $field canonically', async ({ testCase, field }) => { + const { client } = createMockClient(testCase, { ...testCase.validData(), [field]: null }); + + const result = await testCase.invoke(client); + const event = result.event as unknown as Record; + expect(event[field]).toBeUndefined(); + expect(field in event).toBe(false); + }); + + it.each(optionalFields)('$testCase.entityType rejects a present empty Text $field', async ({ testCase, field }) => { + const { client } = createMockClient(testCase, { ...testCase.validData(), [field]: '' }); + + const error = (await captureRejection(testCase.invoke(client))) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.code).toBe(OcpErrorCodes.INVALID_FORMAT); + expect(error.fieldPath).toBe(`${testCase.entityType}.${field}`); + }); + + it.each(optionalFields)( + '$testCase.entityType rejects malformed optional $field losslessly', + async ({ testCase, field }) => { + const { client } = createMockClient(testCase, { + ...testCase.validData(), + [field]: { malformed: true }, + }); + + const error = await captureRejection(testCase.invoke(client)); + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(parseError.context).toMatchObject({ + entityType: testCase.entityType, + decoderPath: `input.${ENTITY_DATA_FIELD_MAP[testCase.entityType]}.${field}`, + decoderMessage: 'raw object was decoded and encoded as null', + }); + } + ); +}); + +describe('stock consolidation, reissuance, and repurchase invariants', () => { + it('rejects a consolidation with no source securities', async () => { + const { client } = createMockClient(stockConsolidationCase, { + ...stockConsolidationData(), + security_ids: [], + }); + + const error = await captureRejection(stockConsolidationCase.invoke(client)); + expect(error).toBeInstanceOf(OcpValidationError); + const validationError = error as OcpValidationError; + expect(validationError.code).toBe(OcpErrorCodes.OUT_OF_RANGE); + expect(validationError.fieldPath).toBe('stockConsolidation.security_ids'); + }); + + it('rejects duplicate consolidation source securities', async () => { + const { client } = createMockClient(stockConsolidationCase, { + ...stockConsolidationData(), + security_ids: ['security-old-1', 'security-old-1'], + }); + + const error = await captureRejection(stockConsolidationCase.invoke(client)); + expect(error).toBeInstanceOf(OcpValidationError); + const validationError = error as OcpValidationError; + expect(validationError.code).toBe(OcpErrorCodes.INVALID_FORMAT); + expect(validationError.fieldPath).toBe('stockConsolidation.security_ids[1]'); + }); + + it('rejects a reissuance with no resulting securities', async () => { + const { client } = createMockClient(stockReissuanceCase, { + ...stockReissuanceData(), + resulting_security_ids: [], + }); + + const error = (await captureRejection(stockReissuanceCase.invoke(client))) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.code).toBe(OcpErrorCodes.OUT_OF_RANGE); + expect(error.fieldPath).toBe('stockReissuance.resulting_security_ids'); + }); + + it('rejects a consolidation whose source security is an empty Text value', async () => { + const { client } = createMockClient(stockConsolidationCase, { + ...stockConsolidationData(), + security_ids: [''], + resulting_security_id: '', + }); + + const error = (await captureRejection(stockConsolidationCase.invoke(client))) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.code).toBe(OcpErrorCodes.INVALID_FORMAT); + expect(error.fieldPath).toBe('stockConsolidation.security_ids[0]'); + }); + + it('rejects duplicate empty consolidation source securities at the duplicate index', async () => { + const { client } = createMockClient(stockConsolidationCase, { + ...stockConsolidationData(), + security_ids: ['', ''], + }); + + const error = await captureRejection(stockConsolidationCase.invoke(client)); + expect(error).toBeInstanceOf(OcpValidationError); + const validationError = error as OcpValidationError; + expect(validationError.code).toBe(OcpErrorCodes.INVALID_FORMAT); + expect(validationError.fieldPath).toBe('stockConsolidation.security_ids[0]'); + }); + + it('rejects duplicate reissuance result security IDs', async () => { + const { client } = createMockClient(stockReissuanceCase, { + ...stockReissuanceData(), + resulting_security_ids: ['duplicate', 'duplicate'], + }); + + const error = (await captureRejection(stockReissuanceCase.invoke(client))) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.code).toBe(OcpErrorCodes.INVALID_FORMAT); + expect(error.fieldPath).toBe('stockReissuance.resulting_security_ids[1]'); + }); + + it('normalizes both repurchase quantity and price without conflating them', async () => { + const { client } = createMockClient(stockRepurchaseCase, { + ...stockRepurchaseData(), + quantity: '125.0000000000', + price: { amount: '7.5000000000', currency: 'EUR' }, + }); + + const result = await stockRepurchaseCase.invoke(client); + expect(result.event).toMatchObject({ + object_type: 'TX_STOCK_REPURCHASE', + quantity: '125', + price: { amount: '7.5', currency: 'EUR' }, + }); + }); +}); diff --git a/test/functions/stockIssuanceBoundaries.test.ts b/test/functions/stockIssuanceBoundaries.test.ts index d75c321d..d44716ee 100644 --- a/test/functions/stockIssuanceBoundaries.test.ts +++ b/test/functions/stockIssuanceBoundaries.test.ts @@ -411,14 +411,16 @@ describe('stock issuance exact boundaries', () => { ); }); - test.each([ - [ - 'share_price.amount', - { ...stockIssuanceDataToDaml(STOCK_ISSUANCE), share_price: { amount: '0.00000000001', currency: 'USD' } }, - ], - ] as const)('rejects malformed generated Numeric(10) at %s', (field, data) => { - expect(() => damlStockIssuanceDataToNative(data as ReturnType)).toThrow( - expect.objectContaining({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath: `stockIssuance.${field}` }) + test('rejects malformed generated Numeric(10) beyond its scale', () => { + const data = { + ...stockIssuanceDataToDaml(STOCK_ISSUANCE), + share_price: { amount: '0.00000000001', currency: 'USD' }, + }; + expect(() => damlStockIssuanceDataToNative(data)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockIssuance.share_price.amount', + }) ); }); diff --git a/test/package-consumer/publicEntrypoint.types.ts b/test/package-consumer/publicEntrypoint.types.ts index f090803b..9b9ae9e5 100644 --- a/test/package-consumer/publicEntrypoint.types.ts +++ b/test/package-consumer/publicEntrypoint.types.ts @@ -9,7 +9,10 @@ import type { OcfEquityCompensationExercise, OcfManifest, OcfObject, + OcfReadDataTypeFor, + OcfStockConsolidation, OcfStockConversion, + OcfStockReissuance, OcfWarrantExercise, OcpClient, OcpEnvironment, @@ -100,6 +103,17 @@ const warrantExerciseRead = client.OpenCapTable.getByObjectType({ objectType: 'TX_WARRANT_EXERCISE', contractId: 'contract-id', }); +const corporateRatioRead = client.OpenCapTable.stockClassConversionRatioAdjustment.get({ contractId: 'contract-id' }); +const corporateSplitRead = client.OpenCapTable.stockClassSplit.get({ contractId: 'contract-id' }); +const corporateConsolidationRead = client.OpenCapTable.getByObjectType({ + objectType: 'TX_STOCK_CONSOLIDATION', + contractId: 'contract-id', +}); +const corporateReissuanceRead = client.OpenCapTable.getByObjectType({ + objectType: 'TX_STOCK_REISSUANCE', + contractId: 'contract-id', +}); +const corporateRepurchaseRead = client.OpenCapTable.stockRepurchase.get({ contractId: 'contract-id' }); const packageConversionExerciseReadersAreExact: Assert< IsExactly['data'], DeepReadonly> > = true; @@ -119,6 +133,27 @@ const packageEmptyStockResults: OcfStockConversion['resulting_security_ids'] = [ const packageEmptyEquityResults: OcfEquityCompensationExercise['resulting_security_ids'] = []; // @ts-expect-error package warrant exercises require at least one resulting security const packageEmptyWarrantResults: OcfWarrantExercise['resulting_security_ids'] = []; +const packageCorporateRatioReaderIsExact: Assert< + IsExactly['data'], OcfReadDataTypeFor<'stockClassConversionRatioAdjustment'>> +> = true; +const packageCorporateSplitReaderIsExact: Assert< + IsExactly['data'], OcfReadDataTypeFor<'stockClassSplit'>> +> = true; +const packageCorporateConsolidationReaderIsExact: Assert< + IsExactly['data'], OcfReadDataTypeFor<'stockConsolidation'>> +> = true; +const packageCorporateReissuanceReaderIsExact: Assert< + IsExactly['data'], OcfReadDataTypeFor<'stockReissuance'>> +> = true; +const packageCorporateRepurchaseReaderIsExact: Assert< + IsExactly['data'], OcfReadDataTypeFor<'stockRepurchase'>> +> = true; +const packageFirstConsolidationSource: string = (null as unknown as OcfStockConsolidation).security_ids[0]; +const packageFirstReissuanceResult: string = (null as unknown as OcfStockReissuance).resulting_security_ids[0]; +// @ts-expect-error package-root reissuance results are statically non-empty +const packageEmptyReissuanceResults: OcfStockReissuance['resulting_security_ids'] = []; +// @ts-expect-error package-root consolidation sources are statically non-empty +const packageEmptyConsolidationSources: OcfStockConsolidation['security_ids'] = []; declare const packageReadonlyConvertible: Awaited['data']; // @ts-expect-error installed-package readers expose recursively readonly result tuples @@ -147,6 +182,20 @@ void packageEmptyConvertibleResults; void packageEmptyStockResults; void packageEmptyEquityResults; void packageEmptyWarrantResults; +void corporateRatioRead; +void corporateSplitRead; +void corporateConsolidationRead; +void corporateReissuanceRead; +void corporateRepurchaseRead; +void packageCorporateRatioReaderIsExact; +void packageCorporateSplitReaderIsExact; +void packageCorporateConsolidationReaderIsExact; +void packageCorporateReissuanceReaderIsExact; +void packageCorporateRepurchaseReaderIsExact; +void packageFirstConsolidationSource; +void packageFirstReissuanceResult; +void packageEmptyReissuanceResults; +void packageEmptyConsolidationSources; void packageReadonlyConvertible; void packageExactnessRejectsCompilerAny; void packageExactnessRejectsNestedCompilerAny; diff --git a/test/schemaAlignment/canonicalOcfObjectInventory.json b/test/schemaAlignment/canonicalOcfObjectInventory.json index 51526866..43a15922 100644 --- a/test/schemaAlignment/canonicalOcfObjectInventory.json +++ b/test/schemaAlignment/canonicalOcfObjectInventory.json @@ -1,5 +1,5 @@ { - "fingerprint": "e4fa493e8c04c33aaeeb106aaf36142ea1bc42702a6d53984bed8930f7e5293c", + "fingerprint": "b7b0624775692f27cd2d17018c1d746c9f144d872a28b2ad0c5e07b113f727da", "objects": [ { "discriminator": "CE_STAKEHOLDER_RELATIONSHIP", @@ -135,15 +135,15 @@ }, { "discriminator": "TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT", - "signature": "intersection(object(\"comments\"?:array(string);\"date\":string;\"id\":string;\"new_ratio_conversion_mechanism\":object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"rounding_type\":union(string:\"CEILING\"|string:\"FLOOR\"|string:\"NORMAL\");\"type\":string:\"RATIO_CONVERSION\");readonly \"object_type\":string:\"TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT\";\"stock_class_id\":string)&object(readonly \"object_type\":string:\"TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT\"))" + "signature": "object(readonly \"comments\"?:readonly-array(string);readonly \"date\":string;readonly \"id\":string;readonly \"new_ratio_conversion_mechanism\":object(readonly \"conversion_price\":object(readonly \"amount\":string;readonly \"currency\":string);readonly \"ratio\":object(readonly \"denominator\":string;readonly \"numerator\":string);readonly \"rounding_type\":union(string:\"CEILING\"|string:\"FLOOR\"|string:\"NORMAL\");readonly \"type\":string:\"RATIO_CONVERSION\");readonly \"object_type\":string:\"TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT\";readonly \"stock_class_id\":string)" }, { "discriminator": "TX_STOCK_CLASS_SPLIT", - "signature": "intersection(object(\"comments\"?:array(string);\"date\":string;\"id\":string;readonly \"object_type\":string:\"TX_STOCK_CLASS_SPLIT\";\"split_ratio\":object(\"denominator\":string;\"numerator\":string);\"stock_class_id\":string)&object(readonly \"object_type\":string:\"TX_STOCK_CLASS_SPLIT\"))" + "signature": "object(readonly \"comments\"?:readonly-array(string);readonly \"date\":string;readonly \"id\":string;readonly \"object_type\":string:\"TX_STOCK_CLASS_SPLIT\";readonly \"split_ratio\":object(readonly \"denominator\":string;readonly \"numerator\":string);readonly \"stock_class_id\":string)" }, { "discriminator": "TX_STOCK_CONSOLIDATION", - "signature": "intersection(object(\"comments\"?:array(string);\"date\":string;\"id\":string;readonly \"object_type\":string:\"TX_STOCK_CONSOLIDATION\";\"reason_text\"?:string;\"resulting_security_id\":string;\"security_ids\":tuple(string,...string))&object(readonly \"object_type\":string:\"TX_STOCK_CONSOLIDATION\"))" + "signature": "object(readonly \"comments\"?:readonly-array(string);readonly \"date\":string;readonly \"id\":string;readonly \"object_type\":string:\"TX_STOCK_CONSOLIDATION\";readonly \"reason_text\"?:string;readonly \"resulting_security_id\":string;readonly \"security_ids\":readonly-tuple(string,...string))" }, { "discriminator": "TX_STOCK_CONVERSION", @@ -163,11 +163,11 @@ }, { "discriminator": "TX_STOCK_REISSUANCE", - "signature": "intersection(object(\"comments\"?:array(string);\"date\":string;\"id\":string;readonly \"object_type\":string:\"TX_STOCK_REISSUANCE\";\"reason_text\"?:string;\"resulting_security_ids\":array(string);\"security_id\":string;\"split_transaction_id\"?:string)&object(readonly \"object_type\":string:\"TX_STOCK_REISSUANCE\"))" + "signature": "object(readonly \"comments\"?:readonly-array(string);readonly \"date\":string;readonly \"id\":string;readonly \"object_type\":string:\"TX_STOCK_REISSUANCE\";readonly \"reason_text\"?:string;readonly \"resulting_security_ids\":readonly-tuple(string,...string);readonly \"security_id\":string;readonly \"split_transaction_id\"?:string)" }, { "discriminator": "TX_STOCK_REPURCHASE", - "signature": "intersection(object(\"balance_security_id\"?:string;\"comments\"?:array(string);\"consideration_text\"?:string;\"date\":string;\"id\":string;readonly \"object_type\":string:\"TX_STOCK_REPURCHASE\";\"price\":object(\"amount\":string;\"currency\":string);\"quantity\":string;\"security_id\":string)&object(readonly \"object_type\":string:\"TX_STOCK_REPURCHASE\"))" + "signature": "object(readonly \"balance_security_id\"?:string;readonly \"comments\"?:readonly-array(string);readonly \"consideration_text\"?:string;readonly \"date\":string;readonly \"id\":string;readonly \"object_type\":string:\"TX_STOCK_REPURCHASE\";readonly \"price\":object(readonly \"amount\":string;readonly \"currency\":string);readonly \"quantity\":string;readonly \"security_id\":string)" }, { "discriminator": "TX_STOCK_RETRACTION", diff --git a/test/schemaAlignment/schemaConformanceRegistry.ts b/test/schemaAlignment/schemaConformanceRegistry.ts index d3d95626..a4611448 100644 --- a/test/schemaAlignment/schemaConformanceRegistry.ts +++ b/test/schemaAlignment/schemaConformanceRegistry.ts @@ -156,6 +156,11 @@ export const EXPECTED_NON_EMPTY_ARRAY_REFINEMENTS = [ kind: 'sdk-only', property: 'resulting_security_ids', }, + { + discriminator: 'TX_STOCK_REISSUANCE', + kind: 'sdk-only', + property: 'resulting_security_ids', + }, { discriminator: 'TX_WARRANT_EXERCISE', kind: 'sdk-only', diff --git a/test/types/stockCorporateActionReaders.types.ts b/test/types/stockCorporateActionReaders.types.ts new file mode 100644 index 00000000..9ede8251 --- /dev/null +++ b/test/types/stockCorporateActionReaders.types.ts @@ -0,0 +1,236 @@ +/** Compile-time contracts for stock corporate-action readers and converters. */ + +import type { DamlDataTypeFor, OcfReadDataTypeFor } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import type { ReadonlyDamlDataTypeFor } from '../../src/functions/OpenCapTable/capTable/damlEntityData'; +import type { + DamlStockClassConversionRatioAdjustmentData, + damlStockClassConversionRatioAdjustmentToNative, +} from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import type { GetStockClassConversionRatioAdjustmentAsOcfResult } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf'; +import type { + DamlStockClassSplitData, + damlStockClassSplitToNative, +} from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; +import type { GetStockClassSplitAsOcfResult } from '../../src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf'; +import type { + DamlStockConsolidationData, + damlStockConsolidationToNative, +} from '../../src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation'; +import type { GetStockConsolidationAsOcfResult } from '../../src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf'; +import type { + DamlStockReissuanceData, + damlStockReissuanceToNative, +} from '../../src/functions/OpenCapTable/stockReissuance/damlToStockReissuance'; +import type { GetStockReissuanceAsOcfResult } from '../../src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf'; +import type { + DamlStockRepurchaseData, + damlStockRepurchaseToNative, +} from '../../src/functions/OpenCapTable/stockRepurchase/damlToOcf'; +import type { GetStockRepurchaseAsOcfResult } from '../../src/functions/OpenCapTable/stockRepurchase/getStockRepurchaseAsOcf'; +import type { + OcfStockClassConversionRatioAdjustment, + OcfStockClassSplit, + OcfStockConsolidation, + OcfStockReissuance, + OcfStockRepurchase, +} from '../../src/types/native'; + +type Assert = T; +type IsAny = 0 extends 1 & T ? true : false; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +type ConversionRatioEvent = GetStockClassConversionRatioAdjustmentAsOcfResult['event']; +type SplitEvent = GetStockClassSplitAsOcfResult['event']; +type ConsolidationEvent = GetStockConsolidationAsOcfResult['event']; +type ReissuanceEvent = GetStockReissuanceAsOcfResult['event']; +type RepurchaseEvent = GetStockRepurchaseAsOcfResult['event']; + +const conversionRatioEventIsExact: Assert< + IsExactly> +> = true; +const splitEventIsExact: Assert>> = true; +const consolidationEventIsExact: Assert>> = true; +const reissuanceEventIsExact: Assert>> = true; +const repurchaseEventIsExact: Assert>> = true; +const conversionRatioResultIsExact: Assert< + IsExactly< + GetStockClassConversionRatioAdjustmentAsOcfResult, + { + readonly event: OcfReadDataTypeFor<'stockClassConversionRatioAdjustment'>; + readonly contractId: string; + } + > +> = true; +const splitResultIsExact: Assert< + IsExactly< + GetStockClassSplitAsOcfResult, + { readonly event: OcfReadDataTypeFor<'stockClassSplit'>; readonly contractId: string } + > +> = true; +const consolidationResultIsExact: Assert< + IsExactly< + GetStockConsolidationAsOcfResult, + { readonly event: OcfReadDataTypeFor<'stockConsolidation'>; readonly contractId: string } + > +> = true; +const reissuanceResultIsExact: Assert< + IsExactly< + GetStockReissuanceAsOcfResult, + { readonly event: OcfReadDataTypeFor<'stockReissuance'>; readonly contractId: string } + > +> = true; +const repurchaseResultIsExact: Assert< + IsExactly< + GetStockRepurchaseAsOcfResult, + { readonly event: OcfReadDataTypeFor<'stockRepurchase'>; readonly contractId: string } + > +> = true; + +const conversionRatioEventIsNotAny: Assert, false>> = true; +const splitEventIsNotAny: Assert, false>> = true; +const consolidationEventIsNotAny: Assert, false>> = true; +const reissuanceEventIsNotAny: Assert, false>> = true; +const repurchaseEventIsNotAny: Assert, false>> = true; + +const conversionRatioDamlIsExact: Assert< + IsExactly> +> = true; +const splitDamlIsExact: Assert>> = true; +const consolidationDamlIsExact: Assert>> = + true; +const reissuanceDamlIsExact: Assert>> = true; +const repurchaseDamlIsExact: Assert>> = true; + +const conversionRatioConverterInputIsExact: Assert< + IsExactly< + Parameters[0], + ReadonlyDamlDataTypeFor<'stockClassConversionRatioAdjustment'> + > +> = true; +const splitConverterInputIsExact: Assert< + IsExactly[0], ReadonlyDamlDataTypeFor<'stockClassSplit'>> +> = true; +const consolidationConverterInputIsExact: Assert< + IsExactly[0], ReadonlyDamlDataTypeFor<'stockConsolidation'>> +> = true; +const reissuanceConverterInputIsExact: Assert< + IsExactly[0], ReadonlyDamlDataTypeFor<'stockReissuance'>> +> = true; +const repurchaseConverterInputIsExact: Assert< + IsExactly[0], ReadonlyDamlDataTypeFor<'stockRepurchase'>> +> = true; +const conversionRatioConverterInputIsNotAny: Assert< + IsExactly[0]>, false> +> = true; +const splitConverterInputIsNotAny: Assert[0]>, false>> = + true; +const consolidationConverterInputIsNotAny: Assert< + IsExactly[0]>, false> +> = true; +const reissuanceConverterInputIsNotAny: Assert< + IsExactly[0]>, false> +> = true; +const repurchaseConverterInputIsNotAny: Assert< + IsExactly[0]>, false> +> = true; + +type ConversionRatioRounding = + OcfStockClassConversionRatioAdjustment['new_ratio_conversion_mechanism']['rounding_type']; +const roundingUnionIsExact: Assert> = true; + +declare const conversionRatioResult: GetStockClassConversionRatioAdjustmentAsOcfResult; +declare const splitResult: GetStockClassSplitAsOcfResult; +declare const consolidationResult: GetStockConsolidationAsOcfResult; +declare const reissuanceResult: GetStockReissuanceAsOcfResult; +declare const repurchaseResult: GetStockRepurchaseAsOcfResult; +declare const conversionRatioDaml: DamlStockClassConversionRatioAdjustmentData; +declare const splitDaml: DamlStockClassSplitData; +declare const consolidationDaml: DamlStockConsolidationData; +declare const reissuanceDaml: DamlStockReissuanceData; +declare const repurchaseDaml: DamlStockRepurchaseData; + +const firstConsolidatedSecurityId: string = consolidationResult.event.security_ids[0]; +const firstReissuedSecurityId: string = reissuanceResult.event.resulting_security_ids[0]; + +// @ts-expect-error corporate-action result wrappers are readonly +conversionRatioResult.contractId = 'other'; +// @ts-expect-error corporate-action event fields are readonly +splitResult.event.id = 'other'; +// @ts-expect-error corporate-action nested objects are recursively readonly +conversionRatioResult.event.new_ratio_conversion_mechanism.ratio.numerator = '4'; +// @ts-expect-error corporate-action identifier tuples are recursively readonly +consolidationResult.event.security_ids.push('other'); +// @ts-expect-error reissuance identifier tuples are recursively readonly +reissuanceResult.event.resulting_security_ids[0] = 'other'; +// @ts-expect-error nested Monetary objects are recursively readonly +repurchaseResult.event.price.amount = '4'; + +// @ts-expect-error conversion-ratio DAML cannot be passed to the split converter +const wrongSplitDamlInput: Parameters[0] = conversionRatioDaml; +// @ts-expect-error split DAML cannot be passed to the consolidation converter +const wrongConsolidationDamlInput: Parameters[0] = splitDaml; +// @ts-expect-error consolidation DAML cannot be passed to the reissuance converter +const wrongReissuanceDamlInput: Parameters[0] = consolidationDaml; +// @ts-expect-error reissuance DAML cannot be passed to the repurchase converter +const wrongRepurchaseDamlInput: Parameters[0] = reissuanceDaml; +// @ts-expect-error repurchase DAML cannot be passed to the conversion-ratio converter +const wrongConversionRatioDamlInput: Parameters[0] = + repurchaseDaml; + +// @ts-expect-error conversion-ratio adjustment cannot be used as a split +const wrongSplit: OcfStockClassSplit = conversionRatioResult.event; +// @ts-expect-error split cannot be used as a consolidation +const wrongConsolidation: OcfStockConsolidation = splitResult.event; +// @ts-expect-error consolidation cannot be used as a reissuance +const wrongReissuance: OcfStockReissuance = consolidationResult.event; +// @ts-expect-error reissuance cannot be used as a repurchase +const wrongRepurchase: OcfStockRepurchase = reissuanceResult.event; +// @ts-expect-error repurchase cannot be used as a conversion-ratio adjustment +const wrongConversionRatio: OcfStockClassConversionRatioAdjustment = repurchaseResult.event; +// @ts-expect-error canonical rounding is a closed union +const invalidRounding: ConversionRatioRounding = 'BANKERS'; + +void conversionRatioEventIsExact; +void splitEventIsExact; +void consolidationEventIsExact; +void reissuanceEventIsExact; +void repurchaseEventIsExact; +void conversionRatioResultIsExact; +void splitResultIsExact; +void consolidationResultIsExact; +void reissuanceResultIsExact; +void repurchaseResultIsExact; +void conversionRatioEventIsNotAny; +void splitEventIsNotAny; +void consolidationEventIsNotAny; +void reissuanceEventIsNotAny; +void repurchaseEventIsNotAny; +void conversionRatioDamlIsExact; +void splitDamlIsExact; +void consolidationDamlIsExact; +void reissuanceDamlIsExact; +void repurchaseDamlIsExact; +void conversionRatioConverterInputIsExact; +void splitConverterInputIsExact; +void consolidationConverterInputIsExact; +void reissuanceConverterInputIsExact; +void repurchaseConverterInputIsExact; +void conversionRatioConverterInputIsNotAny; +void splitConverterInputIsNotAny; +void consolidationConverterInputIsNotAny; +void reissuanceConverterInputIsNotAny; +void repurchaseConverterInputIsNotAny; +void roundingUnionIsExact; +void firstConsolidatedSecurityId; +void firstReissuedSecurityId; +void wrongSplitDamlInput; +void wrongConsolidationDamlInput; +void wrongReissuanceDamlInput; +void wrongRepurchaseDamlInput; +void wrongConversionRatioDamlInput; +void wrongSplit; +void wrongConsolidation; +void wrongReissuance; +void wrongRepurchase; +void wrongConversionRatio; +void invalidRounding; diff --git a/test/types/stockCorporateActionSurfaces.types.ts b/test/types/stockCorporateActionSurfaces.types.ts new file mode 100644 index 00000000..6c8a82fc --- /dev/null +++ b/test/types/stockCorporateActionSurfaces.types.ts @@ -0,0 +1,196 @@ +/** Exact source contracts for stock corporate-action writers, operations, batches, and OcpClient. */ + +import type { OcpClient } from '../../src/OcpClient'; +import type { + DamlDataTypeFor, + OcfCreateDataFor, + OcfEditDataFor, + OcfReadDataTypeFor, +} from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import type { ReadonlyDamlDataTypeFor } from '../../src/functions/OpenCapTable/capTable/damlEntityData'; +import { + buildOcfCreateData, + buildOcfCreateDataFromOperation, + buildOcfEditData, + buildOcfEditDataFromOperation, +} from '../../src/functions/OpenCapTable/capTable/generatedBatchOperations'; +import { convertOperationToDaml, convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; +import { stockClassConversionRatioAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml'; +import { stockClassSplitDataToDaml } from '../../src/functions/OpenCapTable/stockClassSplit/stockClassSplitDataToDaml'; +import { stockConsolidationDataToDaml } from '../../src/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml'; +import { stockReissuanceDataToDaml } from '../../src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml'; +import { stockRepurchaseDataToDaml } from '../../src/functions/OpenCapTable/stockRepurchase/stockRepurchaseDataToDaml'; +import type { + OcfStockClassConversionRatioAdjustment, + OcfStockClassSplit, + OcfStockConsolidation, + OcfStockReissuance, + OcfStockRepurchase, +} from '../../src/types/native'; + +type Assert = T; +type IsAny = 0 extends 1 & T ? true : false; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type EveryTrue = Exclude extends never ? true : false; +type DirectNumberKeys = { + [Key in keyof T]-?: Extract extends never ? never : Key; +}[keyof T]; + +declare const ratio: OcfStockClassConversionRatioAdjustment; +declare const split: OcfStockClassSplit; +declare const consolidation: OcfStockConsolidation; +declare const reissuance: OcfStockReissuance; +declare const repurchase: OcfStockRepurchase; +declare const client: OcpClient; + +const directRatio = stockClassConversionRatioAdjustmentDataToDaml(ratio); +const directSplit = stockClassSplitDataToDaml(split); +const directConsolidation = stockConsolidationDataToDaml(consolidation); +const directReissuance = stockReissuanceDataToDaml(reissuance); +const directRepurchase = stockRepurchaseDataToDaml(repurchase); +const genericRatio = convertToDaml('stockClassConversionRatioAdjustment', ratio); +const genericSplit = convertToDaml('stockClassSplit', split); +const genericConsolidation = convertToDaml('stockConsolidation', consolidation); +const genericReissuance = convertToDaml('stockReissuance', reissuance); +const genericRepurchase = convertToDaml('stockRepurchase', repurchase); +const operationRatio = convertOperationToDaml({ type: 'stockClassConversionRatioAdjustment', data: ratio }); +const operationSplit = convertOperationToDaml({ type: 'stockClassSplit', data: split }); +const operationConsolidation = convertOperationToDaml({ type: 'stockConsolidation', data: consolidation }); +const operationReissuance = convertOperationToDaml({ type: 'stockReissuance', data: reissuance }); +const operationRepurchase = convertOperationToDaml({ type: 'stockRepurchase', data: repurchase }); + +const createRatio = buildOcfCreateData('stockClassConversionRatioAdjustment', ratio); +const createSplitOperation = buildOcfCreateDataFromOperation({ type: 'stockClassSplit', data: split }); +const createConsolidation = buildOcfCreateData('stockConsolidation', consolidation); +const editReissuance = buildOcfEditData('stockReissuance', reissuance); +const editRepurchaseOperation = buildOcfEditDataFromOperation({ type: 'stockRepurchase', data: repurchase }); + +const clientRatio = client.OpenCapTable.stockClassConversionRatioAdjustment.get({ contractId: 'contract-id' }); +const clientSplit = client.OpenCapTable.stockClassSplit.get({ contractId: 'contract-id' }); +const clientConsolidation = client.OpenCapTable.getByObjectType({ + objectType: 'TX_STOCK_CONSOLIDATION', + contractId: 'contract-id', +}); +const clientReissuance = client.OpenCapTable.getByObjectType({ + objectType: 'TX_STOCK_REISSUANCE', + contractId: 'contract-id', +}); +const clientRepurchase = client.OpenCapTable.stockRepurchase.get({ contractId: 'contract-id' }); + +const directWriterTypesAreExact: Assert< + EveryTrue< + [ + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + ] + > +> = true; +const dispatcherAndOperationTypesAreExact: Assert< + EveryTrue< + [ + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + ] + > +> = true; +const batchTypesAreExact: Assert< + EveryTrue< + [ + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + IsExactly>, + ] + > +> = true; +const clientTypesAreExact: Assert< + EveryTrue< + [ + IsExactly['data'], OcfReadDataTypeFor<'stockClassConversionRatioAdjustment'>>, + IsExactly['data'], OcfReadDataTypeFor<'stockClassSplit'>>, + IsExactly['data'], OcfReadDataTypeFor<'stockConsolidation'>>, + IsExactly['data'], OcfReadDataTypeFor<'stockReissuance'>>, + IsExactly['data'], OcfReadDataTypeFor<'stockRepurchase'>>, + IsExactly['data']>, false>, + ] + > +> = true; +const cardinalityAndDamlScalarTypesAreExact: Assert< + EveryTrue< + [ + IsExactly, + IsExactly, + IsExactly, + IsExactly>, never>, + IsExactly>, never>, + IsExactly>, never>, + IsExactly>, never>, + IsExactly>, never>, + ] + > +> = true; + +// @ts-expect-error reissuance resulting_security_ids are statically non-empty +const emptyReissuanceResults: OcfStockReissuance['resulting_security_ids'] = []; +// @ts-expect-error consolidation security_ids are statically non-empty +const emptyConsolidationSources: OcfStockConsolidation['security_ids'] = []; +declare const clientRatioData: Awaited['data']; +declare const clientReissuanceData: Awaited['data']; +// @ts-expect-error generic writers preserve entity/data correlation +convertToDaml('stockClassSplit', consolidation); +// @ts-expect-error operation writers preserve entity/data correlation +convertOperationToDaml({ type: 'stockRepurchase', data: reissuance }); +// @ts-expect-error batch writers preserve entity/data correlation +buildOcfCreateData('stockConsolidation', split); +// @ts-expect-error generated Numeric fields remain strings, never JavaScript numbers +const invalidSplitNumerator: DamlDataTypeFor<'stockClassSplit'>['split_ratio']['numerator'] = 2; +// @ts-expect-error public namespace reads are recursively readonly +clientRatioData.new_ratio_conversion_mechanism.conversion_price.amount = '2'; +// @ts-expect-error public object-type reads expose readonly non-empty tuples +clientReissuanceData.resulting_security_ids.push('other'); + +void directWriterTypesAreExact; +void dispatcherAndOperationTypesAreExact; +void batchTypesAreExact; +void clientTypesAreExact; +void cardinalityAndDamlScalarTypesAreExact; +void directRatio; +void directSplit; +void directConsolidation; +void directReissuance; +void directRepurchase; +void genericRatio; +void genericSplit; +void genericConsolidation; +void genericReissuance; +void genericRepurchase; +void operationRatio; +void operationSplit; +void operationConsolidation; +void operationReissuance; +void operationRepurchase; +void createRatio; +void createSplitOperation; +void createConsolidation; +void editReissuance; +void editRepurchaseOperation; +void clientRatio; +void clientSplit; +void clientConsolidation; +void clientReissuance; +void clientRepurchase; +void emptyReissuanceResults; +void emptyConsolidationSources; +void invalidSplitNumerator;