From d633bc68ae1374b20ad2193fcc5066758e5547ab Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 19:35:23 -0400 Subject: [PATCH 01/47] Harden cap table batch typing --- .github/workflows/ci.yml | 3 + .github/workflows/publish.yml | 6 + eslint.config.mjs | 2 +- jest.config.js | 8 + package.json | 3 +- scripts/check-declarations.ts | 45 +++ .../OpenCapTable/capTable/CapTableBatch.ts | 86 +++-- .../OpenCapTable/capTable/batchTypes.ts | 351 +++++++++++------- .../OpenCapTable/capTable/ocfToDaml.ts | 5 +- src/utils/replicationHelpers.ts | 76 ---- test/batch/CapTableBatch.test.ts | 6 +- test/batch/batchTypes.test.ts | 33 ++ test/declarations/publicApi.types.ts | 51 +++ test/types/capTableBatch.types.ts | 77 ++++ tsconfig.declaration-tests.json | 9 + tsconfig.tests.json | 3 +- 16 files changed, 502 insertions(+), 262 deletions(-) create mode 100644 scripts/check-declarations.ts create mode 100644 test/batch/batchTypes.test.ts create mode 100644 test/declarations/publicApi.types.ts create mode 100644 test/types/capTableBatch.types.ts create mode 100644 tsconfig.declaration-tests.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80345fe7..168decaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,9 @@ jobs: - name: Build run: npm run build + - name: Test declarations + run: npm run test:declarations + - name: Type Check run: npm run typecheck diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 14efd1b9..206985d2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -42,6 +42,12 @@ jobs: - name: Build package run: npm run build + - name: Run unit tests with coverage + run: npm run test:ci + + - name: Test declarations + run: npm run test:declarations + - name: Prepare release run: npm run prepare-release diff --git a/eslint.config.mjs b/eslint.config.mjs index 4e81ea42..3eca2259 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,7 +25,7 @@ const eslintConfig = [ languageOptions: { parser: typescriptEslintParser, parserOptions: { - project: ['./tsconfig.json', './tsconfig.tests.json'], + project: ['./tsconfig.json', './tsconfig.tests.json', './tsconfig.declaration-tests.json'], ecmaVersion: 2020, sourceType: 'module', }, diff --git a/jest.config.js b/jest.config.js index 9052a04b..b94969d0 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,6 +7,14 @@ module.exports = { testMatch: ['**/test/**/*.test.ts'], testPathIgnorePatterns: ['/node_modules/', '/test/integration/'], collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'], + coverageThreshold: { + global: { + branches: 60, + functions: 68, + lines: 72, + statements: 72, + }, + }, setupFilesAfterEnv: ['/test/setupTests.ts'], transform: { '^.+\\.(ts|tsx)$': ['ts-jest', { diagnostics: false }], diff --git a/package.json b/package.json index e19ca614..f8fea457 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,9 @@ "script:create-issuer": "ts-node --project tsconfig.tests.json scripts/createIssuer.ts", "script:get-issuer-ocf": "ts-node --project tsconfig.tests.json scripts/getIssuerAsOcf.ts", "test": "npm run -s typecheck && jest --passWithNoTests", - "test:ci": "npm run -s typecheck && jest --runInBand", + "test:ci": "npm run -s typecheck && jest --runInBand --coverage", "test:coverage": "jest --coverage --passWithNoTests", + "test:declarations": "ts-node --project tsconfig.tests.json scripts/check-declarations.ts && tsc -p tsconfig.declaration-tests.json --noEmit", "test:integration": "npm run -s typecheck && jest -c jest.integration.config.js --passWithNoTests", "test:integration:ci": "npm run -s typecheck && jest -c jest.integration.config.js --runInBand", "test:watch": "jest --watch", diff --git a/scripts/check-declarations.ts b/scripts/check-declarations.ts new file mode 100644 index 00000000..0788895f --- /dev/null +++ b/scripts/check-declarations.ts @@ -0,0 +1,45 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const projectRoot = process.cwd(); +const configPath = path.join(projectRoot, 'tsconfig.json'); +const declarationEntryPoint = path.join(projectRoot, 'dist', 'index.d.ts'); +const declarationRoot = `${path.dirname(declarationEntryPoint)}${path.sep}`; +const diagnosticHost: ts.FormatDiagnosticsHost = { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => projectRoot, + getNewLine: () => ts.sys.newLine, +}; + +if (!fs.existsSync(declarationEntryPoint)) { + throw new Error(`Declaration entry point not found: ${declarationEntryPoint}. Run npm run build first.`); +} + +const configFile = ts.readConfigFile(configPath, (fileName) => ts.sys.readFile(fileName)); +if (configFile.error) { + throw new Error(ts.formatDiagnostic(configFile.error, diagnosticHost)); +} + +const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, projectRoot, { + noEmit: true, + skipLibCheck: false, +}); +if (parsedConfig.errors.length > 0) { + throw new Error(ts.formatDiagnosticsWithColorAndContext(parsedConfig.errors, diagnosticHost)); +} + +const program = ts.createProgram({ + rootNames: [declarationEntryPoint], + options: parsedConfig.options, +}); + +const sdkDiagnostics = ts + .getPreEmitDiagnostics(program) + .filter((diagnostic) => diagnostic.file?.fileName.startsWith(declarationRoot)); + +if (sdkDiagnostics.length > 0) { + throw new Error( + `SDK declaration validation failed:\n${ts.formatDiagnosticsWithColorAndContext(sdkDiagnostics, diagnosticHost)}` + ); +} diff --git a/src/functions/OpenCapTable/capTable/CapTableBatch.ts b/src/functions/OpenCapTable/capTable/CapTableBatch.ts index c1dc2566..9d9109c9 100644 --- a/src/functions/OpenCapTable/capTable/CapTableBatch.ts +++ b/src/functions/OpenCapTable/capTable/CapTableBatch.ts @@ -17,11 +17,20 @@ import { import type { CommandWithDisclosedContracts } from '../../../types'; import { ENTITY_TAG_MAP, + isOcfCreatableEntityType, + isOcfDeletableEntityType, + isOcfEditableEntityType, type CapTableBatchExecuteResult, + type CapTableBatchOperations, + type OcfCreateArguments, type OcfCreateData, + type OcfCreateOperation, type OcfDataTypeFor, + type OcfDeletableEntityType, type OcfDeleteData, + type OcfEditArguments, type OcfEditData, + type OcfEditOperation, type OcfEntityType, type UpdateCapTableResult, } from './batchTypes'; @@ -51,7 +60,7 @@ function createUpdateCapTableCommandId(): string { /** Metadata for a batch operation item, used for debugging and error reporting. */ export interface BatchItemMeta { /** The OCF entity type (e.g., 'stockIssuance', 'stakeholder') */ - entityType: string; + entityType: OcfEntityType; /** The canonical OCF object ID */ id: string; /** The security_id for issuance types (stockIssuance, convertibleIssuance, etc.) */ @@ -98,25 +107,23 @@ export class CapTableBatch { * @param type - The OCF entity type to create (e.g., 'stakeholder', 'stockClass') * @param data - The native OCF data for the entity * @returns This for chaining - * @throws OcpValidationError if type is 'issuer' (issuer is created with CapTable via IssuerAuthorization) + * Unsupported entity kinds are rejected by TypeScript and guarded at runtime for untyped callers. */ - create(type: T, data: OcfDataTypeFor): this { - // Issuer is edit-only (created with CapTable via IssuerAuthorization.CreateCapTable) - if (type === 'issuer') { - throw new OcpValidationError( - 'type', - 'Cannot create issuer via batch - issuer is created with the CapTable via IssuerAuthorization.CreateCapTable', - { code: OcpErrorCodes.INVALID_TYPE } - ); + create(...args: OcfCreateArguments): this { + const [type, data] = args; + if (!isOcfCreatableEntityType(type)) { + throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { + code: OcpErrorCodes.INVALID_TYPE, + }); } - const damlData = convertToDaml(type, data); const tag = ENTITY_TAG_MAP[type].create; if (!tag) { throw new OcpValidationError('type', `Create operation not supported for entity type: ${type}`, { code: OcpErrorCodes.INVALID_TYPE, }); } + const damlData = convertToDaml(...args); this.creates.push({ tag, value: damlData } as unknown as OcfCreateData); this.createMetas.push(extractBatchItemMeta(type, data)); return this; @@ -129,14 +136,21 @@ export class CapTableBatch { * @param data - The updated native OCF data (must include the entity's id) * @returns This for chaining */ - edit(type: T, data: OcfDataTypeFor): this { - const damlData = convertToDaml(type, data); + edit(...args: OcfEditArguments): this { + const [type, data] = args; + if (!isOcfEditableEntityType(type)) { + throw new OcpValidationError('type', `Edit operation not supported for entity type: ${String(type)}`, { + code: OcpErrorCodes.INVALID_TYPE, + }); + } + const tag = ENTITY_TAG_MAP[type].edit; if (!tag) { throw new OcpValidationError('type', `Edit operation not supported for entity type: ${type}`, { code: OcpErrorCodes.INVALID_TYPE, }); } + const damlData = convertToDaml(...args); this.edits.push({ tag, value: damlData } as unknown as OcfEditData); this.editMetas.push(extractBatchItemMeta(type, data)); return this; @@ -148,12 +162,11 @@ export class CapTableBatch { * @param type - The OCF entity type to delete * @param id - The OCF object ID to delete * @returns This for chaining - * @throws OcpValidationError if type is 'issuer' (issuer cannot be deleted) + * Unsupported entity kinds are rejected by TypeScript and guarded at runtime for untyped callers. */ - delete(type: OcfEntityType, id: string): this { - // Issuer cannot be deleted - it must always exist for the CapTable - if (type === 'issuer') { - throw new OcpValidationError('type', 'Cannot delete issuer - issuer must always exist for the CapTable', { + delete(type: OcfDeletableEntityType, id: string): this { + if (!isOcfDeletableEntityType(type)) { + throw new OcpValidationError('type', `Delete operation not supported for entity type: ${String(type)}`, { code: OcpErrorCodes.INVALID_TYPE, }); } @@ -443,25 +456,14 @@ export interface BatchItemDetails { deletes: BatchItemMeta[]; } -/** Entity types that have a security_id field. */ -const ISSUANCE_ENTITY_TYPES = new Set([ - 'stockIssuance', - 'convertibleIssuance', - 'equityCompensationIssuance', - 'warrantIssuance', - 'planSecurityIssuance', -]); - /** * Extract debugging metadata from native OCF data. * Pulls id, and security_id for issuance types. */ -function extractBatchItemMeta(entityType: string, data: unknown): BatchItemMeta { - const obj = data as Record | undefined; - const id = typeof obj?.id === 'string' ? obj.id : 'unknown'; - const meta: BatchItemMeta = { entityType, id }; - if (ISSUANCE_ENTITY_TYPES.has(entityType)) { - const securityId = obj?.security_id; +function extractBatchItemMeta(entityType: T, data: OcfDataTypeFor): BatchItemMeta { + const meta: BatchItemMeta = { entityType, id: data.id }; + if ('security_id' in data) { + const securityId = data.security_id; if (typeof securityId === 'string') { meta.securityId = securityId; } @@ -509,19 +511,15 @@ function findUndefinedPath(value: unknown, currentPath: string): string | undefi */ export function buildUpdateCapTableCommand( params: Omit, - operations: { - creates?: Array<{ type: OcfEntityType; data: OcfDataTypeFor }>; - edits?: Array<{ type: OcfEntityType; data: OcfDataTypeFor }>; - deletes?: Array<{ type: OcfEntityType; id: string }>; - } + operations: CapTableBatchOperations ): CommandWithDisclosedContracts { const batch = new CapTableBatch({ ...params, actAs: [] }); for (const op of operations.creates ?? []) { - batch.create(op.type, op.data); + batch.create(...createOperationArguments(op)); } for (const op of operations.edits ?? []) { - batch.edit(op.type, op.data); + batch.edit(...editOperationArguments(op)); } for (const op of operations.deletes ?? []) { batch.delete(op.type, op.id); @@ -529,3 +527,11 @@ export function buildUpdateCapTableCommand( return batch.build(); } + +function createOperationArguments(operation: OcfCreateOperation): OcfCreateArguments { + return [operation.type, operation.data] as OcfCreateArguments; +} + +function editOperationArguments(operation: OcfEditOperation): OcfEditArguments { + return [operation.type, operation.data] as OcfEditArguments; +} diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index e6d77d70..5e186085 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -81,81 +81,15 @@ export type CapTableBatchExecuteResult = UpdateCapTableResult & { updateId: string; }; -/** - * All supported OCF entity types for batch operations. - * Maps to the OcfCreateData/OcfEditData/OcfDeleteData union tags. - * - * Note: `planSecurity*` types are aliases for their `equityCompensation*` equivalents. - * The SDK accepts both type families and normalizes PlanSecurity to EquityCompensation internally. - * - * Note: `issuer` is edit-only (no create/delete). Issuers are created with the CapTable - * via IssuerAuthorization.CreateCapTable and cannot be deleted. - */ -export type OcfEntityType = - | 'convertibleAcceptance' - | 'convertibleCancellation' - | 'convertibleConversion' - | 'convertibleIssuance' - | 'convertibleRetraction' - | 'convertibleTransfer' - | 'document' - | 'equityCompensationAcceptance' - | 'equityCompensationCancellation' - | 'equityCompensationExercise' - | 'equityCompensationIssuance' - | 'equityCompensationRelease' - | 'equityCompensationRepricing' - | 'equityCompensationRetraction' - | 'equityCompensationTransfer' - | 'issuer' - | 'issuerAuthorizedSharesAdjustment' - // PlanSecurity types are aliases for EquityCompensation types - | 'planSecurityAcceptance' - | 'planSecurityCancellation' - | 'planSecurityExercise' - | 'planSecurityIssuance' - | 'planSecurityRelease' - | 'planSecurityRetraction' - | 'planSecurityTransfer' - | 'stakeholder' - | 'stakeholderRelationshipChangeEvent' - | 'stakeholderStatusChangeEvent' - | 'stockAcceptance' - | 'stockCancellation' - | 'stockClass' - | 'stockClassAuthorizedSharesAdjustment' - | 'stockClassConversionRatioAdjustment' - | 'stockClassSplit' - | 'stockConsolidation' - | 'stockConversion' - | 'stockIssuance' - | 'stockLegendTemplate' - | 'stockPlan' - | 'stockPlanPoolAdjustment' - | 'stockPlanReturnToPool' - | 'stockReissuance' - | 'stockRepurchase' - | 'stockRetraction' - | 'stockTransfer' - | 'valuation' - | 'vestingAcceleration' - | 'vestingEvent' - | 'vestingStart' - | 'vestingTerms' - | 'warrantAcceptance' - | 'warrantCancellation' - | 'warrantExercise' - | 'warrantIssuance' - | 'warrantRetraction' - | 'warrantTransfer'; - /** * Type mapping from entity type string to native OCF data type. * * Note: PlanSecurity types map to their equivalent OCF PlanSecurity interfaces, * which are compatible with the EquityCompensation types they alias. */ -export interface OcfEntityDataMap { +// A closed alias prevents module augmentation from widening OcfEntityType beyond ENTITY_REGISTRY. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type OcfEntityDataMap = { convertibleAcceptance: OcfConvertibleAcceptance; convertibleCancellation: OcfConvertibleCancellation; convertibleConversion: OcfConvertibleConversion; @@ -213,11 +147,54 @@ export interface OcfEntityDataMap { warrantIssuance: OcfWarrantIssuance; warrantRetraction: OcfWarrantRetraction; warrantTransfer: OcfWarrantTransfer; -} +}; + +/** + * All supported OCF entity types for batch operations. + * + * Derived from {@link OcfEntityDataMap} so an entity kind cannot be added to the + * data model without also becoming part of the SDK's canonical entity-kind union. + * Operation-specific subsets are derived from {@link ENTITY_REGISTRY} below. + */ +export type OcfEntityType = keyof OcfEntityDataMap; /** Helper type to get the native OCF data type for a given entity type. */ export type OcfDataTypeFor = OcfEntityDataMap[T]; +type OcfCreateTag = OcfCreateData['tag']; +type OcfEditTag = OcfEditData['tag']; +type OcfDeleteTag = OcfDeleteData['tag']; + +type BatchNameFor = Tag extends `${Prefix}${infer Name}` ? Name : never; +type CreatableBatchName = BatchNameFor; +type EditableBatchName = BatchNameFor; +type DeletableBatchName = BatchNameFor; +type FullyMutableBatchName = CreatableBatchName & EditableBatchName & DeletableBatchName; +type BatchNameForEntity = EntityType extends `planSecurity${infer Suffix}` + ? `EquityCompensation${Suffix}` + : Capitalize; + +/** Exact generated DAML tags supported by an entity in UpdateCapTable. */ +export type OcfEntityOperationTags = Readonly<{ + create?: OcfCreateTag; + edit?: OcfEditTag; + delete?: OcfDeleteTag; +}>; + +function mutableEntityOperations(name: Name) { + return { + create: `OcfCreate${name}` as Extract, + edit: `OcfEdit${name}` as Extract, + delete: `OcfDelete${name}` as Extract, + } as const; +} + +function editOnlyEntityOperations(name: Name) { + return { + edit: `OcfEdit${name}` as Extract, + } as const; +} + /** Shared registry entry for all OCF entity metadata used by batch, read, schema, and state code. */ export interface OcfEntityRegistryEntry { /** Canonical OCF object_type accepted by schema validation. */ @@ -230,14 +207,26 @@ export interface OcfEntityRegistryEntry { capTableField?: string; /** CapTable map field that stores security-id uniqueness entries. */ securityIdField?: string; - /** DAML union suffix used to derive `OcfCreateX`, `OcfEditX`, and `OcfDeleteX` tags. */ - batchName: string; - /** False for edit-only entities such as issuer. */ - supportsCreate?: boolean; - /** False for non-deletable entities such as issuer. */ - supportsDelete?: boolean; + /** Exact generated DAML union tags supported by this entity. */ + operations: OcfEntityOperationTags; } +type MutableOperationsFor = Readonly<{ + create: Extract}`>; + edit: Extract}`>; + delete: Extract}`>; +}>; + +type EditOnlyOperationsFor = Readonly<{ + edit: Extract}`>; +}>; + +type OcfEntityRegistry = { + [EntityType in OcfEntityType]: Omit & { + operations: EntityType extends 'issuer' ? EditOnlyOperationsFor : MutableOperationsFor; + }; +}; + /** * Single source of truth for entity-level metadata. * @@ -249,336 +238,424 @@ export const ENTITY_REGISTRY = { objectType: 'TX_CONVERTIBLE_ACCEPTANCE', dataField: 'acceptance_data', capTableField: 'convertible_acceptances', - batchName: 'ConvertibleAcceptance', + operations: mutableEntityOperations('ConvertibleAcceptance'), }, convertibleCancellation: { objectType: 'TX_CONVERTIBLE_CANCELLATION', dataField: 'cancellation_data', capTableField: 'convertible_cancellations', - batchName: 'ConvertibleCancellation', + operations: mutableEntityOperations('ConvertibleCancellation'), }, convertibleConversion: { objectType: 'TX_CONVERTIBLE_CONVERSION', dataField: 'conversion_data', capTableField: 'convertible_conversions', - batchName: 'ConvertibleConversion', + operations: mutableEntityOperations('ConvertibleConversion'), }, convertibleIssuance: { objectType: 'TX_CONVERTIBLE_ISSUANCE', dataField: 'issuance_data', capTableField: 'convertible_issuances', securityIdField: 'convertible_issuances_by_security_id', - batchName: 'ConvertibleIssuance', + operations: mutableEntityOperations('ConvertibleIssuance'), }, convertibleRetraction: { objectType: 'TX_CONVERTIBLE_RETRACTION', dataField: 'retraction_data', capTableField: 'convertible_retractions', - batchName: 'ConvertibleRetraction', + operations: mutableEntityOperations('ConvertibleRetraction'), }, convertibleTransfer: { objectType: 'TX_CONVERTIBLE_TRANSFER', dataField: 'transfer_data', capTableField: 'convertible_transfers', - batchName: 'ConvertibleTransfer', + operations: mutableEntityOperations('ConvertibleTransfer'), }, document: { objectType: 'DOCUMENT', dataField: 'document_data', capTableField: 'documents', - batchName: 'Document', + operations: mutableEntityOperations('Document'), }, equityCompensationAcceptance: { objectType: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', dataField: 'acceptance_data', capTableField: 'equity_compensation_acceptances', - batchName: 'EquityCompensationAcceptance', + operations: mutableEntityOperations('EquityCompensationAcceptance'), }, equityCompensationCancellation: { objectType: 'TX_EQUITY_COMPENSATION_CANCELLATION', dataField: 'cancellation_data', capTableField: 'equity_compensation_cancellations', - batchName: 'EquityCompensationCancellation', + operations: mutableEntityOperations('EquityCompensationCancellation'), }, equityCompensationExercise: { objectType: 'TX_EQUITY_COMPENSATION_EXERCISE', dataField: 'exercise_data', capTableField: 'equity_compensation_exercises', - batchName: 'EquityCompensationExercise', + operations: mutableEntityOperations('EquityCompensationExercise'), }, equityCompensationIssuance: { objectType: 'TX_EQUITY_COMPENSATION_ISSUANCE', dataField: 'issuance_data', capTableField: 'equity_compensation_issuances', securityIdField: 'equity_compensation_issuances_by_security_id', - batchName: 'EquityCompensationIssuance', + operations: mutableEntityOperations('EquityCompensationIssuance'), }, equityCompensationRelease: { objectType: 'TX_EQUITY_COMPENSATION_RELEASE', dataField: 'release_data', capTableField: 'equity_compensation_releases', - batchName: 'EquityCompensationRelease', + operations: mutableEntityOperations('EquityCompensationRelease'), }, equityCompensationRepricing: { objectType: 'TX_EQUITY_COMPENSATION_REPRICING', dataField: 'repricing_data', capTableField: 'equity_compensation_repricings', - batchName: 'EquityCompensationRepricing', + operations: mutableEntityOperations('EquityCompensationRepricing'), }, equityCompensationRetraction: { objectType: 'TX_EQUITY_COMPENSATION_RETRACTION', dataField: 'retraction_data', capTableField: 'equity_compensation_retractions', - batchName: 'EquityCompensationRetraction', + operations: mutableEntityOperations('EquityCompensationRetraction'), }, equityCompensationTransfer: { objectType: 'TX_EQUITY_COMPENSATION_TRANSFER', dataField: 'transfer_data', capTableField: 'equity_compensation_transfers', - batchName: 'EquityCompensationTransfer', + operations: mutableEntityOperations('EquityCompensationTransfer'), }, issuer: { objectType: 'ISSUER', dataField: 'issuer_data', - batchName: 'Issuer', - supportsCreate: false, - supportsDelete: false, + operations: editOnlyEntityOperations('Issuer'), }, issuerAuthorizedSharesAdjustment: { objectType: 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT', dataField: 'adjustment_data', capTableField: 'issuer_authorized_shares_adjustments', - batchName: 'IssuerAuthorizedSharesAdjustment', + operations: mutableEntityOperations('IssuerAuthorizedSharesAdjustment'), }, planSecurityAcceptance: { objectType: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', dataField: 'acceptance_data', - batchName: 'EquityCompensationAcceptance', + operations: mutableEntityOperations('EquityCompensationAcceptance'), }, planSecurityCancellation: { objectType: 'TX_EQUITY_COMPENSATION_CANCELLATION', dataField: 'cancellation_data', - batchName: 'EquityCompensationCancellation', + operations: mutableEntityOperations('EquityCompensationCancellation'), }, planSecurityExercise: { objectType: 'TX_EQUITY_COMPENSATION_EXERCISE', dataField: 'exercise_data', - batchName: 'EquityCompensationExercise', + operations: mutableEntityOperations('EquityCompensationExercise'), }, planSecurityIssuance: { objectType: 'TX_EQUITY_COMPENSATION_ISSUANCE', dataField: 'issuance_data', - batchName: 'EquityCompensationIssuance', + operations: mutableEntityOperations('EquityCompensationIssuance'), }, planSecurityRelease: { objectType: 'TX_EQUITY_COMPENSATION_RELEASE', dataField: 'release_data', - batchName: 'EquityCompensationRelease', + operations: mutableEntityOperations('EquityCompensationRelease'), }, planSecurityRetraction: { objectType: 'TX_EQUITY_COMPENSATION_RETRACTION', dataField: 'retraction_data', - batchName: 'EquityCompensationRetraction', + operations: mutableEntityOperations('EquityCompensationRetraction'), }, planSecurityTransfer: { objectType: 'TX_EQUITY_COMPENSATION_TRANSFER', dataField: 'transfer_data', - batchName: 'EquityCompensationTransfer', + operations: mutableEntityOperations('EquityCompensationTransfer'), }, stakeholder: { objectType: 'STAKEHOLDER', dataField: 'stakeholder_data', capTableField: 'stakeholders', - batchName: 'Stakeholder', + operations: mutableEntityOperations('Stakeholder'), }, stakeholderRelationshipChangeEvent: { objectType: 'CE_STAKEHOLDER_RELATIONSHIP', dataField: 'relationship_change_data', dataFieldFallbacks: ['event_data'], capTableField: 'stakeholder_relationship_change_events', - batchName: 'StakeholderRelationshipChangeEvent', + operations: mutableEntityOperations('StakeholderRelationshipChangeEvent'), }, stakeholderStatusChangeEvent: { objectType: 'CE_STAKEHOLDER_STATUS', dataField: 'status_change_data', dataFieldFallbacks: ['event_data'], capTableField: 'stakeholder_status_change_events', - batchName: 'StakeholderStatusChangeEvent', + operations: mutableEntityOperations('StakeholderStatusChangeEvent'), }, stockAcceptance: { objectType: 'TX_STOCK_ACCEPTANCE', dataField: 'acceptance_data', capTableField: 'stock_acceptances', - batchName: 'StockAcceptance', + operations: mutableEntityOperations('StockAcceptance'), }, stockCancellation: { objectType: 'TX_STOCK_CANCELLATION', dataField: 'cancellation_data', capTableField: 'stock_cancellations', - batchName: 'StockCancellation', + operations: mutableEntityOperations('StockCancellation'), }, stockClass: { objectType: 'STOCK_CLASS', dataField: 'stock_class_data', capTableField: 'stock_classes', - batchName: 'StockClass', + operations: mutableEntityOperations('StockClass'), }, stockClassAuthorizedSharesAdjustment: { objectType: 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT', dataField: 'adjustment_data', capTableField: 'stock_class_authorized_shares_adjustments', - batchName: 'StockClassAuthorizedSharesAdjustment', + operations: mutableEntityOperations('StockClassAuthorizedSharesAdjustment'), }, stockClassConversionRatioAdjustment: { objectType: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', dataField: 'adjustment_data', capTableField: 'stock_class_conversion_ratio_adjustments', - batchName: 'StockClassConversionRatioAdjustment', + operations: mutableEntityOperations('StockClassConversionRatioAdjustment'), }, stockClassSplit: { objectType: 'TX_STOCK_CLASS_SPLIT', dataField: 'split_data', capTableField: 'stock_class_splits', - batchName: 'StockClassSplit', + operations: mutableEntityOperations('StockClassSplit'), }, stockConsolidation: { objectType: 'TX_STOCK_CONSOLIDATION', dataField: 'consolidation_data', capTableField: 'stock_consolidations', - batchName: 'StockConsolidation', + operations: mutableEntityOperations('StockConsolidation'), }, stockConversion: { objectType: 'TX_STOCK_CONVERSION', dataField: 'conversion_data', capTableField: 'stock_conversions', - batchName: 'StockConversion', + operations: mutableEntityOperations('StockConversion'), }, stockIssuance: { objectType: 'TX_STOCK_ISSUANCE', dataField: 'issuance_data', capTableField: 'stock_issuances', securityIdField: 'stock_issuances_by_security_id', - batchName: 'StockIssuance', + operations: mutableEntityOperations('StockIssuance'), }, stockLegendTemplate: { objectType: 'STOCK_LEGEND_TEMPLATE', dataField: 'template_data', capTableField: 'stock_legend_templates', - batchName: 'StockLegendTemplate', + operations: mutableEntityOperations('StockLegendTemplate'), }, stockPlan: { objectType: 'STOCK_PLAN', dataField: 'stock_plan_data', capTableField: 'stock_plans', - batchName: 'StockPlan', + operations: mutableEntityOperations('StockPlan'), }, stockPlanPoolAdjustment: { objectType: 'TX_STOCK_PLAN_POOL_ADJUSTMENT', dataField: 'adjustment_data', capTableField: 'stock_plan_pool_adjustments', - batchName: 'StockPlanPoolAdjustment', + operations: mutableEntityOperations('StockPlanPoolAdjustment'), }, stockPlanReturnToPool: { objectType: 'TX_STOCK_PLAN_RETURN_TO_POOL', dataField: 'return_data', capTableField: 'stock_plan_return_to_pools', - batchName: 'StockPlanReturnToPool', + operations: mutableEntityOperations('StockPlanReturnToPool'), }, stockReissuance: { objectType: 'TX_STOCK_REISSUANCE', dataField: 'reissuance_data', capTableField: 'stock_reissuances', - batchName: 'StockReissuance', + operations: mutableEntityOperations('StockReissuance'), }, stockRepurchase: { objectType: 'TX_STOCK_REPURCHASE', dataField: 'repurchase_data', capTableField: 'stock_repurchases', - batchName: 'StockRepurchase', + operations: mutableEntityOperations('StockRepurchase'), }, stockRetraction: { objectType: 'TX_STOCK_RETRACTION', dataField: 'retraction_data', capTableField: 'stock_retractions', - batchName: 'StockRetraction', + operations: mutableEntityOperations('StockRetraction'), }, stockTransfer: { objectType: 'TX_STOCK_TRANSFER', dataField: 'transfer_data', capTableField: 'stock_transfers', - batchName: 'StockTransfer', + operations: mutableEntityOperations('StockTransfer'), }, valuation: { objectType: 'VALUATION', dataField: 'valuation_data', capTableField: 'valuations', - batchName: 'Valuation', + operations: mutableEntityOperations('Valuation'), }, vestingAcceleration: { objectType: 'TX_VESTING_ACCELERATION', dataField: 'acceleration_data', dataFieldFallbacks: ['vesting_acceleration_data'], capTableField: 'vesting_accelerations', - batchName: 'VestingAcceleration', + operations: mutableEntityOperations('VestingAcceleration'), }, vestingEvent: { objectType: 'TX_VESTING_EVENT', dataField: 'vesting_data', dataFieldFallbacks: ['vesting_event_data'], capTableField: 'vesting_events', - batchName: 'VestingEvent', + operations: mutableEntityOperations('VestingEvent'), }, vestingStart: { objectType: 'TX_VESTING_START', dataField: 'vesting_data', dataFieldFallbacks: ['vesting_start_data'], capTableField: 'vesting_starts', - batchName: 'VestingStart', + operations: mutableEntityOperations('VestingStart'), }, vestingTerms: { objectType: 'VESTING_TERMS', dataField: 'vesting_terms_data', capTableField: 'vesting_terms', - batchName: 'VestingTerms', + operations: mutableEntityOperations('VestingTerms'), }, warrantAcceptance: { objectType: 'TX_WARRANT_ACCEPTANCE', dataField: 'acceptance_data', capTableField: 'warrant_acceptances', - batchName: 'WarrantAcceptance', + operations: mutableEntityOperations('WarrantAcceptance'), }, warrantCancellation: { objectType: 'TX_WARRANT_CANCELLATION', dataField: 'cancellation_data', capTableField: 'warrant_cancellations', - batchName: 'WarrantCancellation', + operations: mutableEntityOperations('WarrantCancellation'), }, warrantExercise: { objectType: 'TX_WARRANT_EXERCISE', dataField: 'exercise_data', capTableField: 'warrant_exercises', - batchName: 'WarrantExercise', + operations: mutableEntityOperations('WarrantExercise'), }, warrantIssuance: { objectType: 'TX_WARRANT_ISSUANCE', dataField: 'issuance_data', capTableField: 'warrant_issuances', securityIdField: 'warrant_issuances_by_security_id', - batchName: 'WarrantIssuance', + operations: mutableEntityOperations('WarrantIssuance'), }, warrantRetraction: { objectType: 'TX_WARRANT_RETRACTION', dataField: 'retraction_data', capTableField: 'warrant_retractions', - batchName: 'WarrantRetraction', + operations: mutableEntityOperations('WarrantRetraction'), }, warrantTransfer: { objectType: 'TX_WARRANT_TRANSFER', dataField: 'transfer_data', capTableField: 'warrant_transfers', - batchName: 'WarrantTransfer', + operations: mutableEntityOperations('WarrantTransfer'), }, -} as const satisfies Record; +} as const satisfies OcfEntityRegistry; + +type EntityTypeWithCapability = { + [EntityType in OcfEntityType]: (typeof ENTITY_REGISTRY)[EntityType]['operations'] extends Record + ? EntityType + : never; +}[OcfEntityType]; + +/** Entity kinds that can be created through UpdateCapTable. */ +export type OcfCreatableEntityType = EntityTypeWithCapability<'create'>; + +/** Entity kinds that can be edited through UpdateCapTable. */ +export type OcfEditableEntityType = EntityTypeWithCapability<'edit'>; + +/** Entity kinds that can be deleted through UpdateCapTable. */ +export type OcfDeletableEntityType = EntityTypeWithCapability<'delete'>; + +/** Correlated entity-kind and native-data tuples accepted by the converter dispatcher. */ +export type OcfEntityArguments = { + [EntityType in OcfEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; +}[OcfEntityType]; + +/** Correlated argument tuples accepted by {@link CapTableBatch.create}. */ +export type OcfCreateArguments = { + [EntityType in OcfCreatableEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; +}[OcfCreatableEntityType]; + +/** Correlated argument tuples accepted by {@link CapTableBatch.edit}. */ +export type OcfEditArguments = { + [EntityType in OcfEditableEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; +}[OcfEditableEntityType]; + +/** A create operation whose entity kind and payload type are correlated. */ +export type OcfCreateOperation = + EntityType extends OcfCreatableEntityType + ? Readonly<{ + type: EntityType; + data: OcfDataTypeFor; + }> + : never; + +/** An edit operation whose entity kind and payload type are correlated. */ +export type OcfEditOperation = + EntityType extends OcfEditableEntityType + ? Readonly<{ + type: EntityType; + data: OcfDataTypeFor; + }> + : never; + +/** A delete operation limited to entity kinds that support deletion. */ +export type OcfDeleteOperation = + EntityType extends OcfDeletableEntityType + ? Readonly<{ + type: EntityType; + id: string; + }> + : never; + +/** Operations accepted by {@link buildUpdateCapTableCommand}. */ +export interface CapTableBatchOperations { + readonly creates?: readonly OcfCreateOperation[]; + readonly edits?: readonly OcfEditOperation[]; + readonly deletes?: readonly OcfDeleteOperation[]; +} + +/** Runtime guard for SDK entity kinds. */ +export function isOcfEntityType(entityType: string): entityType is OcfEntityType { + return Object.prototype.hasOwnProperty.call(ENTITY_REGISTRY, entityType); +} + +/** Runtime guard for entity kinds that support create operations. */ +export function isOcfCreatableEntityType(entityType: string): entityType is OcfCreatableEntityType { + if (!isOcfEntityType(entityType)) return false; + const entry: OcfEntityRegistryEntry = ENTITY_REGISTRY[entityType]; + return entry.operations.create !== undefined; +} + +/** Runtime guard for entity kinds that support edit operations. */ +export function isOcfEditableEntityType(entityType: string): entityType is OcfEditableEntityType { + if (!isOcfEntityType(entityType)) return false; + const entry: OcfEntityRegistryEntry = ENTITY_REGISTRY[entityType]; + return entry.operations.edit !== undefined; +} + +/** Runtime guard for entity kinds that support delete operations. */ +export function isOcfDeletableEntityType(entityType: string): entityType is OcfDeletableEntityType { + if (!isOcfEntityType(entityType)) return false; + const entry: OcfEntityRegistryEntry = ENTITY_REGISTRY[entityType]; + return entry.operations.delete !== undefined; +} /** * Canonical OCF `object_type` to SDK entity reader mapping. @@ -714,8 +791,4 @@ export const SECURITY_ID_FIELD_TO_ENTITY_TYPE = Object.fromEntries( * PlanSecurity entries derive EquityCompensation tags from ENTITY_REGISTRY because the underlying DAML unions are the * same. */ -export const ENTITY_TAG_MAP = mapRegistryValues((entry) => ({ - create: entry.supportsCreate === false ? undefined : `OcfCreate${entry.batchName}`, - edit: `OcfEdit${entry.batchName}`, - delete: entry.supportsDelete === false ? undefined : `OcfDelete${entry.batchName}`, -})); +export const ENTITY_TAG_MAP = mapRegistryValues((entry) => entry.operations); diff --git a/src/functions/OpenCapTable/capTable/ocfToDaml.ts b/src/functions/OpenCapTable/capTable/ocfToDaml.ts index 1d730f74..5cfeaaa5 100644 --- a/src/functions/OpenCapTable/capTable/ocfToDaml.ts +++ b/src/functions/OpenCapTable/capTable/ocfToDaml.ts @@ -11,7 +11,7 @@ import { OcpErrorCodes, OcpParseError } from '../../../errors'; import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; -import type { OcfDataTypeFor, OcfEntityType } from './batchTypes'; +import type { OcfDataTypeFor, OcfEntityArguments } from './batchTypes'; // Import converters from entity folders import { convertibleAcceptanceDataToDaml } from '../convertibleAcceptance/convertibleAcceptanceDataToDaml'; @@ -72,7 +72,8 @@ import { warrantTransferDataToDaml } from '../warrantTransfer/warrantTransferDat * @param data - The native OCF data object * @returns The DAML-formatted data object */ -export function convertToDaml(type: T, data: OcfDataTypeFor): Record { +export function convertToDaml(...args: OcfEntityArguments): Record { + const [type, data] = args; const d = parseOcfEntityInput(type, data); switch (type) { diff --git a/src/utils/replicationHelpers.ts b/src/utils/replicationHelpers.ts index 0de26883..78142cae 100644 --- a/src/utils/replicationHelpers.ts +++ b/src/utils/replicationHelpers.ts @@ -15,82 +15,6 @@ import type { OcfManifest } from './cantonOcfExtractor'; import { DEFAULT_DEPRECATED_FIELDS, DEFAULT_INTERNAL_FIELDS, ocfDeepEqual } from './ocfComparison'; import { normalizeEntityType, normalizeObjectType, normalizeOcfData } from './planSecurityAliases'; -// ============================================================================ -// OcfEntityType Validation -// ============================================================================ - -/** - * Runtime set of all valid OcfEntityType values. - * Used for runtime validation when entity types come from external sources. - */ -const VALID_OCF_ENTITY_TYPES: ReadonlySet = new Set([ - 'convertibleAcceptance', - 'convertibleCancellation', - 'convertibleConversion', - 'convertibleIssuance', - 'convertibleRetraction', - 'convertibleTransfer', - 'document', - 'equityCompensationAcceptance', - 'equityCompensationCancellation', - 'equityCompensationExercise', - 'equityCompensationIssuance', - 'equityCompensationRelease', - 'equityCompensationRepricing', - 'equityCompensationRetraction', - 'equityCompensationTransfer', - 'issuer', - 'issuerAuthorizedSharesAdjustment', - 'planSecurityAcceptance', - 'planSecurityCancellation', - 'planSecurityExercise', - 'planSecurityIssuance', - 'planSecurityRelease', - 'planSecurityRetraction', - 'planSecurityTransfer', - 'stakeholder', - 'stakeholderRelationshipChangeEvent', - 'stakeholderStatusChangeEvent', - 'stockAcceptance', - 'stockCancellation', - 'stockClass', - 'stockClassAuthorizedSharesAdjustment', - 'stockClassConversionRatioAdjustment', - 'stockClassSplit', - 'stockConsolidation', - 'stockConversion', - 'stockIssuance', - 'stockLegendTemplate', - 'stockPlan', - 'stockPlanPoolAdjustment', - 'stockPlanReturnToPool', - 'stockReissuance', - 'stockRepurchase', - 'stockRetraction', - 'stockTransfer', - 'valuation', - 'vestingAcceleration', - 'vestingEvent', - 'vestingStart', - 'vestingTerms', - 'warrantAcceptance', - 'warrantCancellation', - 'warrantExercise', - 'warrantIssuance', - 'warrantRetraction', - 'warrantTransfer', -]); - -/** - * Runtime type guard for OcfEntityType. - * - * @param value - The value to check - * @returns True if value is a valid OcfEntityType - */ -export function isOcfEntityType(value: string): value is OcfEntityType { - return VALID_OCF_ENTITY_TYPES.has(value); -} - // ============================================================================ // Categorized Type Mapping // ============================================================================ diff --git a/test/batch/CapTableBatch.test.ts b/test/batch/CapTableBatch.test.ts index da391d77..8be6184b 100644 --- a/test/batch/CapTableBatch.test.ts +++ b/test/batch/CapTableBatch.test.ts @@ -191,12 +191,13 @@ describe('CapTableBatch', () => { }; try { + // @ts-expect-error issuer is edit-only; exercise the runtime guard for untyped callers batch.create('issuer', issuerData); throw new Error('Expected OcpValidationError to be thrown'); } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); const validationError = error as OcpValidationError; - expect(validationError.message).toContain('Cannot create issuer via batch'); + expect(validationError.message).toContain('Create operation not supported for entity type: issuer'); expect(validationError.fieldPath).toBe('type'); expect(validationError.code).toBe(OcpErrorCodes.INVALID_TYPE); } @@ -209,12 +210,13 @@ describe('CapTableBatch', () => { }); try { + // @ts-expect-error issuer is edit-only; exercise the runtime guard for untyped callers batch.delete('issuer', 'issuer-123'); throw new Error('Expected OcpValidationError to be thrown'); } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); const validationError = error as OcpValidationError; - expect(validationError.message).toContain('Cannot delete issuer'); + expect(validationError.message).toContain('Delete operation not supported for entity type: issuer'); expect(validationError.fieldPath).toBe('type'); expect(validationError.code).toBe(OcpErrorCodes.INVALID_TYPE); } diff --git a/test/batch/batchTypes.test.ts b/test/batch/batchTypes.test.ts new file mode 100644 index 00000000..6cf51ce0 --- /dev/null +++ b/test/batch/batchTypes.test.ts @@ -0,0 +1,33 @@ +import { + ENTITY_REGISTRY, + isOcfCreatableEntityType, + isOcfDeletableEntityType, + isOcfEditableEntityType, + isOcfEntityType, + type OcfEntityType, +} from '../../src'; + +const entityTypes = Object.keys(ENTITY_REGISTRY) as OcfEntityType[]; + +describe('batch entity capabilities', () => { + it.each(entityTypes)('recognizes %s as a supported entity type', (entityType) => { + expect(isOcfEntityType(entityType)).toBe(true); + }); + + it('rejects unknown entity types', () => { + expect(isOcfEntityType('notAnEntity')).toBe(false); + }); + + it.each(entityTypes)('derives the complete operation set for %s from the registry', (entityType) => { + const isIssuer = entityType === 'issuer'; + expect(isOcfCreatableEntityType(entityType)).toBe(!isIssuer); + expect(isOcfEditableEntityType(entityType)).toBe(true); + expect(isOcfDeletableEntityType(entityType)).toBe(!isIssuer); + }); + + it('rejects unknown entity types for every capability', () => { + expect(isOcfCreatableEntityType('notAnEntity')).toBe(false); + expect(isOcfEditableEntityType('notAnEntity')).toBe(false); + expect(isOcfDeletableEntityType('notAnEntity')).toBe(false); + }); +}); diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts new file mode 100644 index 00000000..3718de5f --- /dev/null +++ b/test/declarations/publicApi.types.ts @@ -0,0 +1,51 @@ +/** Compile-time smoke tests for declarations exported by the built SDK. */ + +import { + convertToDaml, + type CapTableBatch, + type CapTableBatchOperations, + type OcfIssuer, + type OcfStakeholder, + type OcfStockClass, +} from '../../dist'; + +function verifyPublishedBatchApi( + batch: CapTableBatch, + stakeholder: OcfStakeholder, + stockClass: OcfStockClass, + issuer: OcfIssuer +): void { + batch.create('stakeholder', stakeholder); + batch.create('stockClass', stockClass); + batch.edit('issuer', issuer); + batch.delete('stakeholder', stakeholder.id); + + // @ts-expect-error issuer is edit-only + batch.create('issuer', issuer); + + // @ts-expect-error issuer cannot be deleted from a cap table + batch.delete('issuer', issuer.id); + + // @ts-expect-error the published declaration must correlate kind and payload + batch.create('stockClass', stakeholder); + + const widenedKind = 'stakeholder' as 'stakeholder' | 'stockClass'; + + // @ts-expect-error a union-valued kind does not prove which payload belongs to it + batch.create(widenedKind, stakeholder); + + // @ts-expect-error a union-valued kind cannot bypass edit payload correlation + batch.edit(widenedKind, stakeholder); + + // @ts-expect-error a union-valued kind cannot bypass converter payload correlation + convertToDaml(widenedKind, stakeholder); + + const operations: CapTableBatchOperations = { + creates: [{ type: 'stakeholder', data: stakeholder }], + edits: [{ type: 'issuer', data: issuer }], + deletes: [{ type: 'stockClass', id: stockClass.id }], + }; + void operations; +} + +void verifyPublishedBatchApi; diff --git a/test/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts new file mode 100644 index 00000000..e5a033a7 --- /dev/null +++ b/test/types/capTableBatch.types.ts @@ -0,0 +1,77 @@ +/** + * Compile-time contract tests for the public CapTableBatch API. + * + * This file is included by tsconfig.tests.json but intentionally does not match + * Jest's test-file pattern. `npm run typecheck` is the test runner: every + * `@ts-expect-error` must continue to describe a real compiler error. + */ + +import { + type CapTableBatch, + type CapTableBatchOperations, + convertToDaml, + type OcfCreateOperation, + type OcfIssuer, + type OcfStakeholder, + type OcfStockClass, +} from '../../src'; + +function verifyCapTableBatchContract( + batch: CapTableBatch, + stakeholder: OcfStakeholder, + stockClass: OcfStockClass, + issuer: OcfIssuer +): void { + batch.create('stakeholder', stakeholder); + batch.create('stockClass', stockClass); + batch.edit('issuer', issuer); + batch.delete('stakeholder', stakeholder.id); + + // @ts-expect-error issuer is edit-only + batch.create('issuer', issuer); + + // @ts-expect-error issuer cannot be deleted from a cap table + batch.delete('issuer', issuer.id); + + // @ts-expect-error the entity kind determines the payload type + batch.create('stockClass', stakeholder); + + // @ts-expect-error the entity kind determines the edit payload type + batch.edit('stakeholder', stockClass); + + const widenedKind = 'stakeholder' as 'stakeholder' | 'stockClass'; + + // @ts-expect-error a union-valued kind does not prove which payload belongs to it + batch.create(widenedKind, stakeholder); + + // @ts-expect-error explicit union type arguments cannot bypass kind/payload correlation + batch.edit(widenedKind, stakeholder); + + // @ts-expect-error the converter uses the same kind/payload correlation as the batch API + convertToDaml(widenedKind, stakeholder); + + const createOperation: OcfCreateOperation = { + type: 'stakeholder', + data: stakeholder, + }; + void createOperation; + + const operations: CapTableBatchOperations = { + creates: [ + { type: 'stakeholder', data: stakeholder }, + { type: 'stockClass', data: stockClass }, + ], + edits: [{ type: 'issuer', data: issuer }], + deletes: [{ type: 'stakeholder', id: stakeholder.id }], + }; + void operations; + + // @ts-expect-error a stockClass operation cannot carry stakeholder data + const invalidCreateOperation: OcfCreateOperation = { + type: 'stockClass', + data: stakeholder, + }; + void invalidCreateOperation; +} + +void verifyCapTableBatchContract; diff --git a/tsconfig.declaration-tests.json b/tsconfig.declaration-tests.json new file mode 100644 index 00000000..4ec52e55 --- /dev/null +++ b/tsconfig.declaration-tests.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.tests.json", + "compilerOptions": { + "rootDir": ".", + "types": ["node"] + }, + "include": ["test/declarations/**/*.ts"], + "exclude": [] +} diff --git a/tsconfig.tests.json b/tsconfig.tests.json index 78d1820d..34135e7c 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -5,5 +5,6 @@ "types": ["jest", "node"] }, "extends": "./tsconfig.json", - "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"], + "exclude": ["test/declarations/**/*.ts"] } From 797f4b9c6a4c73ad91fdea5586d5b366132db13f Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 21:15:00 -0400 Subject: [PATCH 02/47] Discriminate canonical OCF entities --- src/OcpClient.ts | 139 +++++--- .../OpenCapTable/capTable/batchTypes.ts | 184 ++++++---- .../OpenCapTable/capTable/damlToOcf.ts | 246 +++++-------- src/functions/OpenCapTable/capTable/index.ts | 1 + .../OpenCapTable/capTable/ocfToDaml.ts | 35 +- .../convertibleAcceptanceDataToDaml.ts | 1 + .../convertibleCancellation/damlToOcf.ts | 1 + .../getConvertibleCancellationAsOcf.ts | 20 +- .../convertibleConversion/damlToOcf.ts | 1 + .../getConvertibleIssuanceAsOcf.ts | 38 +- .../convertibleRetraction/damlToOcf.ts | 1 + .../convertibleTransfer/damlToOcf.ts | 1 + .../OpenCapTable/document/getDocumentAsOcf.ts | 23 +- .../equityCompensationAcceptanceDataToDaml.ts | 1 + .../damlToOcf.ts | 5 +- .../getEquityCompensationExerciseAsOcf.ts | 11 +- .../getEquityCompensationIssuanceAsOcf.ts | 11 +- .../equityCompensationRelease/damlToOcf.ts | 1 + .../equityCompensationRepricing/damlToOcf.ts | 1 + .../equityCompensationRetraction/damlToOcf.ts | 1 + .../equityCompensationTransfer/damlToOcf.ts | 5 +- .../OpenCapTable/issuer/getIssuerAsOcf.ts | 6 +- ...etIssuerAuthorizedSharesAdjustmentAsOcf.ts | 10 +- .../stakeholder/getStakeholderAsOcf.ts | 101 +++--- .../stockAcceptanceDataToDaml.ts | 1 + .../stockCancellation/damlToOcf.ts | 5 +- .../stockClass/getStockClassAsOcf.ts | 144 +------- ...ockClassAuthorizedSharesAdjustmentAsOcf.ts | 18 +- ...mlToStockClassConversionRatioAdjustment.ts | 1 + .../stockClassSplit/damlToStockClassSplit.ts | 1 + .../damlToStockConsolidation.ts | 1 + .../OpenCapTable/stockConversion/damlToOcf.ts | 1 + .../stockIssuance/getStockIssuanceAsOcf.ts | 35 +- .../getStockLegendTemplateAsOcf.ts | 25 +- .../stockPlan/getStockPlanAsOcf.ts | 30 +- .../getStockPlanPoolAdjustmentAsOcf.ts | 10 +- .../stockPlanReturnToPool/damlToOcf.ts | 1 + .../stockReissuance/damlToStockReissuance.ts | 1 + .../OpenCapTable/stockRepurchase/damlToOcf.ts | 1 + .../OpenCapTable/stockRetraction/damlToOcf.ts | 1 + .../OpenCapTable/stockTransfer/damlToOcf.ts | 5 +- .../OpenCapTable/valuation/damlToOcf.ts | 1 + .../valuation/getValuationAsOcf.ts | 7 +- .../vestingAcceleration/damlToOcf.ts | 1 + .../getVestingAccelerationAsOcf.ts | 7 +- .../OpenCapTable/vestingEvent/damlToOcf.ts | 1 + .../vestingEvent/getVestingEventAsOcf.ts | 7 +- .../OpenCapTable/vestingStart/damlToOcf.ts | 1 + .../vestingStart/getVestingStartAsOcf.ts | 7 +- .../vestingTerms/getVestingTermsAsOcf.ts | 29 +- .../warrantAcceptanceDataToDaml.ts | 1 + .../warrantCancellation/damlToOcf.ts | 5 +- .../OpenCapTable/warrantExercise/damlToOcf.ts | 1 + .../getWarrantExerciseAsOcf.ts | 13 +- .../getWarrantIssuanceAsOcf.ts | 11 +- .../warrantRetraction/damlToOcf.ts | 1 + .../OpenCapTable/warrantTransfer/damlToOcf.ts | 5 +- src/types/native.ts | 239 +++++++------ src/types/output.ts | 19 +- src/utils/cantonOcfExtractor.ts | 21 +- src/utils/ocfHelpers.ts | 18 +- src/utils/ocfMetadata.ts | 14 +- src/utils/ocfZodSchemas.ts | 35 +- src/utils/planSecurityAliases.ts | 145 +++++--- src/utils/replicationHelpers.ts | 38 +- src/utils/transactionHelpers.ts | 18 +- src/utils/typeGuards.ts | 232 ++---------- test/batch/CapTableBatch.test.ts | 34 +- test/batch/damlToOcfDispatcher.test.ts | 64 +++- test/batch/remainingOcfTypes.test.ts | 16 + test/client/OcpClient.test.ts | 1 + test/converters/acceptanceConverters.test.ts | 27 ++ .../convertibleCancellationConverters.test.ts | 65 ++++ .../coreObjectReadValidation.test.ts | 60 ++++ .../exerciseConversionConverters.test.ts | 6 + test/converters/issuerConverters.test.ts | 9 + .../converters/planSecurityConverters.test.ts | 64 ++-- .../stockClassAdjustmentConverters.test.ts | 8 + test/converters/stockClassConverters.test.ts | 1 + test/converters/stockPlanConverters.test.ts | 15 + test/converters/transferConverters.test.ts | 20 ++ .../valuationVestingConverters.test.ts | 20 ++ .../warrantIssuanceConverters.test.ts | 25 +- .../stockIssuanceReadConversions.test.ts | 8 + test/declarations/damlReadDispatch.types.ts | 31 ++ test/declarations/normalization.types.ts | 36 ++ test/declarations/publicApi.types.ts | 71 +++- .../acceptanceTypes.integration.test.ts | 17 +- .../capTableBatch.integration.test.ts | 1 + ...xerciseConversionTypes.integration.test.ts | 3 + .../stockClassAdjustments.integration.test.ts | 7 + test/integration/utils/setupTestData.ts | 99 ++++-- .../batchOperations.integration.test.ts | 3 + test/types/capTableBatch.types.ts | 70 +++- test/types/damlReadDispatch.types.ts | 31 ++ test/types/normalization.types.ts | 44 +++ test/utils/ocfZodSchemas.test.ts | 125 ++++++- test/utils/planSecurityAliases.test.ts | 18 +- test/utils/replicationHelpers.test.ts | 33 +- test/utils/typeGuards.test.ts | 332 +++++++++++++++++- test/validation/boundaries.test.ts | 16 + test/validation/requiredFields.test.ts | 4 + 102 files changed, 2081 insertions(+), 1305 deletions(-) create mode 100644 test/converters/convertibleCancellationConverters.test.ts create mode 100644 test/converters/coreObjectReadValidation.test.ts create mode 100644 test/declarations/damlReadDispatch.types.ts create mode 100644 test/declarations/normalization.types.ts create mode 100644 test/types/damlReadDispatch.types.ts create mode 100644 test/types/normalization.types.ts diff --git a/src/OcpClient.ts b/src/OcpClient.ts index 0ffe05da..904e7112 100644 --- a/src/OcpClient.ts +++ b/src/OcpClient.ts @@ -124,13 +124,15 @@ import { archiveCapTable, CapTableBatch, classifyIssuerCapTables, - ENTITY_OBJECT_TYPE_MAP, getCapTableState, mapOcfObjectTypeToEntityType, type ArchiveCapTableParams, type ArchiveCapTableResult, type CapTableState, type IssuerCapTableClassification, + type OcfDataTypeFor, + type OcfEntityType, + type OcfReadableDataForObjectType, type OcfReadableObjectType, } from './functions/OpenCapTable/capTable'; import { mergeCommandContext, type CommandObservabilityOptions, type OcpObservabilityOptions } from './observability'; @@ -154,7 +156,6 @@ import type { OcfEquityCompensationTransferOutput, OcfIssuerAuthorizedSharesAdjustmentOutput, OcfIssuerOutput, - OcfOutputForObjectType, OcfStakeholderOutput, OcfStakeholderRelationshipChangeEventOutput, OcfStakeholderStatusChangeEventOutput, @@ -188,23 +189,20 @@ import type { OcfWarrantTransferOutput, } from './types/output'; +type WithClientObservability = Omit & + OcpObservabilityOptions; + // ===== Helper to adapt underlying function results to ContractResult ===== /** * Adapt an underlying `get*AsOcf` function result to the standardized `ContractResult` format. * - * The underlying functions define their own local output types that may differ - * structurally from the centralized output types in `types/output.ts` (e.g., optional - * vs required fields, locally-defined interfaces vs `WithObjectType` aliases). The - * centralized types are stricter and canonical. This adapter performs a single, contained - * type assertion to bridge the gap, keeping the cast out of individual call sites. - * - * @typeParam T - The target output type from `types/output.ts` - * @param data - Entity data from the underlying function (runtime-correct, locally-typed) + * @typeParam T - The canonical entity type returned by the underlying reader + * @param data - Canonically typed entity data from the underlying function * @param contractId - The contract ID returned by the underlying function * @internal */ -function toContractResult(data: unknown, contractId: string): ContractResult { +function toContractResult(data: T, contractId: string): ContractResult { if (data == null) { throw new OcpValidationError( 'toContractResult.data', @@ -212,46 +210,37 @@ function toContractResult(data: unknown, contractId: string): ContractResult< { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: data } ); } - return { data: data as T, contractId }; + return { data, contractId }; } -function withObjectType(data: unknown, objectType: string): unknown { - if (data == null || typeof data !== 'object' || Array.isArray(data)) { - return data; - } - - const record = data as Record; - return typeof record.object_type === 'string' ? record : { ...record, object_type: objectType }; -} - -function makeGenericEntityReader( +function makeGenericEntityReader( client: LedgerJsonApiClient, - entityType: Parameters[1] -): EntityReader { + entityType: T +): EntityReader> { return { get: async ({ contractId, ...options }) => { const r = await getEntityAsOcf(client, entityType, contractId, options); - return toContractResult(withObjectType(r.data, ENTITY_OBJECT_TYPE_MAP[entityType]), r.contractId); + return toContractResult(r.data, r.contractId); }, }; } -type OpenCapTableObjectReaderKey = ReturnType & keyof OpenCapTableMethods; -type OpenCapTableObjectReaderMap = Pick; +type OpenCapTableObjectReaderMap = { + [ObjectType in OcfReadableObjectType]: EntityReader>; +}; -function getOpenCapTableObjectReader( +function selectObjectTypeReader( readers: OpenCapTableObjectReaderMap, objectType: T -): EntityReader> { - const entityType = mapOcfObjectTypeToEntityType(String(objectType)); - if (entityType === null) { +): OpenCapTableObjectReaderMap[T] { + if (mapOcfObjectTypeToEntityType(String(objectType)) === null) { throw new OcpValidationError('objectType', `Unsupported OCF object_type: ${objectType}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, receivedValue: objectType, }); } - return readers[entityType as OpenCapTableObjectReaderKey] as EntityReader>; + return readers[objectType]; } // ===== Context Manager ===== @@ -482,7 +471,7 @@ export class OcpClient { this.OpenCapTable = this.createOpenCapTableMethods(); } - private withObservability(params: T): T { + private withObservability(params: T): WithClientObservability { const { logger, metrics, defaultContext: paramsDefaultContext, ...commandParams } = params; const defaultContext = mergeCommandContext(this.observability.defaultContext, paramsDefaultContext); return { @@ -491,7 +480,7 @@ export class OcpClient { ...(logger !== undefined ? { logger } : {}), ...(metrics !== undefined ? { metrics } : {}), ...(defaultContext ? { defaultContext } : {}), - } as T; + }; } /** @@ -579,7 +568,13 @@ export class OcpClient { * const built = ocp.OpenCapTable.issuer.buildCreate({ * issuerAuthorizationContractDetails, * issuerParty: issuerPartyId, - * issuerData: { id: 'i1', legal_name: 'Co', country_of_formation: 'US', formation_date: '2020-01-01' }, + * issuerData: { + * object_type: 'ISSUER', + * id: 'i1', + * legal_name: 'Co', + * country_of_formation: 'US', + * formation_date: '2020-01-01', + * }, * }); * await batch.addBuiltCommand(built).submitAndWaitForTransactionTree(); * ``` @@ -590,8 +585,8 @@ export class OcpClient { private createOpenCapTableMethods(): OpenCapTableMethods { const client = this.ledger; - const genericEntity = (entityType: Parameters[1]): EntityReader => - makeGenericEntityReader(client, entityType); + const genericEntity = (entityType: T): EntityReader> => + makeGenericEntityReader(client, entityType); const methods = { // ===== Objects ===== @@ -721,11 +716,10 @@ export class OcpClient { }, // ===== Retractions ===== - stockRetraction: genericEntity('stockRetraction'), - warrantRetraction: genericEntity('warrantRetraction'), - convertibleRetraction: genericEntity('convertibleRetraction'), - equityCompensationRetraction: - genericEntity('equityCompensationRetraction'), + stockRetraction: genericEntity('stockRetraction'), + warrantRetraction: genericEntity('warrantRetraction'), + convertibleRetraction: genericEntity('convertibleRetraction'), + equityCompensationRetraction: genericEntity('equityCompensationRetraction'), // ===== Exercises ===== equityCompensationExercise: { @@ -812,7 +806,7 @@ export class OcpClient { return toContractResult(r.event, r.contractId); }, }, - stockPlanReturnToPool: genericEntity('stockPlanReturnToPool'), + stockPlanReturnToPool: genericEntity('stockPlanReturnToPool'), // ===== Other Transactions ===== stockRepurchase: { @@ -833,8 +827,8 @@ export class OcpClient { return toContractResult(r.event, r.contractId); }, }, - equityCompensationRelease: genericEntity('equityCompensationRelease'), - equityCompensationRepricing: genericEntity('equityCompensationRepricing'), + equityCompensationRelease: genericEntity('equityCompensationRelease'), + equityCompensationRepricing: genericEntity('equityCompensationRepricing'), // ===== Vesting ===== vestingStart: { @@ -914,9 +908,60 @@ export class OcpClient { }, } satisfies Omit; + const objectReaders = { + CE_STAKEHOLDER_RELATIONSHIP: methods.stakeholderRelationshipChangeEvent, + CE_STAKEHOLDER_STATUS: methods.stakeholderStatusChangeEvent, + DOCUMENT: methods.document, + ISSUER: methods.issuer, + STAKEHOLDER: methods.stakeholder, + STOCK_CLASS: methods.stockClass, + STOCK_LEGEND_TEMPLATE: methods.stockLegendTemplate, + STOCK_PLAN: methods.stockPlan, + TX_CONVERTIBLE_ACCEPTANCE: methods.convertibleAcceptance, + TX_CONVERTIBLE_CANCELLATION: methods.convertibleCancellation, + TX_CONVERTIBLE_CONVERSION: methods.convertibleConversion, + TX_CONVERTIBLE_ISSUANCE: methods.convertibleIssuance, + TX_CONVERTIBLE_RETRACTION: methods.convertibleRetraction, + TX_CONVERTIBLE_TRANSFER: methods.convertibleTransfer, + TX_EQUITY_COMPENSATION_ACCEPTANCE: methods.equityCompensationAcceptance, + TX_EQUITY_COMPENSATION_CANCELLATION: methods.equityCompensationCancellation, + TX_EQUITY_COMPENSATION_EXERCISE: methods.equityCompensationExercise, + TX_EQUITY_COMPENSATION_ISSUANCE: methods.equityCompensationIssuance, + TX_EQUITY_COMPENSATION_RELEASE: methods.equityCompensationRelease, + TX_EQUITY_COMPENSATION_REPRICING: methods.equityCompensationRepricing, + TX_EQUITY_COMPENSATION_RETRACTION: methods.equityCompensationRetraction, + TX_EQUITY_COMPENSATION_TRANSFER: methods.equityCompensationTransfer, + TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT: methods.issuerAuthorizedSharesAdjustment, + TX_STOCK_ACCEPTANCE: methods.stockAcceptance, + TX_STOCK_CANCELLATION: methods.stockCancellation, + TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT: methods.stockClassAuthorizedSharesAdjustment, + TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT: methods.stockClassConversionRatioAdjustment, + TX_STOCK_CLASS_SPLIT: methods.stockClassSplit, + TX_STOCK_CONSOLIDATION: methods.stockConsolidation, + TX_STOCK_CONVERSION: methods.stockConversion, + TX_STOCK_ISSUANCE: methods.stockIssuance, + TX_STOCK_PLAN_POOL_ADJUSTMENT: methods.stockPlanPoolAdjustment, + TX_STOCK_PLAN_RETURN_TO_POOL: methods.stockPlanReturnToPool, + TX_STOCK_REISSUANCE: methods.stockReissuance, + TX_STOCK_REPURCHASE: methods.stockRepurchase, + TX_STOCK_RETRACTION: methods.stockRetraction, + TX_STOCK_TRANSFER: methods.stockTransfer, + TX_VESTING_ACCELERATION: methods.vestingAcceleration, + TX_VESTING_EVENT: methods.vestingEvent, + TX_VESTING_START: methods.vestingStart, + TX_WARRANT_ACCEPTANCE: methods.warrantAcceptance, + TX_WARRANT_CANCELLATION: methods.warrantCancellation, + TX_WARRANT_EXERCISE: methods.warrantExercise, + TX_WARRANT_ISSUANCE: methods.warrantIssuance, + TX_WARRANT_RETRACTION: methods.warrantRetraction, + TX_WARRANT_TRANSFER: methods.warrantTransfer, + VALUATION: methods.valuation, + VESTING_TERMS: methods.vestingTerms, + } satisfies OpenCapTableObjectReaderMap; + return { getByObjectType: async ({ objectType, ...params }) => { - const reader = getOpenCapTableObjectReader(methods, objectType); + const reader = selectObjectTypeReader(objectReaders, objectType); return reader.get(params); }, ...methods, @@ -1056,7 +1101,7 @@ interface OpenCapTableMethods { */ getByObjectType: ( params: GetByObjectTypeParams - ) => Promise>>; + ) => Promise>>; // Objects issuer: EntityReader & { diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index 5e186085..909c2a10 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -5,7 +5,7 @@ * and deletes of OCF entities. */ -import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { OcfConvertibleAcceptance, OcfConvertibleCancellation, @@ -24,13 +24,7 @@ import type { OcfEquityCompensationTransfer, OcfIssuer, OcfIssuerAuthorizedSharesAdjustment, - OcfPlanSecurityAcceptance, - OcfPlanSecurityCancellation, - OcfPlanSecurityExercise, - OcfPlanSecurityIssuance, - OcfPlanSecurityRelease, - OcfPlanSecurityRetraction, - OcfPlanSecurityTransfer, + OcfObjectType, OcfStakeholder, OcfStakeholderRelationshipChangeEvent, OcfStakeholderStatusChangeEvent, @@ -83,9 +77,6 @@ export type CapTableBatchExecuteResult = UpdateCapTableResult & { /** * Type mapping from entity type string to native OCF data type. - * - * Note: PlanSecurity types map to their equivalent OCF PlanSecurity interfaces, - * which are compatible with the EquityCompensation types they alias. */ // A closed alias prevents module augmentation from widening OcfEntityType beyond ENTITY_REGISTRY. // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -108,14 +99,6 @@ export type OcfEntityDataMap = { /** Issuer is edit-only (no create/delete) - created with CapTable via IssuerAuthorization */ issuer: OcfIssuer; issuerAuthorizedSharesAdjustment: OcfIssuerAuthorizedSharesAdjustment; - // PlanSecurity types - these are aliases for EquityCompensation types - planSecurityAcceptance: OcfPlanSecurityAcceptance; - planSecurityCancellation: OcfPlanSecurityCancellation; - planSecurityExercise: OcfPlanSecurityExercise; - planSecurityIssuance: OcfPlanSecurityIssuance; - planSecurityRelease: OcfPlanSecurityRelease; - planSecurityRetraction: OcfPlanSecurityRetraction; - planSecurityTransfer: OcfPlanSecurityTransfer; stakeholder: OcfStakeholder; stakeholderRelationshipChangeEvent: OcfStakeholderRelationshipChangeEvent; stakeholderStatusChangeEvent: OcfStakeholderStatusChangeEvent; @@ -170,9 +153,7 @@ type CreatableBatchName = BatchNameFor; type EditableBatchName = BatchNameFor; type DeletableBatchName = BatchNameFor; type FullyMutableBatchName = CreatableBatchName & EditableBatchName & DeletableBatchName; -type BatchNameForEntity = EntityType extends `planSecurity${infer Suffix}` - ? `EquityCompensation${Suffix}` - : Capitalize; +type BatchNameForEntity = Capitalize; /** Exact generated DAML tags supported by an entity in UpdateCapTable. */ export type OcfEntityOperationTags = Readonly<{ @@ -198,7 +179,9 @@ function editOnlyEntityOperations(name: Na /** Shared registry entry for all OCF entity metadata used by batch, read, schema, and state code. */ export interface OcfEntityRegistryEntry { /** Canonical OCF object_type accepted by schema validation. */ - objectType: string; + objectType: OcfObjectType; + /** Generated DAML template identity required when reading this entity from the ledger. */ + templateId: string; /** Field containing entity data in DAML contract create arguments. */ dataField: string; /** Alternate create-argument fields accepted for older template payloads. */ @@ -222,7 +205,8 @@ type EditOnlyOperationsFor = Readonly<{ }>; type OcfEntityRegistry = { - [EntityType in OcfEntityType]: Omit & { + [EntityType in OcfEntityType]: Omit & { + objectType: OcfEntityDataMap[EntityType]['object_type']; operations: EntityType extends 'issuer' ? EditOnlyOperationsFor : MutableOperationsFor; }; }; @@ -230,30 +214,34 @@ type OcfEntityRegistry = { /** * Single source of truth for entity-level metadata. * - * PlanSecurity entries intentionally use EquityCompensation object types and batch tags because the SDK normalizes - * those aliases before writing to Canton. + * Legacy PlanSecurity objects normalize to the canonical EquityCompensation family + * before reaching typed batch operations. */ export const ENTITY_REGISTRY = { convertibleAcceptance: { objectType: 'TX_CONVERTIBLE_ACCEPTANCE', + templateId: Fairmint.OpenCapTable.OCF.ConvertibleAcceptance.ConvertibleAcceptance.templateId, dataField: 'acceptance_data', capTableField: 'convertible_acceptances', operations: mutableEntityOperations('ConvertibleAcceptance'), }, convertibleCancellation: { objectType: 'TX_CONVERTIBLE_CANCELLATION', + templateId: Fairmint.OpenCapTable.OCF.ConvertibleCancellation.ConvertibleCancellation.templateId, dataField: 'cancellation_data', capTableField: 'convertible_cancellations', operations: mutableEntityOperations('ConvertibleCancellation'), }, convertibleConversion: { objectType: 'TX_CONVERTIBLE_CONVERSION', + templateId: Fairmint.OpenCapTable.OCF.ConvertibleConversion.ConvertibleConversion.templateId, dataField: 'conversion_data', capTableField: 'convertible_conversions', operations: mutableEntityOperations('ConvertibleConversion'), }, convertibleIssuance: { objectType: 'TX_CONVERTIBLE_ISSUANCE', + templateId: Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuance.templateId, dataField: 'issuance_data', capTableField: 'convertible_issuances', securityIdField: 'convertible_issuances_by_security_id', @@ -261,42 +249,49 @@ export const ENTITY_REGISTRY = { }, convertibleRetraction: { objectType: 'TX_CONVERTIBLE_RETRACTION', + templateId: Fairmint.OpenCapTable.OCF.ConvertibleRetraction.ConvertibleRetraction.templateId, dataField: 'retraction_data', capTableField: 'convertible_retractions', operations: mutableEntityOperations('ConvertibleRetraction'), }, convertibleTransfer: { objectType: 'TX_CONVERTIBLE_TRANSFER', + templateId: Fairmint.OpenCapTable.OCF.ConvertibleTransfer.ConvertibleTransfer.templateId, dataField: 'transfer_data', capTableField: 'convertible_transfers', operations: mutableEntityOperations('ConvertibleTransfer'), }, document: { objectType: 'DOCUMENT', + templateId: Fairmint.OpenCapTable.OCF.Document.Document.templateId, dataField: 'document_data', capTableField: 'documents', operations: mutableEntityOperations('Document'), }, equityCompensationAcceptance: { objectType: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', + templateId: Fairmint.OpenCapTable.OCF.EquityCompensationAcceptance.EquityCompensationAcceptance.templateId, dataField: 'acceptance_data', capTableField: 'equity_compensation_acceptances', operations: mutableEntityOperations('EquityCompensationAcceptance'), }, equityCompensationCancellation: { objectType: 'TX_EQUITY_COMPENSATION_CANCELLATION', + templateId: Fairmint.OpenCapTable.OCF.EquityCompensationCancellation.EquityCompensationCancellation.templateId, dataField: 'cancellation_data', capTableField: 'equity_compensation_cancellations', operations: mutableEntityOperations('EquityCompensationCancellation'), }, equityCompensationExercise: { objectType: 'TX_EQUITY_COMPENSATION_EXERCISE', + templateId: Fairmint.OpenCapTable.OCF.EquityCompensationExercise.EquityCompensationExercise.templateId, dataField: 'exercise_data', capTableField: 'equity_compensation_exercises', operations: mutableEntityOperations('EquityCompensationExercise'), }, equityCompensationIssuance: { objectType: 'TX_EQUITY_COMPENSATION_ISSUANCE', + templateId: Fairmint.OpenCapTable.OCF.EquityCompensationIssuance.EquityCompensationIssuance.templateId, dataField: 'issuance_data', capTableField: 'equity_compensation_issuances', securityIdField: 'equity_compensation_issuances_by_security_id', @@ -304,82 +299,56 @@ export const ENTITY_REGISTRY = { }, equityCompensationRelease: { objectType: 'TX_EQUITY_COMPENSATION_RELEASE', + templateId: Fairmint.OpenCapTable.OCF.EquityCompensationRelease.EquityCompensationRelease.templateId, dataField: 'release_data', capTableField: 'equity_compensation_releases', operations: mutableEntityOperations('EquityCompensationRelease'), }, equityCompensationRepricing: { objectType: 'TX_EQUITY_COMPENSATION_REPRICING', + templateId: Fairmint.OpenCapTable.OCF.EquityCompensationRepricing.EquityCompensationRepricing.templateId, dataField: 'repricing_data', capTableField: 'equity_compensation_repricings', operations: mutableEntityOperations('EquityCompensationRepricing'), }, equityCompensationRetraction: { objectType: 'TX_EQUITY_COMPENSATION_RETRACTION', + templateId: Fairmint.OpenCapTable.OCF.EquityCompensationRetraction.EquityCompensationRetraction.templateId, dataField: 'retraction_data', capTableField: 'equity_compensation_retractions', operations: mutableEntityOperations('EquityCompensationRetraction'), }, equityCompensationTransfer: { objectType: 'TX_EQUITY_COMPENSATION_TRANSFER', + templateId: Fairmint.OpenCapTable.OCF.EquityCompensationTransfer.EquityCompensationTransfer.templateId, dataField: 'transfer_data', capTableField: 'equity_compensation_transfers', operations: mutableEntityOperations('EquityCompensationTransfer'), }, issuer: { objectType: 'ISSUER', + templateId: Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, dataField: 'issuer_data', operations: editOnlyEntityOperations('Issuer'), }, issuerAuthorizedSharesAdjustment: { objectType: 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT', + templateId: Fairmint.OpenCapTable.OCF.IssuerAuthorizedSharesAdjustment.IssuerAuthorizedSharesAdjustment.templateId, dataField: 'adjustment_data', capTableField: 'issuer_authorized_shares_adjustments', operations: mutableEntityOperations('IssuerAuthorizedSharesAdjustment'), }, - planSecurityAcceptance: { - objectType: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', - dataField: 'acceptance_data', - operations: mutableEntityOperations('EquityCompensationAcceptance'), - }, - planSecurityCancellation: { - objectType: 'TX_EQUITY_COMPENSATION_CANCELLATION', - dataField: 'cancellation_data', - operations: mutableEntityOperations('EquityCompensationCancellation'), - }, - planSecurityExercise: { - objectType: 'TX_EQUITY_COMPENSATION_EXERCISE', - dataField: 'exercise_data', - operations: mutableEntityOperations('EquityCompensationExercise'), - }, - planSecurityIssuance: { - objectType: 'TX_EQUITY_COMPENSATION_ISSUANCE', - dataField: 'issuance_data', - operations: mutableEntityOperations('EquityCompensationIssuance'), - }, - planSecurityRelease: { - objectType: 'TX_EQUITY_COMPENSATION_RELEASE', - dataField: 'release_data', - operations: mutableEntityOperations('EquityCompensationRelease'), - }, - planSecurityRetraction: { - objectType: 'TX_EQUITY_COMPENSATION_RETRACTION', - dataField: 'retraction_data', - operations: mutableEntityOperations('EquityCompensationRetraction'), - }, - planSecurityTransfer: { - objectType: 'TX_EQUITY_COMPENSATION_TRANSFER', - dataField: 'transfer_data', - operations: mutableEntityOperations('EquityCompensationTransfer'), - }, stakeholder: { objectType: 'STAKEHOLDER', + templateId: Fairmint.OpenCapTable.OCF.Stakeholder.Stakeholder.templateId, dataField: 'stakeholder_data', capTableField: 'stakeholders', operations: mutableEntityOperations('Stakeholder'), }, stakeholderRelationshipChangeEvent: { objectType: 'CE_STAKEHOLDER_RELATIONSHIP', + templateId: + Fairmint.OpenCapTable.OCF.StakeholderRelationshipChangeEvent.StakeholderRelationshipChangeEvent.templateId, dataField: 'relationship_change_data', dataFieldFallbacks: ['event_data'], capTableField: 'stakeholder_relationship_change_events', @@ -387,6 +356,7 @@ export const ENTITY_REGISTRY = { }, stakeholderStatusChangeEvent: { objectType: 'CE_STAKEHOLDER_STATUS', + templateId: Fairmint.OpenCapTable.OCF.StakeholderStatusChangeEvent.StakeholderStatusChangeEvent.templateId, dataField: 'status_change_data', dataFieldFallbacks: ['event_data'], capTableField: 'stakeholder_status_change_events', @@ -394,54 +364,65 @@ export const ENTITY_REGISTRY = { }, stockAcceptance: { objectType: 'TX_STOCK_ACCEPTANCE', + templateId: Fairmint.OpenCapTable.OCF.StockAcceptance.StockAcceptance.templateId, dataField: 'acceptance_data', capTableField: 'stock_acceptances', operations: mutableEntityOperations('StockAcceptance'), }, stockCancellation: { objectType: 'TX_STOCK_CANCELLATION', + templateId: Fairmint.OpenCapTable.OCF.StockCancellation.StockCancellation.templateId, dataField: 'cancellation_data', capTableField: 'stock_cancellations', operations: mutableEntityOperations('StockCancellation'), }, stockClass: { objectType: 'STOCK_CLASS', + templateId: Fairmint.OpenCapTable.OCF.StockClass.StockClass.templateId, dataField: 'stock_class_data', capTableField: 'stock_classes', operations: mutableEntityOperations('StockClass'), }, stockClassAuthorizedSharesAdjustment: { objectType: 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT', + templateId: + Fairmint.OpenCapTable.OCF.StockClassAuthorizedSharesAdjustment.StockClassAuthorizedSharesAdjustment.templateId, dataField: 'adjustment_data', capTableField: 'stock_class_authorized_shares_adjustments', operations: mutableEntityOperations('StockClassAuthorizedSharesAdjustment'), }, stockClassConversionRatioAdjustment: { objectType: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + templateId: + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment.templateId, dataField: 'adjustment_data', capTableField: 'stock_class_conversion_ratio_adjustments', operations: mutableEntityOperations('StockClassConversionRatioAdjustment'), }, stockClassSplit: { objectType: 'TX_STOCK_CLASS_SPLIT', + templateId: Fairmint.OpenCapTable.OCF.StockClassSplit.StockClassSplit.templateId, dataField: 'split_data', capTableField: 'stock_class_splits', operations: mutableEntityOperations('StockClassSplit'), }, stockConsolidation: { objectType: 'TX_STOCK_CONSOLIDATION', + templateId: Fairmint.OpenCapTable.OCF.StockConsolidation.StockConsolidation.templateId, dataField: 'consolidation_data', capTableField: 'stock_consolidations', operations: mutableEntityOperations('StockConsolidation'), }, stockConversion: { objectType: 'TX_STOCK_CONVERSION', + templateId: Fairmint.OpenCapTable.OCF.StockConversion.StockConversion.templateId, dataField: 'conversion_data', capTableField: 'stock_conversions', operations: mutableEntityOperations('StockConversion'), }, stockIssuance: { objectType: 'TX_STOCK_ISSUANCE', + templateId: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuance.templateId, dataField: 'issuance_data', capTableField: 'stock_issuances', securityIdField: 'stock_issuances_by_security_id', @@ -449,60 +430,70 @@ export const ENTITY_REGISTRY = { }, stockLegendTemplate: { objectType: 'STOCK_LEGEND_TEMPLATE', + templateId: Fairmint.OpenCapTable.OCF.StockLegendTemplate.StockLegendTemplate.templateId, dataField: 'template_data', capTableField: 'stock_legend_templates', operations: mutableEntityOperations('StockLegendTemplate'), }, stockPlan: { objectType: 'STOCK_PLAN', + templateId: Fairmint.OpenCapTable.OCF.StockPlan.StockPlan.templateId, dataField: 'stock_plan_data', capTableField: 'stock_plans', operations: mutableEntityOperations('StockPlan'), }, stockPlanPoolAdjustment: { objectType: 'TX_STOCK_PLAN_POOL_ADJUSTMENT', + templateId: Fairmint.OpenCapTable.OCF.StockPlanPoolAdjustment.StockPlanPoolAdjustment.templateId, dataField: 'adjustment_data', capTableField: 'stock_plan_pool_adjustments', operations: mutableEntityOperations('StockPlanPoolAdjustment'), }, stockPlanReturnToPool: { objectType: 'TX_STOCK_PLAN_RETURN_TO_POOL', + templateId: Fairmint.OpenCapTable.OCF.StockPlanReturnToPool.StockPlanReturnToPool.templateId, dataField: 'return_data', capTableField: 'stock_plan_return_to_pools', operations: mutableEntityOperations('StockPlanReturnToPool'), }, stockReissuance: { objectType: 'TX_STOCK_REISSUANCE', + templateId: Fairmint.OpenCapTable.OCF.StockReissuance.StockReissuance.templateId, dataField: 'reissuance_data', capTableField: 'stock_reissuances', operations: mutableEntityOperations('StockReissuance'), }, stockRepurchase: { objectType: 'TX_STOCK_REPURCHASE', + templateId: Fairmint.OpenCapTable.OCF.StockRepurchase.StockRepurchase.templateId, dataField: 'repurchase_data', capTableField: 'stock_repurchases', operations: mutableEntityOperations('StockRepurchase'), }, stockRetraction: { objectType: 'TX_STOCK_RETRACTION', + templateId: Fairmint.OpenCapTable.OCF.StockRetraction.StockRetraction.templateId, dataField: 'retraction_data', capTableField: 'stock_retractions', operations: mutableEntityOperations('StockRetraction'), }, stockTransfer: { objectType: 'TX_STOCK_TRANSFER', + templateId: Fairmint.OpenCapTable.OCF.StockTransfer.StockTransfer.templateId, dataField: 'transfer_data', capTableField: 'stock_transfers', operations: mutableEntityOperations('StockTransfer'), }, valuation: { objectType: 'VALUATION', + templateId: Fairmint.OpenCapTable.OCF.Valuation.Valuation.templateId, dataField: 'valuation_data', capTableField: 'valuations', operations: mutableEntityOperations('Valuation'), }, vestingAcceleration: { objectType: 'TX_VESTING_ACCELERATION', + templateId: Fairmint.OpenCapTable.OCF.VestingAcceleration.VestingAcceleration.templateId, dataField: 'acceleration_data', dataFieldFallbacks: ['vesting_acceleration_data'], capTableField: 'vesting_accelerations', @@ -510,6 +501,7 @@ export const ENTITY_REGISTRY = { }, vestingEvent: { objectType: 'TX_VESTING_EVENT', + templateId: Fairmint.OpenCapTable.OCF.VestingEvent.VestingEvent.templateId, dataField: 'vesting_data', dataFieldFallbacks: ['vesting_event_data'], capTableField: 'vesting_events', @@ -517,6 +509,7 @@ export const ENTITY_REGISTRY = { }, vestingStart: { objectType: 'TX_VESTING_START', + templateId: Fairmint.OpenCapTable.OCF.VestingStart.VestingStart.templateId, dataField: 'vesting_data', dataFieldFallbacks: ['vesting_start_data'], capTableField: 'vesting_starts', @@ -524,30 +517,35 @@ export const ENTITY_REGISTRY = { }, vestingTerms: { objectType: 'VESTING_TERMS', + templateId: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTerms.templateId, dataField: 'vesting_terms_data', capTableField: 'vesting_terms', operations: mutableEntityOperations('VestingTerms'), }, warrantAcceptance: { objectType: 'TX_WARRANT_ACCEPTANCE', + templateId: Fairmint.OpenCapTable.OCF.WarrantAcceptance.WarrantAcceptance.templateId, dataField: 'acceptance_data', capTableField: 'warrant_acceptances', operations: mutableEntityOperations('WarrantAcceptance'), }, warrantCancellation: { objectType: 'TX_WARRANT_CANCELLATION', + templateId: Fairmint.OpenCapTable.OCF.WarrantCancellation.WarrantCancellation.templateId, dataField: 'cancellation_data', capTableField: 'warrant_cancellations', operations: mutableEntityOperations('WarrantCancellation'), }, warrantExercise: { objectType: 'TX_WARRANT_EXERCISE', + templateId: Fairmint.OpenCapTable.OCF.WarrantExercise.WarrantExercise.templateId, dataField: 'exercise_data', capTableField: 'warrant_exercises', operations: mutableEntityOperations('WarrantExercise'), }, warrantIssuance: { objectType: 'TX_WARRANT_ISSUANCE', + templateId: Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuance.templateId, dataField: 'issuance_data', capTableField: 'warrant_issuances', securityIdField: 'warrant_issuances_by_security_id', @@ -555,12 +553,14 @@ export const ENTITY_REGISTRY = { }, warrantRetraction: { objectType: 'TX_WARRANT_RETRACTION', + templateId: Fairmint.OpenCapTable.OCF.WarrantRetraction.WarrantRetraction.templateId, dataField: 'retraction_data', capTableField: 'warrant_retractions', operations: mutableEntityOperations('WarrantRetraction'), }, warrantTransfer: { objectType: 'TX_WARRANT_TRANSFER', + templateId: Fairmint.OpenCapTable.OCF.WarrantTransfer.WarrantTransfer.templateId, dataField: 'transfer_data', capTableField: 'warrant_transfers', operations: mutableEntityOperations('WarrantTransfer'), @@ -582,6 +582,40 @@ export type OcfEditableEntityType = EntityTypeWithCapability<'edit'>; /** Entity kinds that can be deleted through UpdateCapTable. */ export type OcfDeletableEntityType = EntityTypeWithCapability<'delete'>; +type OcfOperationTagFor = + (typeof ENTITY_REGISTRY)[EntityType]['operations'] extends Readonly> + ? Tag + : never; + +/** Exact generated DAML create variant for one SDK entity kind. */ +export type OcfCreateDataFor = Extract< + OcfCreateData, + { tag: OcfOperationTagFor } +>; + +/** Exact generated DAML edit variant for one SDK entity kind. */ +export type OcfEditDataFor = Extract< + OcfEditData, + { tag: OcfOperationTagFor } +>; + +/** Exact generated DAML delete variant for one SDK entity kind. */ +export type OcfDeleteDataFor = Extract< + OcfDeleteData, + { tag: OcfOperationTagFor } +>; + +/** Exact generated DAML entity-data payload decoded for one SDK entity kind. */ +export type DamlDataTypeFor = Extract< + OcfEditData, + { tag: OcfOperationTagFor } +>['value']; + +/** Correlated entity-kind and generated DAML-data tuples accepted by the read dispatcher. */ +export type DamlEntityArguments = { + [EntityType in OcfEntityType]: readonly [type: EntityType, damlData: DamlDataTypeFor]; +}[OcfEntityType]; + /** Correlated entity-kind and native-data tuples accepted by the converter dispatcher. */ export type OcfEntityArguments = { [EntityType in OcfEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; @@ -660,9 +694,8 @@ export function isOcfDeletableEntityType(entityType: string): entityType is OcfD /** * Canonical OCF `object_type` to SDK entity reader mapping. * - * PlanSecurity aliases intentionally resolve to the EquityCompensation object types in - * {@link ENTITY_REGISTRY}, so this map exposes the canonical ledger object types that - * have first-class read facades. + * Legacy PlanSecurity aliases normalize to EquityCompensation before reaching this map, + * so it exposes only canonical ledger object types with first-class read facades. */ export const OCF_OBJECT_TYPE_TO_ENTITY_TYPE = { CE_STAKEHOLDER_RELATIONSHIP: 'stakeholderRelationshipChangeEvent', @@ -721,6 +754,11 @@ export type OcfReadableObjectType = keyof typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE; /** SDK entity reader name for a given OCF object type. */ export type OcfEntityTypeForObjectType = (typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE)[T]; +/** Canonical entity data returned for a readable OCF object type. */ +export type OcfReadableDataForObjectType = OcfDataTypeFor< + OcfEntityTypeForObjectType +>; + export function mapOcfObjectTypeToEntityType( objectType: T ): OcfEntityTypeForObjectType; @@ -756,8 +794,15 @@ function partialMapFromRegistry( ); } -/** Mapping from entity type to canonical OCF object_type. */ -export const ENTITY_OBJECT_TYPE_MAP = mapRegistryValues((entry) => entry.objectType); +/** Mapping from entity type to its exact canonical OCF object_type. */ +export const ENTITY_OBJECT_TYPE_MAP = mapRegistryValues((entry) => entry.objectType) as { + readonly [EntityType in OcfEntityType]: OcfEntityDataMap[EntityType]['object_type']; +}; + +/** Mapping from entity type to the generated DAML template required at the ledger read boundary. */ +export const ENTITY_TEMPLATE_ID_MAP = mapRegistryValues((entry) => entry.templateId) as { + readonly [EntityType in OcfEntityType]: (typeof ENTITY_REGISTRY)[EntityType]['templateId']; +}; /** Mapping from entity type to DAML create-argument data field. */ export const ENTITY_DATA_FIELD_MAP = mapRegistryValues((entry) => entry.dataField); @@ -788,7 +833,8 @@ export const SECURITY_ID_FIELD_TO_ENTITY_TYPE = Object.fromEntries( /** * Mapping from entity type to DAML tag names. * - * PlanSecurity entries derive EquityCompensation tags from ENTITY_REGISTRY because the underlying DAML unions are the - * same. + * Every tag is constrained to the generated DAML union for the registry key. */ -export const ENTITY_TAG_MAP = mapRegistryValues((entry) => entry.operations); +export const ENTITY_TAG_MAP = mapRegistryValues((entry) => entry.operations) as { + readonly [EntityType in OcfEntityType]: (typeof ENTITY_REGISTRY)[EntityType]['operations']; +}; diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index 2021d7bf..690f1aff 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -10,12 +10,16 @@ */ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { ReadScopeParams } from '../../../types/common'; import { readSingleContract } from '../shared/singleContractRead'; import { ENTITY_DATA_FIELD_FALLBACK_MAP, ENTITY_DATA_FIELD_MAP, + ENTITY_TAG_MAP, + ENTITY_TEMPLATE_ID_MAP, + type DamlDataTypeFor, type OcfDataTypeFor, type OcfEntityType, } from './batchTypes'; @@ -70,7 +74,7 @@ import { damlWarrantIssuanceDataToNative } from '../warrantIssuance/getWarrantIs import { damlWarrantRetractionToNative } from '../warrantRetraction/damlToOcf'; import { damlWarrantTransferToNative } from '../warrantTransfer/damlToOcf'; -export { ENTITY_DATA_FIELD_FALLBACK_MAP, ENTITY_DATA_FIELD_MAP }; +export { ENTITY_DATA_FIELD_FALLBACK_MAP, ENTITY_DATA_FIELD_MAP, ENTITY_TEMPLATE_ID_MAP }; // Note: DAML input type definitions and converter implementations have been moved to their // respective entity folders (e.g., stockTransfer/damlToOcf.ts) following the Entity Folder @@ -81,8 +85,6 @@ export { ENTITY_DATA_FIELD_FALLBACK_MAP, ENTITY_DATA_FIELD_MAP }; * * This dispatcher supports all core OCF entity types. Each converter is imported from its * respective entity folder following the Entity Folder Organization pattern. - * - * PlanSecurity types are aliases that delegate to EquityCompensation converters. */ export type SupportedOcfReadType = OcfEntityType; @@ -103,246 +105,157 @@ export type SupportedOcfReadType = OcfEntityType; * const native = convertToOcf('stockAcceptance', damlData); * ``` */ -export function convertToOcf( - type: T, - damlData: Record -): OcfDataTypeFor { - // Cast through unknown to allow conversion from Record to specific types - const data = damlData as unknown; - +export function convertToOcf( + type: EntityType, + damlData: DamlDataTypeFor +): OcfDataTypeFor; +export function convertToOcf( + type: SupportedOcfReadType, + data: DamlDataTypeFor +): OcfDataTypeFor { switch (type) { // ===== Core objects ===== case 'document': - return damlDocumentDataToNative(data as Parameters[0]) as OcfDataTypeFor; + return damlDocumentDataToNative(data as Parameters[0]); case 'issuer': - return damlIssuerDataToNative(data as Parameters[0]) as OcfDataTypeFor; + return damlIssuerDataToNative(data as Parameters[0]); case 'stakeholder': - return damlStakeholderDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStakeholderDataToNative(data as Parameters[0]); case 'stockClass': - return damlStockClassDataToNative(data as Parameters[0]) as OcfDataTypeFor; + return damlStockClassDataToNative(data as Parameters[0]); case 'stockLegendTemplate': - return damlStockLegendTemplateDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockLegendTemplateDataToNative(data as Parameters[0]); case 'stockPlan': - return damlStockPlanDataToNative(data as Parameters[0]) as OcfDataTypeFor; + return damlStockPlanDataToNative(data as Parameters[0]); case 'vestingTerms': - return damlVestingTermsDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlVestingTermsDataToNative(data as Parameters[0]); // ===== Issuance types ===== case 'convertibleIssuance': - return damlConvertibleIssuanceDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlConvertibleIssuanceDataToNative(data); case 'equityCompensationIssuance': - return damlEquityCompensationIssuanceDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlEquityCompensationIssuanceDataToNative(data); case 'stockIssuance': - return damlStockIssuanceDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockIssuanceDataToNative(data as Parameters[0]); case 'warrantIssuance': - return damlWarrantIssuanceDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlWarrantIssuanceDataToNative(data); // ===== Acceptance types ===== case 'stockAcceptance': - return damlStockAcceptanceToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockAcceptanceToNative(data as Parameters[0]); case 'convertibleAcceptance': - return damlConvertibleAcceptanceToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlConvertibleAcceptanceToNative(data as Parameters[0]); case 'equityCompensationAcceptance': return damlEquityCompensationAcceptanceToNative( data as Parameters[0] - ) as OcfDataTypeFor; + ); case 'warrantAcceptance': - return damlWarrantAcceptanceToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlWarrantAcceptanceToNative(data as Parameters[0]); // ===== Exercise types ===== case 'equityCompensationExercise': - return damlEquityCompensationExerciseDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlEquityCompensationExerciseDataToNative(data); // ===== Adjustment types ===== case 'issuerAuthorizedSharesAdjustment': - return damlIssuerAuthorizedSharesAdjustmentDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlIssuerAuthorizedSharesAdjustmentDataToNative(data); case 'stockClassAuthorizedSharesAdjustment': return damlStockClassAuthorizedSharesAdjustmentDataToNative( data as Parameters[0] - ) as OcfDataTypeFor; + ); case 'stockPlanPoolAdjustment': return damlStockPlanPoolAdjustmentDataToNative( data as Parameters[0] - ) as OcfDataTypeFor; + ); // Stock class adjustments (with converters from entity folders) case 'stockClassConversionRatioAdjustment': return damlStockClassConversionRatioAdjustmentToNative( data as Parameters[0] - ) as OcfDataTypeFor; + ); case 'stockClassSplit': - return damlStockClassSplitToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockClassSplitToNative(data as Parameters[0]); case 'stockConsolidation': - return damlStockConsolidationToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockConsolidationToNative(data as Parameters[0]); // Valuation and vesting (with converters from entity folders) case 'valuation': - return damlValuationToNative(data as Parameters[0]) as OcfDataTypeFor; + return damlValuationToNative(data as Parameters[0]); case 'vestingAcceleration': - return damlVestingAccelerationToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlVestingAccelerationToNative(data as Parameters[0]); case 'vestingEvent': - return damlVestingEventToNative(data as Parameters[0]) as OcfDataTypeFor; + return damlVestingEventToNative(data as Parameters[0]); case 'vestingStart': - return damlVestingStartToNative(data as Parameters[0]) as OcfDataTypeFor; + return damlVestingStartToNative(data as Parameters[0]); // Types with converters imported from entity folders case 'stockRetraction': - return damlStockRetractionToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockRetractionToNative(data as Parameters[0]); case 'stockConversion': - return damlStockConversionToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockConversionToNative(data as Parameters[0]); case 'stockPlanReturnToPool': - return damlStockPlanReturnToPoolToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockPlanReturnToPoolToNative(data as Parameters[0]); case 'stockReissuance': - return damlStockReissuanceToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockReissuanceToNative(data as Parameters[0]); case 'warrantExercise': - return damlWarrantExerciseToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlWarrantExerciseToNative(data); case 'warrantRetraction': - return damlWarrantRetractionToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlWarrantRetractionToNative(data as Parameters[0]); case 'convertibleConversion': - return damlConvertibleConversionToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlConvertibleConversionToNative(data as Parameters[0]); case 'convertibleRetraction': - return damlConvertibleRetractionToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlConvertibleRetractionToNative(data as Parameters[0]); case 'equityCompensationRelease': - return damlEquityCompensationReleaseToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlEquityCompensationReleaseToNative(data as Parameters[0]); case 'equityCompensationRepricing': return damlEquityCompensationRepricingToNative( data as Parameters[0] - ) as OcfDataTypeFor; + ); case 'equityCompensationRetraction': return damlEquityCompensationRetractionToNative( data as Parameters[0] - ) as OcfDataTypeFor; + ); // Transfer types (with converters from entity folders) case 'stockTransfer': - return damlStockTransferToNative(data as Parameters[0]) as OcfDataTypeFor; + return damlStockTransferToNative(data as Parameters[0]); case 'warrantTransfer': - return damlWarrantTransferToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlWarrantTransferToNative(data as Parameters[0]); case 'equityCompensationTransfer': return damlEquityCompensationTransferToNative( data as Parameters[0] - ) as OcfDataTypeFor; + ); case 'convertibleTransfer': - return damlConvertibleTransferToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlConvertibleTransferToNative(data as Parameters[0]); // Cancellation types (with converters from entity folders) case 'stockCancellation': - return damlStockCancellationToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockCancellationToNative(data as Parameters[0]); case 'warrantCancellation': - return damlWarrantCancellationToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlWarrantCancellationToNative(data as Parameters[0]); case 'equityCompensationCancellation': return damlEquityCompensationCancellationToNative( data as Parameters[0] - ) as OcfDataTypeFor; + ); case 'convertibleCancellation': - return damlConvertibleCancellationToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlConvertibleCancellationToNative(data as Parameters[0]); // Repurchase (with converter from entity folder) case 'stockRepurchase': - return damlStockRepurchaseToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockRepurchaseToNative(data as Parameters[0]); // Stakeholder events (with converters from entity folders) case 'stakeholderRelationshipChangeEvent': return damlStakeholderRelationshipChangeEventToNative( data as Parameters[0] - ) as OcfDataTypeFor; + ); case 'stakeholderStatusChangeEvent': return damlStakeholderStatusChangeEventToNative( data as Parameters[0] - ) as OcfDataTypeFor; - - // PlanSecurity aliases - delegate to EquityCompensation converters - case 'planSecurityAcceptance': - return damlEquityCompensationAcceptanceToNative( - data as Parameters[0] - ) as OcfDataTypeFor; - case 'planSecurityCancellation': - return damlEquityCompensationCancellationToNative( - data as Parameters[0] - ) as OcfDataTypeFor; - case 'planSecurityExercise': - return damlEquityCompensationExerciseDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; - case 'planSecurityIssuance': - return damlEquityCompensationIssuanceDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; - case 'planSecurityRelease': - return damlEquityCompensationReleaseToNative( - damlData as unknown as Parameters[0] - ) as OcfDataTypeFor; - case 'planSecurityRetraction': - return damlEquityCompensationRetractionToNative( - damlData as unknown as Parameters[0] - ) as OcfDataTypeFor; - case 'planSecurityTransfer': - return damlEquityCompensationTransferToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + ); default: { - throw new OcpParseError(`Unsupported entity type for convertToOcf: ${type}`, { + throw new OcpParseError(`Unsupported entity type for convertToOcf: ${String(type)}`, { source: 'damlToOcf.convertToOcf', code: OcpErrorCodes.UNKNOWN_ENTITY_TYPE, }); @@ -350,6 +263,29 @@ export function convertToOcf( } } +/** Decode unknown ledger JSON into the exact generated DAML payload for an entity kind. */ +export function decodeDamlEntityData( + entityType: EntityType, + input: unknown +): DamlDataTypeFor; +export function decodeDamlEntityData(entityType: OcfEntityType, input: unknown): DamlDataTypeFor { + const tag = ENTITY_TAG_MAP[entityType].edit; + try { + return Fairmint.OpenCapTable.CapTable.OcfEditData.decoder.runWithException({ tag, value: input }).value; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new OcpParseError(`Invalid DAML data for ${entityType}: ${message}`, { + source: `damlToOcf.${entityType}`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { entityType, expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType] }, + }); + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + /** * Extract entity data from a DAML contract's create argument. * @@ -368,7 +304,7 @@ export function convertToOcf( * ``` */ export function extractEntityData(entityType: OcfEntityType, createArgument: unknown): Record { - if (!createArgument || typeof createArgument !== 'object') { + if (!isRecord(createArgument)) { throw new OcpParseError('Invalid createArgument: expected an object', { source: entityType, code: OcpErrorCodes.INVALID_RESPONSE, @@ -377,7 +313,7 @@ export function extractEntityData(entityType: OcfEntityType, createArgument: unk const dataFieldName = ENTITY_DATA_FIELD_MAP[entityType]; const fallbackFieldNames = ENTITY_DATA_FIELD_FALLBACK_MAP[entityType] ?? []; - const record = createArgument as Record; + const record = createArgument; const resolvedDataFieldName = dataFieldName in record ? dataFieldName : fallbackFieldNames.find((fieldName) => fieldName in record); @@ -393,14 +329,14 @@ export function extractEntityData(entityType: OcfEntityType, createArgument: unk } const entityData = record[resolvedDataFieldName]; - if (!entityData || typeof entityData !== 'object') { + if (!isRecord(entityData)) { throw new OcpParseError(`Entity data field '${resolvedDataFieldName}' is not an object for ${entityType}`, { source: entityType, code: OcpErrorCodes.SCHEMA_MISMATCH, }); } - return entityData as Record; + return entityData; } export { extractCreateArgument } from '../shared/singleContractRead'; @@ -454,14 +390,16 @@ export async function getEntityAsOcf( { operation: `getEntityAsOcf(${entityType})`, missingDataError: 'parse', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], } ); // Extract entity-specific data field const entityData = extractEntityData(entityType, createArgument); + const decodedEntityData = decodeDamlEntityData(entityType, entityData); // Convert DAML data to native OCF format - const nativeData = convertToOcf(entityType, entityData); + const nativeData = convertToOcf(entityType, decodedEntityData); return { data: nativeData, diff --git a/src/functions/OpenCapTable/capTable/index.ts b/src/functions/OpenCapTable/capTable/index.ts index 1c3756ec..2b45d7f8 100644 --- a/src/functions/OpenCapTable/capTable/index.ts +++ b/src/functions/OpenCapTable/capTable/index.ts @@ -31,6 +31,7 @@ export { convertToDaml } from './ocfToDaml'; export { ENTITY_DATA_FIELD_MAP, convertToOcf, + decodeDamlEntityData, extractCreateArgument, extractEntityData, getEntityAsOcf, diff --git a/src/functions/OpenCapTable/capTable/ocfToDaml.ts b/src/functions/OpenCapTable/capTable/ocfToDaml.ts index 5cfeaaa5..266e163e 100644 --- a/src/functions/OpenCapTable/capTable/ocfToDaml.ts +++ b/src/functions/OpenCapTable/capTable/ocfToDaml.ts @@ -11,7 +11,13 @@ import { OcpErrorCodes, OcpParseError } from '../../../errors'; import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; -import type { OcfDataTypeFor, OcfEntityArguments } from './batchTypes'; +import type { + OcfCreateOperation, + OcfDataTypeFor, + OcfEditOperation, + OcfEntityArguments, + OcfEntityType, +} from './batchTypes'; // Import converters from entity folders import { convertibleAcceptanceDataToDaml } from '../convertibleAcceptance/convertibleAcceptanceDataToDaml'; @@ -31,8 +37,6 @@ import { equityCompensationRetractionDataToDaml } from '../equityCompensationRet import { equityCompensationTransferDataToDaml } from '../equityCompensationTransfer/equityCompensationTransferDataToDaml'; import { issuerDataToDaml } from '../issuer/createIssuer'; import { issuerAuthorizedSharesAdjustmentDataToDaml } from '../issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment'; -import { planSecurityExerciseDataToDaml } from '../planSecurityExercise/planSecurityExerciseDataToDaml'; -import { planSecurityIssuanceDataToDaml } from '../planSecurityIssuance/planSecurityIssuanceDataToDaml'; import { stakeholderDataToDaml } from '../stakeholder/stakeholderDataToDaml'; import { stakeholderRelationshipChangeEventDataToDaml } from '../stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml'; import { stakeholderStatusChangeEventDataToDaml } from '../stakeholderStatusChangeEvent/stakeholderStatusChangeEventDataToDaml'; @@ -74,6 +78,15 @@ import { warrantTransferDataToDaml } from '../warrantTransfer/warrantTransferDat */ export function convertToDaml(...args: OcfEntityArguments): Record { const [type, data] = args; + return convertEntityToDaml(type, data); +} + +/** Convert a correlated create/edit operation object to its generated DAML payload. */ +export function convertOperationToDaml(operation: OcfCreateOperation | OcfEditOperation): Record { + return convertEntityToDaml(operation.type, operation.data); +} + +function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor): Record { const d = parseOcfEntityInput(type, data); switch (type) { @@ -176,22 +189,6 @@ export function convertToDaml(...args: OcfEntityArguments): Record); - // PlanSecurity aliases - delegate to dedicated converters or EquityCompensation converters - case 'planSecurityIssuance': - return planSecurityIssuanceDataToDaml(d as OcfDataTypeFor<'planSecurityIssuance'>); - case 'planSecurityExercise': - return planSecurityExerciseDataToDaml(d as OcfDataTypeFor<'planSecurityExercise'>); - case 'planSecurityCancellation': - return equityCompensationCancellationDataToDaml(d as unknown as OcfDataTypeFor<'equityCompensationCancellation'>); - case 'planSecurityAcceptance': - return equityCompensationAcceptanceDataToDaml(d as unknown as OcfDataTypeFor<'equityCompensationAcceptance'>); - case 'planSecurityRelease': - return equityCompensationReleaseDataToDaml(d as unknown as OcfDataTypeFor<'equityCompensationRelease'>); - case 'planSecurityRetraction': - return equityCompensationRetractionDataToDaml(d as unknown as OcfDataTypeFor<'equityCompensationRetraction'>); - case 'planSecurityTransfer': - return equityCompensationTransferDataToDaml(d as unknown as OcfDataTypeFor<'equityCompensationTransfer'>); - // Stakeholder change events case 'stakeholderRelationshipChangeEvent': return stakeholderRelationshipChangeEventDataToDaml(d as OcfDataTypeFor<'stakeholderRelationshipChangeEvent'>); diff --git a/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts index 7f24c9a5..bdee117a 100644 --- a/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts @@ -41,6 +41,7 @@ export function convertibleAcceptanceDataToDaml(d: OcfConvertibleAcceptance): Re */ export function damlConvertibleAcceptanceToNative(damlData: DamlConvertibleAcceptanceData): OcfConvertibleAcceptance { return { + object_type: 'TX_CONVERTIBLE_ACCEPTANCE', id: damlData.id, date: damlTimeToDateString(damlData.date), security_id: damlData.security_id, diff --git a/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts b/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts index b68af410..80d67f88 100644 --- a/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts @@ -41,6 +41,7 @@ export function damlConvertibleCancellationToNative(d: DamlConvertibleCancellati } return { + object_type: 'TX_CONVERTIBLE_CANCELLATION', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts b/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts index cfc262d0..4ddf18b6 100644 --- a/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts @@ -1,7 +1,9 @@ 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 { OcfConvertibleCancellation } from '../../../types/native'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlConvertibleCancellationToNative } from './damlToOcf'; /** * OCF Convertible Cancellation Event with object_type discriminator OCF: @@ -10,15 +12,7 @@ import { readSingleContract } from '../shared/singleContractRead'; * Note: Convertible cancellations don't have a quantity field since convertibles are monetary instruments (SAFEs, * convertible notes) rather than share-based securities. */ -export interface OcfConvertibleCancellationEvent { - object_type: 'TX_CONVERTIBLE_CANCELLATION'; - id: string; - date: string; - security_id: string; - balance_security_id?: string; - reason_text: string; - comments?: string[]; -} +export type OcfConvertibleCancellationEvent = OcfConvertibleCancellation; export type GetConvertibleCancellationAsOcfParams = GetByContractIdParams; @@ -47,14 +41,14 @@ export async function getConvertibleCancellationAsOcf( const contract = createArgument as ConvertibleCancellationCreateArgument; const data = contract.cancellation_data; - const event: OcfConvertibleCancellationEvent = { - object_type: 'TX_CONVERTIBLE_CANCELLATION', + const event = damlConvertibleCancellationToNative({ id: data.id, - date: data.date.split('T')[0], + date: data.date, security_id: data.security_id, + amount: data.amount, ...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}), reason_text: data.reason_text, ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; + }); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts index e3aada78..9fc9a644 100644 --- a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts @@ -30,6 +30,7 @@ export interface DamlConvertibleConversionData { */ export function damlConvertibleConversionToNative(d: DamlConvertibleConversionData): OcfConvertibleConversion { return { + object_type: 'TX_CONVERTIBLE_CONVERSION', id: d.id, date: damlTimeToDateString(d.date), reason_text: d.reason_text, diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 0d510b0d..826d3438 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -67,8 +67,8 @@ interface NoteConversionMechanism { }> | null; day_count_convention?: 'ACTUAL_365' | '30_360'; interest_payout?: 'DEFERRED' | 'CASH'; - interest_accrual_period?: string; - compounding_type?: string; + interest_accrual_period?: 'DAILY' | 'MONTHLY' | 'QUARTERLY' | 'SEMI_ANNUAL' | 'ANNUAL'; + compounding_type?: 'SIMPLE' | 'COMPOUNDING'; conversion_discount?: string; conversion_valuation_cap?: { amount: string; currency: string }; capitalization_definition?: string; @@ -104,24 +104,7 @@ interface ConversionTrigger { end_date?: string; } -export interface OcfConvertibleIssuanceEvent { - object_type: 'TX_CONVERTIBLE_ISSUANCE'; - id: string; - date: string; - security_id: string; - custom_id: string; - stakeholder_id: string; - board_approval_date?: string; - stockholder_approval_date?: string; - investment_amount: { amount: string; currency: string }; - consideration_text?: string; - convertible_type: 'NOTE' | 'SAFE' | 'CONVERTIBLE_SECURITY'; - conversion_triggers: ConversionTrigger[]; - pro_rata?: string; - seniority: number; - security_law_exemptions: Array<{ description: string; jurisdiction: string }>; - comments?: string[]; -} +export type OcfConvertibleIssuanceEvent = OcfConvertibleIssuance; export interface GetConvertibleIssuanceAsOcfParams extends GetByContractIdParams {} @@ -402,7 +385,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers if (s.endsWith('OcfAccrualAnnual') || s === 'OcfAccrualAnnual') return 'ANNUAL'; return undefined; }; - const compoundingFromDaml = (v: unknown): string | undefined => { + const compoundingFromDaml = (v: unknown): 'SIMPLE' | 'COMPOUNDING' | undefined => { const s = safeString(v); if (!s) return undefined; if (s === 'OcfSimple') return 'SIMPLE'; @@ -650,10 +633,8 @@ export function damlConvertibleIssuanceDataToNative(d: Record): const investmentAmountStr = typeof investmentAmount.amount === 'number' ? investmentAmount.amount.toString() : investmentAmount.amount; - // Build the issuance object - use type assertion because the local helper types - // (ConversionTrigger, etc.) have compatible structure but different TypeScript definitions - // than the native types (ConvertibleConversionTrigger, etc.) const issuance = { + object_type: 'TX_CONVERTIBLE_ISSUANCE', id: d.id, date: d.date.split('T')[0], security_id: d.security_id, @@ -699,7 +680,7 @@ export function damlConvertibleIssuanceDataToNative(d: Record): jurisdiction: string; }>, ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments as string[] } : {}), - } as OcfConvertibleIssuance; + } satisfies OcfConvertibleIssuance; return issuance; } @@ -722,10 +703,5 @@ export async function getConvertibleIssuanceAsOcf( const d = (arg as { issuance_data: Record }).issuance_data; const native = damlConvertibleIssuanceDataToNative(d); - // Add object_type to create the full event type - const event = { - object_type: 'TX_CONVERTIBLE_ISSUANCE' as const, - ...native, - } as OcfConvertibleIssuanceEvent; - return { event, contractId: params.contractId }; + return { event: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/convertibleRetraction/damlToOcf.ts b/src/functions/OpenCapTable/convertibleRetraction/damlToOcf.ts index 2b377799..6f244537 100644 --- a/src/functions/OpenCapTable/convertibleRetraction/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleRetraction/damlToOcf.ts @@ -25,6 +25,7 @@ export interface DamlConvertibleRetractionData { */ export function damlConvertibleRetractionToNative(d: DamlConvertibleRetractionData): OcfConvertibleRetraction { return { + object_type: 'TX_CONVERTIBLE_RETRACTION', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts b/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts index a5af4d88..80a9a2dd 100644 --- a/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts @@ -28,6 +28,7 @@ export interface DamlConvertibleTransferData { */ export function damlConvertibleTransferToNative(d: DamlConvertibleTransferData): OcfConvertibleTransfer { return { + object_type: 'TX_CONVERTIBLE_TRANSFER', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/document/getDocumentAsOcf.ts b/src/functions/OpenCapTable/document/getDocumentAsOcf.ts index 0fbb30b8..76a64873 100644 --- a/src/functions/OpenCapTable/document/getDocumentAsOcf.ts +++ b/src/functions/OpenCapTable/document/getDocumentAsOcf.ts @@ -1,6 +1,6 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfDocument, OcfObjectReference } from '../../../types/native'; import { readSingleContract } from '../shared/singleContractRead'; @@ -130,9 +130,16 @@ function objectTypeToNative(t: Fairmint.OpenCapTable.OCF.Document.OcfObjectType) } export function damlDocumentDataToNative(d: Fairmint.OpenCapTable.OCF.Document.DocumentOcfData): OcfDocument { - const docWithId = d as unknown as { id?: string }; + const { id } = d as unknown as { id?: unknown }; + if (typeof id !== 'string' || id.length === 0) { + throw new OcpValidationError('document.id', 'Required field is missing or invalid', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: id, + }); + } return { - id: docWithId.id ?? '', + object_type: 'DOCUMENT', + id, ...(d.path ? { path: d.path } : {}), ...(d.uri ? { uri: d.uri } : {}), md5: d.md5, @@ -149,7 +156,7 @@ export function damlDocumentDataToNative(d: Fairmint.OpenCapTable.OCF.Document.D export interface GetDocumentAsOcfParams extends GetByContractIdParams {} export interface GetDocumentAsOcfResult { - document: OcfDocument & { object_type: 'DOCUMENT'; id?: string }; + document: OcfDocument; contractId: string; } @@ -177,11 +184,5 @@ export async function getDocumentAsOcf( } const native = damlDocumentDataToNative(createArgument.document_data); - const { id, ...rest } = native; - const ocf = { - object_type: 'DOCUMENT' as const, - id, - ...rest, - }; - return { document: ocf, contractId: params.contractId }; + return { document: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts index f6df6d49..1b55853d 100644 --- a/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts +++ b/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts @@ -43,6 +43,7 @@ export function damlEquityCompensationAcceptanceToNative( damlData: DamlEquityCompensationAcceptanceData ): OcfEquityCompensationAcceptance { return { + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', id: damlData.id, date: damlTimeToDateString(damlData.date), security_id: damlData.security_id, diff --git a/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts b/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts index c4d5781f..fded1cf5 100644 --- a/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts @@ -20,5 +20,8 @@ export type DamlEquityCompensationCancellationData = DamlQuantityCancellationDat export function damlEquityCompensationCancellationToNative( d: DamlEquityCompensationCancellationData ): OcfEquityCompensationCancellation { - return quantityCancellationToNative(d); + return { + ...quantityCancellationToNative(d), + object_type: 'TX_EQUITY_COMPENSATION_CANCELLATION', + }; } diff --git a/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts b/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts index 8b85ec26..e48a4e91 100644 --- a/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts @@ -48,6 +48,7 @@ export function damlEquityCompensationExerciseDataToNative(d: Record; const native = damlEquityCompensationExerciseDataToNative(d); - // Add object_type to create the full event type - const event = { - object_type: 'TX_EQUITY_COMPENSATION_EXERCISE' as const, - ...native, - }; - - return { event, contractId: params.contractId }; + return { event: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 08af59b3..e07b07a2 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -13,7 +13,7 @@ import { readSingleContract } from '../shared/singleContractRead'; export interface GetEquityCompensationIssuanceAsOcfParams extends GetByContractIdParams {} export interface GetEquityCompensationIssuanceAsOcfResult { - event: OcfEquityCompensationIssuance & { object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE' }; + event: OcfEquityCompensationIssuance; contractId: string; } @@ -198,6 +198,7 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record; const native = damlEquityCompensationIssuanceDataToNative(d); - // Add object_type to create the full event type - const event = { - object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE' as const, - ...native, - }; - - return { event, contractId: params.contractId }; + return { event: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/equityCompensationRelease/damlToOcf.ts b/src/functions/OpenCapTable/equityCompensationRelease/damlToOcf.ts index 6a8570de..ad6726e0 100644 --- a/src/functions/OpenCapTable/equityCompensationRelease/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationRelease/damlToOcf.ts @@ -33,6 +33,7 @@ export function damlEquityCompensationReleaseToNative( d: DamlEquityCompensationReleaseData ): OcfEquityCompensationRelease { return { + object_type: 'TX_EQUITY_COMPENSATION_RELEASE', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/equityCompensationRepricing/damlToOcf.ts b/src/functions/OpenCapTable/equityCompensationRepricing/damlToOcf.ts index 9a8dc39a..0d0bab41 100644 --- a/src/functions/OpenCapTable/equityCompensationRepricing/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationRepricing/damlToOcf.ts @@ -29,6 +29,7 @@ export function damlEquityCompensationRepricingToNative( d: DamlEquityCompensationRepricingData ): OcfEquityCompensationRepricing { return { + object_type: 'TX_EQUITY_COMPENSATION_REPRICING', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/equityCompensationRetraction/damlToOcf.ts b/src/functions/OpenCapTable/equityCompensationRetraction/damlToOcf.ts index 887c57d7..29f104b1 100644 --- a/src/functions/OpenCapTable/equityCompensationRetraction/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationRetraction/damlToOcf.ts @@ -27,6 +27,7 @@ export function damlEquityCompensationRetractionToNative( d: DamlEquityCompensationRetractionData ): OcfEquityCompensationRetraction { return { + object_type: 'TX_EQUITY_COMPENSATION_RETRACTION', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/equityCompensationTransfer/damlToOcf.ts b/src/functions/OpenCapTable/equityCompensationTransfer/damlToOcf.ts index bd4e7446..c427e816 100644 --- a/src/functions/OpenCapTable/equityCompensationTransfer/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationTransfer/damlToOcf.ts @@ -20,5 +20,8 @@ export type DamlEquityCompensationTransferData = DamlQuantityTransferData; export function damlEquityCompensationTransferToNative( d: DamlEquityCompensationTransferData ): OcfEquityCompensationTransfer { - return quantityTransferToNative(d); + return { + ...quantityTransferToNative(d), + object_type: 'TX_EQUITY_COMPENSATION_TRANSFER', + }; } diff --git a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts index aceee3a2..15a4f06f 100644 --- a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts +++ b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts @@ -43,6 +43,7 @@ export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issue }); } const out: OcfIssuerInput = { + object_type: 'ISSUER', id: dataWithId.id, legal_name: damlData.legal_name, country_of_formation: damlData.country_of_formation, @@ -101,10 +102,7 @@ export async function getIssuerAsOcf( const issuerData = createArgument.issuer_data as Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData; const native = damlIssuerDataToNative(issuerData); - const data: OcfIssuerOutput = { - object_type: 'ISSUER', - ...native, - }; + const data: OcfIssuerOutput = native; return { data, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf.ts b/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf.ts index a1c9b91e..9e6e24d4 100644 --- a/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf.ts @@ -7,7 +7,7 @@ import { readSingleContract } from '../shared/singleContractRead'; export interface GetIssuerAuthorizedSharesAdjustmentAsOcfParams extends GetByContractIdParams {} export interface GetIssuerAuthorizedSharesAdjustmentAsOcfResult { - event: OcfIssuerAuthorizedSharesAdjustment & { object_type: 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT' }; + event: OcfIssuerAuthorizedSharesAdjustment; contractId: string; } @@ -47,6 +47,7 @@ export function damlIssuerAuthorizedSharesAdjustmentDataToNative( }); return { + object_type: 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT', id: d.id, date: d.date.split('T')[0], issuer_id: d.issuer_id, @@ -71,10 +72,5 @@ export async function getIssuerAuthorizedSharesAdjustmentAsOcf( const d = (arg.adjustment_data ?? arg) as Record; const native = damlIssuerAuthorizedSharesAdjustmentDataToNative(d); - // Add object_type to create the full event type - const event = { - object_type: 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT' as const, - ...native, - }; - return { event, contractId: params.contractId }; + return { event: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts b/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts index 2d209de0..9adf592d 100644 --- a/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts +++ b/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts @@ -2,7 +2,15 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; -import type { ContactInfo, ContactInfoWithoutName, Email, Name, Phone } from '../../../types/native'; +import type { + ContactInfo, + ContactInfoWithoutName, + Email, + Name, + OcfStakeholder, + Phone, + StakeholderRelationshipType, +} from '../../../types/native'; import { damlEmailTypeToNative, damlPhoneTypeToNative, @@ -63,22 +71,48 @@ function damlContactInfoWithoutNameToNative( export function damlStakeholderDataToNative( damlData: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData -): Omit { +): OcfStakeholder { const dAny = damlData as unknown as Record; - const nameData = dAny.name as Record | undefined; + const { id } = dAny; + if (typeof id !== 'string' || id.length === 0) { + throw new OcpValidationError('stakeholder.id', 'Required field is missing or invalid', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: id, + }); + } + + const nameData = dAny.name; + if (typeof nameData !== 'object' || nameData === null || Array.isArray(nameData)) { + throw new OcpValidationError('stakeholder.name', 'Required field is missing or invalid', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: nameData, + }); + } + const legalName = (nameData as Record).legal_name; + if (typeof legalName !== 'string' || legalName.length === 0) { + throw new OcpValidationError('stakeholder.name.legal_name', 'Required field is missing or invalid', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: legalName, + }); + } + const nameRecord = nameData as Record; const name: Name = { - legal_name: (nameData?.legal_name as string | undefined) ?? '', - ...(nameData?.first_name ? { first_name: nameData.first_name as string } : {}), - ...(nameData?.last_name ? { last_name: nameData.last_name as string } : {}), + legal_name: legalName, + ...(typeof nameRecord.first_name === 'string' && nameRecord.first_name.length > 0 + ? { first_name: nameRecord.first_name } + : {}), + ...(typeof nameRecord.last_name === 'string' && nameRecord.last_name.length > 0 + ? { last_name: nameRecord.last_name } + : {}), }; - const relationships: string[] = Array.isArray(dAny.current_relationships) + const relationships: StakeholderRelationshipType[] = Array.isArray(dAny.current_relationships) ? (dAny.current_relationships as string[]).map((r) => damlStakeholderRelationshipToNative(r as Fairmint.OpenCapTable.Types.Stakeholder.OcfStakeholderRelationshipType) ) : []; - const dataWithId = dAny as { id?: string }; - const native: Omit = { - id: dataWithId.id ?? '', + const native: OcfStakeholder = { + object_type: 'STAKEHOLDER', + id, name, stakeholder_type: damlStakeholderTypeToNative(damlData.stakeholder_type), ...(damlData.issuer_assigned_id ? { issuer_assigned_id: damlData.issuer_assigned_id } : {}), @@ -105,44 +139,10 @@ export function damlStakeholderDataToNative( return native; } -interface OcfStakeholderOutput { - object_type: 'STAKEHOLDER'; - id?: string; - name: { legal_name: string; first_name?: string; last_name?: string }; - stakeholder_type: 'INDIVIDUAL' | 'INSTITUTION'; - issuer_assigned_id?: string; - current_relationships?: string[]; - primary_contact?: { - name: { legal_name: string; first_name?: string; last_name?: string }; - phone_numbers?: Array<{ - phone_type: 'HOME' | 'MOBILE' | 'BUSINESS' | 'OTHER'; - phone_number: string; - }>; - emails?: Array<{ email_type: 'PERSONAL' | 'BUSINESS' | 'OTHER'; email_address: string }>; - }; - contact_info?: { - phone_numbers?: Array<{ - phone_type: 'HOME' | 'MOBILE' | 'BUSINESS' | 'OTHER'; - phone_number: string; - }>; - emails?: Array<{ email_type: 'PERSONAL' | 'BUSINESS' | 'OTHER'; email_address: string }>; - }; - addresses: Array<{ - address_type: 'LEGAL' | 'CONTACT' | 'OTHER'; - street_suite?: string; - city?: string; - country_subdivision?: string; - country: string; - postal_code?: string; - }>; - tax_ids: Array<{ tax_id: string; country: string }>; - comments?: string[]; -} - export interface GetStakeholderAsOcfParams extends GetByContractIdParams {} export interface GetStakeholderAsOcfResult { - stakeholder: OcfStakeholderOutput; + stakeholder: OcfStakeholder; contractId: string; } @@ -183,14 +183,7 @@ export async function getStakeholderAsOcf( }); } - const native = damlStakeholderDataToNative(createArgument.stakeholder_data); - - const ocfStakeholder: OcfStakeholderOutput = { - object_type: 'STAKEHOLDER', - ...native, - addresses: native.addresses, - tax_ids: native.tax_ids, - }; + const stakeholder = damlStakeholderDataToNative(createArgument.stakeholder_data); - return { stakeholder: ocfStakeholder, contractId: params.contractId }; + return { stakeholder, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml.ts index d0f6153c..6a9b4e2f 100644 --- a/src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml.ts +++ b/src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml.ts @@ -41,6 +41,7 @@ export function stockAcceptanceDataToDaml(d: OcfStockAcceptance): Record; const dataWithId = damlRecord as { id?: string }; @@ -119,6 +101,7 @@ export function damlStockClassDataToNative( } return { + object_type: 'STOCK_CLASS', id: dataWithId.id, name: damlData.name, class_type: damlStockClassTypeToNative(damlData.class_type), @@ -255,89 +238,11 @@ export function damlStockClassDataToNative( }; } -/** - * OCF Stock Class object according to the Open Cap Table Coalition schema Object describing a class of stock issued by - * the issuer - * - * @see https://schema.opencaptablecoalition.com/v/1.2.0/objects/StockClass.schema.json - */ -interface OcfStockClassOutput { - /** Object type identifier - must be "STOCK_CLASS" */ - object_type: 'STOCK_CLASS'; - - /** Name for the stock type (e.g. Series A Preferred or Class A Common) */ - name: string; - - /** The type of this stock class (e.g. Preferred or Common) */ - class_type: 'PREFERRED' | 'COMMON'; - - /** - * Default prefix for certificate numbers in certificated shares (e.g. CS- in CS-1). If certificate IDs have a dash, - * the prefix should end in the dash like CS- - */ - default_id_prefix: string; - - /** The initial number of shares authorized for this stock class */ - initial_shares_authorized: string | number; - - /** Date on which the board approved the stock class (YYYY-MM-DD format) */ - board_approval_date?: string; - - /** Date on which the stockholders approved the stock class (YYYY-MM-DD format) */ - stockholder_approval_date?: string; - - /** The number of votes each share of this stock class gets */ - votes_per_share: string | number; - - /** Per-share par value of this stock class */ - par_value?: { - /** The amount of the monetary value */ - amount: string; - /** The currency code for the monetary value (ISO 4217) */ - currency: string; - }; - - /** Per-share price this stock class was issued for */ - price_per_share?: { - /** The amount of the monetary value */ - amount: string; - /** The currency code for the monetary value (ISO 4217) */ - currency: string; - }; - - /** - * Seniority of the stock - determines repayment priority. Seniority is ordered by increasing number so that stock - * classes with a higher seniority have higher repayment priority. The following properties hold for all stock classes - * for a given company: a) transitivity: stock classes are absolutely stackable by seniority and in increasing - * numerical order, b) non-uniqueness: multiple stock classes can have the same Seniority number and therefore have - * the same liquidation/repayment order. In practice, stock classes with same seniority may be created at different - * points in time and (for example, an extension of an existing preferred financing round), and also a new stock class - * can be created with seniority between two existing stock classes, in which case it is assigned some decimal number - * between the numbers representing seniority of the respective classes. - */ - seniority: string | number; - - /** List of stock class conversion rights possible for this stock class */ - conversion_rights?: StockClassConversionRight[]; - - /** The liquidation preference per share for this stock class */ - liquidation_preference_multiple?: string | number; - - /** The participation cap multiple per share for this stock class */ - participation_cap_multiple?: string | number; - - /** Unique identifier for the stock class object */ - id?: string; - - /** Additional comments or notes about the stock class */ - comments?: string[]; -} - export interface GetStockClassAsOcfParams extends GetByContractIdParams {} export interface GetStockClassAsOcfResult { /** The OCF StockClass object */ - stockClass: OcfStockClassOutput; + stockClass: OcfStockClass; /** The original contract ID */ contractId: string; } @@ -387,47 +292,8 @@ export async function getStockClassAsOcf( // Use the shared conversion function from typeConversions.ts const nativeStockClassData = damlStockClassDataToNative(stockClassData); - // Helper function to ensure monetary amounts are strings - const ensureStringAmount = (monetary: { amount: string | number; currency: string }) => ({ - amount: typeof monetary.amount === 'number' ? monetary.amount.toString() : monetary.amount, - currency: monetary.currency, - }); - - // Destructure native data, excluding fields that need type conversion - const { - id, - par_value, - price_per_share, - initial_shares_authorized, - votes_per_share, - seniority, - liquidation_preference_multiple, - participation_cap_multiple, - conversion_rights, - comments, - ...baseStockClassData - } = nativeStockClassData; - - // Transform native stock class data to OCF format, adding OCF-specific fields - // Note: All numeric values are already normalized to strings by damlStockClassDataToNative - const ocfStockClassOutput: OcfStockClassOutput = { - object_type: 'STOCK_CLASS', - id, - ...baseStockClassData, - comments, - initial_shares_authorized, - votes_per_share, - seniority, - // Add optional monetary fields with proper string conversion - ...(par_value && { par_value: ensureStringAmount(par_value) }), - ...(price_per_share && { price_per_share: ensureStringAmount(price_per_share) }), - ...(liquidation_preference_multiple != null ? { liquidation_preference_multiple } : {}), - ...(participation_cap_multiple != null ? { participation_cap_multiple } : {}), - ...(conversion_rights.length > 0 ? { conversion_rights } : {}), - }; - return { - stockClass: ocfStockClassOutput, + stockClass: nativeStockClassData, contractId: params.contractId, }; } diff --git a/src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/getStockClassAuthorizedSharesAdjustmentAsOcf.ts b/src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/getStockClassAuthorizedSharesAdjustmentAsOcf.ts index 6f378318..e6a890ef 100644 --- a/src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/getStockClassAuthorizedSharesAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/getStockClassAuthorizedSharesAdjustmentAsOcf.ts @@ -6,16 +6,7 @@ import type { OcfStockClassAuthorizedSharesAdjustment } from '../../../types/nat import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; -export interface OcfStockClassAuthorizedSharesAdjustmentEvent { - object_type: 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT'; - id: string; - date: string; - stock_class_id: string; - new_shares_authorized: string; - board_approval_date?: string; - stockholder_approval_date?: string; - comments?: string[]; -} +export type OcfStockClassAuthorizedSharesAdjustmentEvent = OcfStockClassAuthorizedSharesAdjustment; export interface GetStockClassAuthorizedSharesAdjustmentAsOcfParams extends GetByContractIdParams {} export interface GetStockClassAuthorizedSharesAdjustmentAsOcfResult { @@ -40,6 +31,7 @@ export function damlStockClassAuthorizedSharesAdjustmentDataToNative( typeof newSharesAuthorized === 'number' ? newSharesAuthorized.toString() : newSharesAuthorized; return { + object_type: 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT', id: data.id, date: damlTimeToDateString(data.date), stock_class_id: data.stock_class_id, @@ -64,9 +56,5 @@ export async function getStockClassAuthorizedSharesAdjustmentAsOcf( const contract = createArgument as StockClassAuthorizedSharesAdjustmentCreateArgument; const native = damlStockClassAuthorizedSharesAdjustmentDataToNative(contract.adjustment_data); - const event: OcfStockClassAuthorizedSharesAdjustmentEvent = { - object_type: 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT', - ...native, - }; - return { event, contractId: params.contractId }; + return { event: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts index 07fc49c8..4778e429 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts @@ -39,6 +39,7 @@ export function damlStockClassConversionRatioAdjustmentToNative( : d.new_ratio_conversion_mechanism.ratio.denominator; return { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', id: d.id, date: d.date.split('T')[0], stock_class_id: d.stock_class_id, diff --git a/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts b/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts index 8829e1c6..b48c6c04 100644 --- a/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts +++ b/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts @@ -29,6 +29,7 @@ export function damlStockClassSplitToNative(d: DamlStockClassSplitData): OcfStoc typeof d.split_ratio.denominator === 'number' ? d.split_ratio.denominator.toString() : d.split_ratio.denominator; return { + object_type: 'TX_STOCK_CLASS_SPLIT', id: d.id, date: d.date.split('T')[0], stock_class_id: d.stock_class_id, diff --git a/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts b/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts index 04f0b707..6e21620f 100644 --- a/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts +++ b/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts @@ -21,6 +21,7 @@ export interface DamlStockConsolidationData { */ export function damlStockConsolidationToNative(d: DamlStockConsolidationData): OcfStockConsolidation { return { + object_type: 'TX_STOCK_CONSOLIDATION', id: d.id, date: d.date.split('T')[0], security_ids: d.security_ids, diff --git a/src/functions/OpenCapTable/stockConversion/damlToOcf.ts b/src/functions/OpenCapTable/stockConversion/damlToOcf.ts index 9d48df09..3771196f 100644 --- a/src/functions/OpenCapTable/stockConversion/damlToOcf.ts +++ b/src/functions/OpenCapTable/stockConversion/damlToOcf.ts @@ -27,6 +27,7 @@ export interface DamlStockConversionData { */ export function damlStockConversionToNative(d: DamlStockConversionData): OcfStockConversion { return { + object_type: 'TX_STOCK_CONVERSION', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index c42cc845..de951fcb 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -1,14 +1,8 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; -import type { - Monetary, - OcfStockIssuance, - SecurityExemption, - ShareNumberRange, - StockIssuanceType, -} from '../../../types/native'; +import type { OcfStockIssuance, SecurityExemption, ShareNumberRange, StockIssuanceType } from '../../../types/native'; import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; @@ -42,9 +36,16 @@ export function damlStockIssuanceDataToNative( d: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuanceOcfData ): OcfStockIssuance { const anyD = d as unknown as Record; - const dataWithId = anyD as { id?: string }; + const { id } = anyD; + if (typeof id !== 'string' || id.length === 0) { + throw new OcpValidationError('stockIssuance.id', 'Required field is missing or invalid', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: id, + }); + } return { - id: dataWithId.id ?? '', + object_type: 'TX_STOCK_ISSUANCE', + id, date: damlTimeToDateString(d.date), security_id: d.security_id, custom_id: d.custom_id, @@ -98,18 +99,7 @@ export interface GetStockIssuanceAsOcfParams extends GetByContractIdParams {} export interface GetStockIssuanceAsOcfResult { contractId: string; - stockIssuance: Partial & { - object_type: 'TX_STOCK_ISSUANCE'; - id: string; - date: string; - security_id: string; - custom_id: string; - stakeholder_id: string; - stock_class_id: string; - share_price: Monetary; - quantity: string | number; - security_law_exemptions?: SecurityExemption[]; - }; + stockIssuance: OcfStockIssuance; } /** @@ -142,7 +132,6 @@ export async function getStockIssuanceAsOcf( const { share_numbers_issued, vestings, comments, issuance_type, ...rest } = native; const ocf = { - object_type: 'TX_STOCK_ISSUANCE' as const, ...rest, ...(share_numbers_issued && share_numbers_issued.length > 0 ? { share_numbers_issued } : {}), ...(vestings && vestings.length > 0 ? { vestings } : {}), diff --git a/src/functions/OpenCapTable/stockLegendTemplate/getStockLegendTemplateAsOcf.ts b/src/functions/OpenCapTable/stockLegendTemplate/getStockLegendTemplateAsOcf.ts index 12b0a32a..07c7a46a 100644 --- a/src/functions/OpenCapTable/stockLegendTemplate/getStockLegendTemplateAsOcf.ts +++ b/src/functions/OpenCapTable/stockLegendTemplate/getStockLegendTemplateAsOcf.ts @@ -2,15 +2,14 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; +import type { OcfStockLegendTemplate } from '../../../types/native'; import { readSingleContract } from '../shared/singleContractRead'; /** Type alias for DAML StockLegendTemplate contract createArgument */ type StockLegendTemplateCreateArgument = Fairmint.OpenCapTable.OCF.StockLegendTemplate.StockLegendTemplate; type DamlStockLegendTemplateOcfData = Fairmint.OpenCapTable.OCF.StockLegendTemplate.StockLegendTemplateOcfData; -export function damlStockLegendTemplateDataToNative( - damlData: DamlStockLegendTemplateOcfData -): Omit { +export function damlStockLegendTemplateDataToNative(damlData: DamlStockLegendTemplateOcfData): OcfStockLegendTemplate { if (!damlData.name) { throw new OcpValidationError('stockLegendTemplate.name', 'Required field is missing', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -22,6 +21,7 @@ export function damlStockLegendTemplateDataToNative( }); } return { + object_type: 'STOCK_LEGEND_TEMPLATE', id: damlData.id, name: damlData.name, text: damlData.text, @@ -29,18 +29,10 @@ export function damlStockLegendTemplateDataToNative( }; } -interface OcfStockLegendTemplateOutput { - object_type: 'STOCK_LEGEND_TEMPLATE'; - id?: string; - name: string; - text: string; - comments?: string[]; -} - export interface GetStockLegendTemplateAsOcfParams extends GetByContractIdParams {} export interface GetStockLegendTemplateAsOcfResult { - stockLegendTemplate: OcfStockLegendTemplateOutput; + stockLegendTemplate: OcfStockLegendTemplate; contractId: string; } @@ -53,12 +45,7 @@ export async function getStockLegendTemplateAsOcf( expectedTemplateId: Fairmint.OpenCapTable.OCF.StockLegendTemplate.StockLegendTemplate.templateId, }); const contract = createArgument as StockLegendTemplateCreateArgument; - const native = damlStockLegendTemplateDataToNative(contract.template_data); - - const ocf: OcfStockLegendTemplateOutput = { - object_type: 'STOCK_LEGEND_TEMPLATE', - ...native, - }; + const stockLegendTemplate = damlStockLegendTemplateDataToNative(contract.template_data); - return { stockLegendTemplate: ocf, contractId: params.contractId }; + return { stockLegendTemplate, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts index 2931c5b0..83028d3e 100644 --- a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; -import type { StockPlanCancellationBehavior } from '../../../types/native'; +import type { OcfStockPlan, StockPlanCancellationBehavior } from '../../../types/native'; import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; @@ -25,9 +25,7 @@ function damlCancellationBehaviorToNative(b: string | null): StockPlanCancellati } } -export function damlStockPlanDataToNative( - d: Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData -): Omit { +export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData): OcfStockPlan { // Access fields via Record type to handle DAML types that may vary from the SDK definition const damlRecord = d as Record; const dataWithId = damlRecord as { id?: string }; @@ -60,6 +58,7 @@ export function damlStockPlanDataToNative( } return { + object_type: 'STOCK_PLAN', id: dataWithId.id, plan_name: d.plan_name, ...(d.board_approval_date && { @@ -81,22 +80,10 @@ export function damlStockPlanDataToNative( }; } -interface OcfStockPlanOutput { - object_type: 'STOCK_PLAN'; - id?: string; - plan_name: string; - initial_shares_reserved: string | number; - board_approval_date?: string; - stockholder_approval_date?: string; - default_cancellation_behavior?: string; - stock_class_ids?: string[]; - comments?: string[]; -} - export interface GetStockPlanAsOcfParams extends GetByContractIdParams {} export interface GetStockPlanAsOcfResult { - stockPlan: OcfStockPlanOutput; + stockPlan: OcfStockPlan; contractId: string; } @@ -121,14 +108,9 @@ export async function getStockPlanAsOcf( }); } - const native = damlStockPlanDataToNative( + const stockPlan = damlStockPlanDataToNative( createArgument.plan_data as Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData ); - const ocf: OcfStockPlanOutput = { - object_type: 'STOCK_PLAN', - ...native, - }; - - return { stockPlan: ocf, contractId: params.contractId }; + return { stockPlan, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf.ts b/src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf.ts index 1b59b16d..638812ff 100644 --- a/src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf.ts @@ -7,7 +7,7 @@ import { readSingleContract } from '../shared/singleContractRead'; export interface GetStockPlanPoolAdjustmentAsOcfParams extends GetByContractIdParams {} export interface GetStockPlanPoolAdjustmentAsOcfResult { - event: OcfStockPlanPoolAdjustment & { object_type: 'TX_STOCK_PLAN_POOL_ADJUSTMENT' }; + event: OcfStockPlanPoolAdjustment; contractId: string; } @@ -29,6 +29,7 @@ export function damlStockPlanPoolAdjustmentDataToNative( const sharesReservedStr = typeof sharesReserved === 'number' ? sharesReserved.toString() : sharesReserved; return { + object_type: 'TX_STOCK_PLAN_POOL_ADJUSTMENT', id: data.id, date: data.date.split('T')[0], stock_plan_id: data.stock_plan_id, @@ -52,10 +53,5 @@ export async function getStockPlanPoolAdjustmentAsOcf( const contract = createArgument as StockPlanPoolAdjustmentCreateArgument; const native = damlStockPlanPoolAdjustmentDataToNative(contract.adjustment_data); - // Add object_type to create the full event type - const event = { - object_type: 'TX_STOCK_PLAN_POOL_ADJUSTMENT' as const, - ...native, - }; - return { event, contractId: params.contractId }; + return { event: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockPlanReturnToPool/damlToOcf.ts b/src/functions/OpenCapTable/stockPlanReturnToPool/damlToOcf.ts index 4dc8e5bb..95d25244 100644 --- a/src/functions/OpenCapTable/stockPlanReturnToPool/damlToOcf.ts +++ b/src/functions/OpenCapTable/stockPlanReturnToPool/damlToOcf.ts @@ -27,6 +27,7 @@ export interface DamlStockPlanReturnToPoolData { */ export function damlStockPlanReturnToPoolToNative(d: DamlStockPlanReturnToPoolData): OcfStockPlanReturnToPool { return { + object_type: 'TX_STOCK_PLAN_RETURN_TO_POOL', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts b/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts index 14f563a6..e940899d 100644 --- a/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts +++ b/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts @@ -20,6 +20,7 @@ export interface DamlStockReissuanceData { */ export function damlStockReissuanceToNative(d: DamlStockReissuanceData): OcfStockReissuance { return { + object_type: 'TX_STOCK_REISSUANCE', id: d.id, date: d.date.split('T')[0], security_id: d.security_id, diff --git a/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts b/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts index 6fea0013..809af595 100644 --- a/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts +++ b/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts @@ -28,6 +28,7 @@ export interface DamlStockRepurchaseData { */ export function damlStockRepurchaseToNative(d: DamlStockRepurchaseData): OcfStockRepurchase { return { + object_type: 'TX_STOCK_REPURCHASE', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/stockRetraction/damlToOcf.ts b/src/functions/OpenCapTable/stockRetraction/damlToOcf.ts index 8fd2aa44..cedb03b5 100644 --- a/src/functions/OpenCapTable/stockRetraction/damlToOcf.ts +++ b/src/functions/OpenCapTable/stockRetraction/damlToOcf.ts @@ -25,6 +25,7 @@ export interface DamlStockRetractionData { */ export function damlStockRetractionToNative(d: DamlStockRetractionData): OcfStockRetraction { return { + object_type: 'TX_STOCK_RETRACTION', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/stockTransfer/damlToOcf.ts b/src/functions/OpenCapTable/stockTransfer/damlToOcf.ts index 88ba1381..9922643c 100644 --- a/src/functions/OpenCapTable/stockTransfer/damlToOcf.ts +++ b/src/functions/OpenCapTable/stockTransfer/damlToOcf.ts @@ -18,5 +18,8 @@ export type DamlStockTransferData = DamlQuantityTransferData; * @returns The native OCF StockTransfer object */ export function damlStockTransferToNative(d: DamlStockTransferData): OcfStockTransfer { - return quantityTransferToNative(d); + return { + ...quantityTransferToNative(d), + object_type: 'TX_STOCK_TRANSFER', + }; } diff --git a/src/functions/OpenCapTable/valuation/damlToOcf.ts b/src/functions/OpenCapTable/valuation/damlToOcf.ts index 951e4db2..a4fce3b9 100644 --- a/src/functions/OpenCapTable/valuation/damlToOcf.ts +++ b/src/functions/OpenCapTable/valuation/damlToOcf.ts @@ -52,6 +52,7 @@ export interface DamlValuationData { */ export function damlValuationToNative(d: DamlValuationData): OcfValuation { return { + object_type: 'VALUATION', id: d.id, stock_class_id: d.stock_class_id, price_per_share: damlMonetaryToNative(d.price_per_share), diff --git a/src/functions/OpenCapTable/valuation/getValuationAsOcf.ts b/src/functions/OpenCapTable/valuation/getValuationAsOcf.ts index 8fcc0d15..bcc2f532 100644 --- a/src/functions/OpenCapTable/valuation/getValuationAsOcf.ts +++ b/src/functions/OpenCapTable/valuation/getValuationAsOcf.ts @@ -8,7 +8,7 @@ import { damlValuationToNative, type DamlValuationData } from './damlToOcf'; export interface GetValuationAsOcfParams extends GetByContractIdParams {} export interface GetValuationAsOcfResult { - valuation: OcfValuation & { object_type: 'VALUATION' }; + valuation: OcfValuation; contractId: string; } @@ -45,10 +45,7 @@ export async function getValuationAsOcf( const native = damlValuationToNative(createArgument.valuation_data); return { - valuation: { - object_type: 'VALUATION' as const, - ...native, - }, + valuation: native, contractId: params.contractId, }; } diff --git a/src/functions/OpenCapTable/vestingAcceleration/damlToOcf.ts b/src/functions/OpenCapTable/vestingAcceleration/damlToOcf.ts index c6e6fb29..770cb195 100644 --- a/src/functions/OpenCapTable/vestingAcceleration/damlToOcf.ts +++ b/src/functions/OpenCapTable/vestingAcceleration/damlToOcf.ts @@ -26,6 +26,7 @@ export interface DamlVestingAccelerationData { */ export function damlVestingAccelerationToNative(d: DamlVestingAccelerationData): OcfVestingAcceleration { return { + object_type: 'TX_VESTING_ACCELERATION', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/vestingAcceleration/getVestingAccelerationAsOcf.ts b/src/functions/OpenCapTable/vestingAcceleration/getVestingAccelerationAsOcf.ts index 4f33fea2..eec57f17 100644 --- a/src/functions/OpenCapTable/vestingAcceleration/getVestingAccelerationAsOcf.ts +++ b/src/functions/OpenCapTable/vestingAcceleration/getVestingAccelerationAsOcf.ts @@ -8,7 +8,7 @@ import { damlVestingAccelerationToNative, type DamlVestingAccelerationData } fro export type GetVestingAccelerationAsOcfParams = GetByContractIdParams; export interface GetVestingAccelerationAsOcfResult { - vestingAcceleration: OcfVestingAcceleration & { object_type: 'TX_VESTING_ACCELERATION' }; + vestingAcceleration: OcfVestingAcceleration; contractId: string; } @@ -59,10 +59,7 @@ export async function getVestingAccelerationAsOcf( const native = damlVestingAccelerationToNative(accelerationData); return { - vestingAcceleration: { - object_type: 'TX_VESTING_ACCELERATION' as const, - ...native, - }, + vestingAcceleration: native, contractId: params.contractId, }; } diff --git a/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts b/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts index 41e4c3bb..154bf30f 100644 --- a/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts +++ b/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts @@ -25,6 +25,7 @@ export interface DamlVestingEventData { */ export function damlVestingEventToNative(d: DamlVestingEventData): OcfVestingEvent { return { + object_type: 'TX_VESTING_EVENT', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/vestingEvent/getVestingEventAsOcf.ts b/src/functions/OpenCapTable/vestingEvent/getVestingEventAsOcf.ts index 640f0a3f..907f35a6 100644 --- a/src/functions/OpenCapTable/vestingEvent/getVestingEventAsOcf.ts +++ b/src/functions/OpenCapTable/vestingEvent/getVestingEventAsOcf.ts @@ -8,7 +8,7 @@ import { damlVestingEventToNative, type DamlVestingEventData } from './damlToOcf export type GetVestingEventAsOcfParams = GetByContractIdParams; export interface GetVestingEventAsOcfResult { - vestingEvent: OcfVestingEvent & { object_type: 'TX_VESTING_EVENT' }; + vestingEvent: OcfVestingEvent; contractId: string; } @@ -59,10 +59,7 @@ export async function getVestingEventAsOcf( const native = damlVestingEventToNative(vestingData); return { - vestingEvent: { - object_type: 'TX_VESTING_EVENT' as const, - ...native, - }, + vestingEvent: native, contractId: params.contractId, }; } diff --git a/src/functions/OpenCapTable/vestingStart/damlToOcf.ts b/src/functions/OpenCapTable/vestingStart/damlToOcf.ts index fa5e040e..44089b09 100644 --- a/src/functions/OpenCapTable/vestingStart/damlToOcf.ts +++ b/src/functions/OpenCapTable/vestingStart/damlToOcf.ts @@ -25,6 +25,7 @@ export interface DamlVestingStartData { */ export function damlVestingStartToNative(d: DamlVestingStartData): OcfVestingStart { return { + object_type: 'TX_VESTING_START', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf.ts b/src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf.ts index d30ac300..ea0b41cc 100644 --- a/src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf.ts +++ b/src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf.ts @@ -8,7 +8,7 @@ import { damlVestingStartToNative, type DamlVestingStartData } from './damlToOcf export type GetVestingStartAsOcfParams = GetByContractIdParams; export interface GetVestingStartAsOcfResult { - vestingStart: OcfVestingStart & { object_type: 'TX_VESTING_START' }; + vestingStart: OcfVestingStart; contractId: string; } @@ -59,10 +59,7 @@ export async function getVestingStartAsOcf( const native = damlVestingStartToNative(vestingData); return { - vestingStart: { - object_type: 'TX_VESTING_START' as const, - ...native, - }, + vestingStart: native, contractId: params.contractId, }; } diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index ea62f8f8..d854d1f0 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -4,6 +4,7 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../error import type { GetByContractIdParams } from '../../../types/common'; import type { AllocationType, + OcfVestingTerms, VestingCondition, VestingConditionPortion, VestingDayOfMonth, @@ -30,8 +31,8 @@ function damlAllocationTypeToNative(t: Fairmint.OpenCapTable.OCF.VestingTerms.Oc case 'OcfAllocationFractional': return 'FRACTIONAL'; default: { - const _exhaustiveCheck: never = t; - throw new OcpParseError(`Unknown DAML allocation type: ${String(t)}`, { + const exhaustiveCheck: never = t; + throw new OcpParseError(`Unknown DAML allocation type: ${String(exhaustiveCheck)}`, { source: 'vestingTerms.allocation_type', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -293,7 +294,7 @@ function damlVestingConditionToNative(c: Fairmint.OpenCapTable.OCF.VestingTerms. export function damlVestingTermsDataToNative( d: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData -): Omit { +): OcfVestingTerms { const dataWithId = d as unknown as { id?: string }; // Validate required fields - fail fast if missing @@ -321,6 +322,7 @@ export function damlVestingTermsDataToNative( : []; return { + object_type: 'VESTING_TERMS', id: dataWithId.id, name: d.name, description: d.description, @@ -330,20 +332,10 @@ export function damlVestingTermsDataToNative( }; } -interface OcfVestingTermsOutput { - object_type: 'VESTING_TERMS'; - id?: string; - name: string; - description: string; - allocation_type: string; - vesting_conditions: VestingCondition[]; - comments?: string[]; -} - export interface GetVestingTermsAsOcfParams extends GetByContractIdParams {} export interface GetVestingTermsAsOcfResult { - vestingTerms: OcfVestingTermsOutput; + vestingTerms: OcfVestingTerms; contractId: string; } @@ -379,12 +371,7 @@ export async function getVestingTermsAsOcf( }); } - const native = damlVestingTermsDataToNative(createArgument.vesting_terms_data); - - const ocf: OcfVestingTermsOutput = { - object_type: 'VESTING_TERMS', - ...native, - }; + const vestingTerms = damlVestingTermsDataToNative(createArgument.vesting_terms_data); - return { vestingTerms: ocf, contractId: params.contractId }; + return { vestingTerms, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/warrantAcceptance/warrantAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/warrantAcceptance/warrantAcceptanceDataToDaml.ts index 0ecc6844..20a7a3af 100644 --- a/src/functions/OpenCapTable/warrantAcceptance/warrantAcceptanceDataToDaml.ts +++ b/src/functions/OpenCapTable/warrantAcceptance/warrantAcceptanceDataToDaml.ts @@ -41,6 +41,7 @@ export function warrantAcceptanceDataToDaml(d: OcfWarrantAcceptance): Record): OcfWarr } return { + object_type: 'TX_WARRANT_EXERCISE', id: d.id as string, date: damlTimeToDateString(d.date as string), security_id: d.security_id as string, diff --git a/src/functions/OpenCapTable/warrantExercise/getWarrantExerciseAsOcf.ts b/src/functions/OpenCapTable/warrantExercise/getWarrantExerciseAsOcf.ts index a161b7c6..ad08ed1e 100644 --- a/src/functions/OpenCapTable/warrantExercise/getWarrantExerciseAsOcf.ts +++ b/src/functions/OpenCapTable/warrantExercise/getWarrantExerciseAsOcf.ts @@ -10,9 +10,7 @@ import { damlWarrantExerciseToNative } from './damlToOcf'; * OCF Warrant Exercise Event with object_type discriminator OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/exercise/WarrantExercise.schema.json */ -export interface OcfWarrantExerciseEvent extends OcfWarrantExercise { - object_type: 'TX_WARRANT_EXERCISE'; -} +export type OcfWarrantExerciseEvent = OcfWarrantExercise; export type GetWarrantExerciseAsOcfParams = GetByContractIdParams; @@ -43,12 +41,5 @@ export async function getWarrantExerciseAsOcf( const d = exerciseData; const native = damlWarrantExerciseToNative(d); - // Add object_type to create the full event type - // Note: quantity is typed as string | number in OcfWarrantExercise but normalizeNumericString always returns string - const event: OcfWarrantExerciseEvent = { - object_type: 'TX_WARRANT_EXERCISE', - ...native, - }; - - return { event, contractId: params.contractId }; + return { event: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index d0742931..156bb937 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -22,7 +22,7 @@ import { readSingleContract } from '../shared/singleContractRead'; export interface GetWarrantIssuanceAsOcfParams extends GetByContractIdParams {} export interface GetWarrantIssuanceAsOcfResult { - warrantIssuance: OcfWarrantIssuance & { object_type: 'TX_WARRANT_ISSUANCE' }; + warrantIssuance: OcfWarrantIssuance; contractId: string; } @@ -453,6 +453,7 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf } return { + object_type: 'TX_WARRANT_ISSUANCE', id: d.id, date: d.date.split('T')[0], security_id: d.security_id, @@ -524,11 +525,5 @@ export async function getWarrantIssuanceAsOcf( const d = arg.issuance_data as Record; const native = damlWarrantIssuanceDataToNative(d); - - const warrantIssuance = { - object_type: 'TX_WARRANT_ISSUANCE' as const, - ...native, - }; - - return { warrantIssuance, contractId: params.contractId }; + return { warrantIssuance: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/warrantRetraction/damlToOcf.ts b/src/functions/OpenCapTable/warrantRetraction/damlToOcf.ts index 310fa9b2..d0726b08 100644 --- a/src/functions/OpenCapTable/warrantRetraction/damlToOcf.ts +++ b/src/functions/OpenCapTable/warrantRetraction/damlToOcf.ts @@ -25,6 +25,7 @@ export interface DamlWarrantRetractionData { */ export function damlWarrantRetractionToNative(d: DamlWarrantRetractionData): OcfWarrantRetraction { return { + object_type: 'TX_WARRANT_RETRACTION', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/warrantTransfer/damlToOcf.ts b/src/functions/OpenCapTable/warrantTransfer/damlToOcf.ts index 9200d7f9..b8f35f33 100644 --- a/src/functions/OpenCapTable/warrantTransfer/damlToOcf.ts +++ b/src/functions/OpenCapTable/warrantTransfer/damlToOcf.ts @@ -18,5 +18,8 @@ export type DamlWarrantTransferData = DamlQuantityTransferData; * @returns The native OCF WarrantTransfer object */ export function damlWarrantTransferToNative(d: DamlWarrantTransferData): OcfWarrantTransfer { - return quantityTransferToNative(d); + return { + ...quantityTransferToNative(d), + object_type: 'TX_WARRANT_TRANSFER', + }; } diff --git a/src/types/native.ts b/src/types/native.ts index 098916e3..edce2b95 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -504,11 +504,76 @@ export interface StockClassConversionRight { expires_at?: string; } +/** Canonical OCF object discriminators supported by this SDK. */ +export type OcfObjectType = + | 'ISSUER' + | 'STAKEHOLDER' + | 'STOCK_CLASS' + | 'STOCK_LEGEND_TEMPLATE' + | 'STOCK_PLAN' + | 'VALUATION' + | 'VESTING_TERMS' + | 'FINANCING' + | 'DOCUMENT' + | 'CE_STAKEHOLDER_RELATIONSHIP' + | 'CE_STAKEHOLDER_STATUS' + | 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT' + | 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT' + | 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT' + | 'TX_STOCK_CLASS_SPLIT' + | 'TX_STOCK_PLAN_POOL_ADJUSTMENT' + | 'TX_STOCK_PLAN_RETURN_TO_POOL' + | 'TX_CONVERTIBLE_ACCEPTANCE' + | 'TX_CONVERTIBLE_CANCELLATION' + | 'TX_CONVERTIBLE_CONVERSION' + | 'TX_CONVERTIBLE_ISSUANCE' + | 'TX_CONVERTIBLE_RETRACTION' + | 'TX_CONVERTIBLE_TRANSFER' + | 'TX_EQUITY_COMPENSATION_ACCEPTANCE' + | 'TX_EQUITY_COMPENSATION_CANCELLATION' + | 'TX_EQUITY_COMPENSATION_EXERCISE' + | 'TX_EQUITY_COMPENSATION_ISSUANCE' + | 'TX_EQUITY_COMPENSATION_RELEASE' + | 'TX_EQUITY_COMPENSATION_RETRACTION' + | 'TX_EQUITY_COMPENSATION_TRANSFER' + | 'TX_EQUITY_COMPENSATION_REPRICING' + | 'TX_PLAN_SECURITY_ACCEPTANCE' + | 'TX_PLAN_SECURITY_CANCELLATION' + | 'TX_PLAN_SECURITY_EXERCISE' + | 'TX_PLAN_SECURITY_ISSUANCE' + | 'TX_PLAN_SECURITY_RELEASE' + | 'TX_PLAN_SECURITY_RETRACTION' + | 'TX_PLAN_SECURITY_TRANSFER' + | 'TX_STOCK_ACCEPTANCE' + | 'TX_STOCK_CANCELLATION' + | 'TX_STOCK_CONVERSION' + | 'TX_STOCK_ISSUANCE' + | 'TX_STOCK_REISSUANCE' + | 'TX_STOCK_CONSOLIDATION' + | 'TX_STOCK_REPURCHASE' + | 'TX_STOCK_RETRACTION' + | 'TX_STOCK_TRANSFER' + | 'TX_WARRANT_ACCEPTANCE' + | 'TX_WARRANT_CANCELLATION' + | 'TX_WARRANT_EXERCISE' + | 'TX_WARRANT_ISSUANCE' + | 'TX_WARRANT_RETRACTION' + | 'TX_WARRANT_TRANSFER' + | 'TX_VESTING_ACCELERATION' + | 'TX_VESTING_START' + | 'TX_VESTING_EVENT'; + +/** Shared discriminator contract for every top-level OCF object. */ +export interface OcfObjectBase { + /** Canonical OCF object type discriminator. */ + readonly object_type: TObjectType; +} + /** * Object - Issuer Object describing the issuer of the cap table (the company whose cap table this is). OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/Issuer.schema.json */ -export interface OcfIssuer { +export interface OcfIssuer extends OcfObjectBase<'ISSUER'> { /** Identifier for the object */ id: string; /** Legal name of the issuer */ @@ -541,7 +606,7 @@ export interface OcfIssuer { * Object - Stock Class Object describing a class of stock issued by the issuer OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/StockClass.schema.json */ -export interface OcfStockClass { +export interface OcfStockClass extends OcfObjectBase<'STOCK_CLASS'> { /** Identifier for the object */ id: string; /** The type of this stock class (e.g. Preferred or Common) */ @@ -620,7 +685,7 @@ export interface ContactInfoWithoutName { * Object - Stakeholder Object describing a stakeholder in the issuer's cap table OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/Stakeholder.schema.json */ -export interface OcfStakeholder { +export interface OcfStakeholder extends OcfObjectBase<'STAKEHOLDER'> { /** Identifier for the object */ id: string; /** Stakeholder's name */ @@ -654,7 +719,7 @@ export interface OcfStakeholder { * Object - Stock Legend Template Object describing a stock legend template OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/StockLegendTemplate.schema.json */ -export interface OcfStockLegendTemplate { +export interface OcfStockLegendTemplate extends OcfObjectBase<'STOCK_LEGEND_TEMPLATE'> { /** Identifier for the object */ id: string; /** Name for the stock legend template */ @@ -671,63 +736,7 @@ export interface OcfStockLegendTemplate { */ export interface OcfObjectReference { /** Type of the referenced object */ - object_type: - | 'ISSUER' - | 'STAKEHOLDER' - | 'STOCK_CLASS' - | 'STOCK_LEGEND_TEMPLATE' - | 'STOCK_PLAN' - | 'VALUATION' - | 'VESTING_TERMS' - | 'FINANCING' - | 'DOCUMENT' - | 'CE_STAKEHOLDER_RELATIONSHIP' - | 'CE_STAKEHOLDER_STATUS' - | 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT' - | 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT' - | 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT' - | 'TX_STOCK_CLASS_SPLIT' - | 'TX_STOCK_PLAN_POOL_ADJUSTMENT' - | 'TX_STOCK_PLAN_RETURN_TO_POOL' - | 'TX_CONVERTIBLE_ACCEPTANCE' - | 'TX_CONVERTIBLE_CANCELLATION' - | 'TX_CONVERTIBLE_CONVERSION' - | 'TX_CONVERTIBLE_ISSUANCE' - | 'TX_CONVERTIBLE_RETRACTION' - | 'TX_CONVERTIBLE_TRANSFER' - | 'TX_EQUITY_COMPENSATION_ACCEPTANCE' - | 'TX_EQUITY_COMPENSATION_CANCELLATION' - | 'TX_EQUITY_COMPENSATION_EXERCISE' - | 'TX_EQUITY_COMPENSATION_ISSUANCE' - | 'TX_EQUITY_COMPENSATION_RELEASE' - | 'TX_EQUITY_COMPENSATION_RETRACTION' - | 'TX_EQUITY_COMPENSATION_TRANSFER' - | 'TX_EQUITY_COMPENSATION_REPRICING' - | 'TX_PLAN_SECURITY_ACCEPTANCE' - | 'TX_PLAN_SECURITY_CANCELLATION' - | 'TX_PLAN_SECURITY_EXERCISE' - | 'TX_PLAN_SECURITY_ISSUANCE' - | 'TX_PLAN_SECURITY_RELEASE' - | 'TX_PLAN_SECURITY_RETRACTION' - | 'TX_PLAN_SECURITY_TRANSFER' - | 'TX_STOCK_ACCEPTANCE' - | 'TX_STOCK_CANCELLATION' - | 'TX_STOCK_CONVERSION' - | 'TX_STOCK_ISSUANCE' - | 'TX_STOCK_REISSUANCE' - | 'TX_STOCK_CONSOLIDATION' - | 'TX_STOCK_REPURCHASE' - | 'TX_STOCK_RETRACTION' - | 'TX_STOCK_TRANSFER' - | 'TX_WARRANT_ACCEPTANCE' - | 'TX_WARRANT_CANCELLATION' - | 'TX_WARRANT_EXERCISE' - | 'TX_WARRANT_ISSUANCE' - | 'TX_WARRANT_RETRACTION' - | 'TX_WARRANT_TRANSFER' - | 'TX_VESTING_ACCELERATION' - | 'TX_VESTING_START' - | 'TX_VESTING_EVENT'; + object_type: OcfObjectType; /** Identifier of the referenced object */ object_id: string; } @@ -736,7 +745,7 @@ export interface OcfObjectReference { * Object - Document Object describing a document OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/Document.schema.json */ -export interface OcfDocument { +export interface OcfDocument extends OcfObjectBase<'DOCUMENT'> { /** Identifier for the object */ id: string; /** Relative file path to the document within the OCF bundle */ @@ -761,7 +770,7 @@ export type ValuationType = '409A'; * Object - Valuation Object describing a valuation used in the cap table OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/Valuation.schema.json */ -export interface OcfValuation { +export interface OcfValuation extends OcfObjectBase<'VALUATION'> { /** Identifier for the object */ id: string; /** Identifier of the stock class for this valuation */ @@ -807,7 +816,7 @@ export interface VestingSimple { amount: string; } -export interface OcfStockIssuance { +export interface OcfStockIssuance extends OcfObjectBase<'TX_STOCK_ISSUANCE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -972,7 +981,7 @@ export interface VestingCondition { * Object - Vesting Terms Object describing the terms under which a security vests OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/VestingTerms.schema.json */ -export interface OcfVestingTerms { +export interface OcfVestingTerms extends OcfObjectBase<'VESTING_TERMS'> { id: string; /** Concise name for the vesting schedule */ name: string; @@ -994,7 +1003,7 @@ export type StockPlanCancellationBehavior = | 'HOLD_AS_CAPITAL_STOCK' | 'DEFINED_PER_PLAN_SECURITY'; -export interface OcfStockPlan { +export interface OcfStockPlan extends OcfObjectBase<'STOCK_PLAN'> { id: string; /** Human-friendly name of the plan */ plan_name: string; @@ -1050,7 +1059,7 @@ export interface TerminationWindow { period_type: PeriodType; } -export interface OcfEquityCompensationIssuance { +export interface OcfEquityCompensationIssuance extends OcfObjectBase<'TX_EQUITY_COMPENSATION_ISSUANCE'> { id: string; date: string; security_id: string; @@ -1107,7 +1116,7 @@ export type QuantitySourceType = * transaction by the issuer and held by a stakeholder OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/issuance/ConvertibleIssuance.schema.json */ -export interface OcfConvertibleIssuance { +export interface OcfConvertibleIssuance extends OcfObjectBase<'TX_CONVERTIBLE_ISSUANCE'> { id: string; date: string; security_id: string; @@ -1136,7 +1145,7 @@ export interface OcfConvertibleIssuance { * and held by a stakeholder OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/issuance/WarrantIssuance.schema.json */ -export interface OcfWarrantIssuance { +export interface OcfWarrantIssuance extends OcfObjectBase<'TX_WARRANT_ISSUANCE'> { id: string; date: string; security_id: string; @@ -1174,7 +1183,7 @@ export interface OcfWarrantIssuance { comments?: string[]; } -export interface OcfStockCancellation { +export interface OcfStockCancellation extends OcfObjectBase<'TX_STOCK_CANCELLATION'> { id: string; date: string; security_id: string; @@ -1188,7 +1197,7 @@ export interface OcfStockCancellation { * Object - Warrant Cancellation Transaction Object describing a warrant cancellation transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/cancellation/WarrantCancellation.schema.json */ -export interface OcfWarrantCancellation { +export interface OcfWarrantCancellation extends OcfObjectBase<'TX_WARRANT_CANCELLATION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1209,7 +1218,7 @@ export interface OcfWarrantCancellation { * Object - Convertible Cancellation Transaction Object describing a convertible cancellation transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/cancellation/ConvertibleCancellation.schema.json */ -export interface OcfConvertibleCancellation { +export interface OcfConvertibleCancellation extends OcfObjectBase<'TX_CONVERTIBLE_CANCELLATION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1231,7 +1240,7 @@ export interface OcfConvertibleCancellation { * transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/cancellation/EquityCompensationCancellation.schema.json */ -export interface OcfEquityCompensationCancellation { +export interface OcfEquityCompensationCancellation extends OcfObjectBase<'TX_EQUITY_COMPENSATION_CANCELLATION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1248,7 +1257,7 @@ export interface OcfEquityCompensationCancellation { comments?: string[]; } -export interface OcfIssuerAuthorizedSharesAdjustment { +export interface OcfIssuerAuthorizedSharesAdjustment extends OcfObjectBase<'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT'> { id: string; date: string; issuer_id: string; @@ -1258,7 +1267,7 @@ export interface OcfIssuerAuthorizedSharesAdjustment { comments?: string[]; } -export interface OcfStockClassAuthorizedSharesAdjustment { +export interface OcfStockClassAuthorizedSharesAdjustment extends OcfObjectBase<'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT'> { id: string; date: string; stock_class_id: string; @@ -1268,7 +1277,7 @@ export interface OcfStockClassAuthorizedSharesAdjustment { comments?: string[]; } -export interface OcfStockPlanPoolAdjustment { +export interface OcfStockPlanPoolAdjustment extends OcfObjectBase<'TX_STOCK_PLAN_POOL_ADJUSTMENT'> { id: string; date: string; stock_plan_id: string; @@ -1278,7 +1287,7 @@ export interface OcfStockPlanPoolAdjustment { comments?: string[]; } -export interface OcfEquityCompensationExercise { +export interface OcfEquityCompensationExercise extends OcfObjectBase<'TX_EQUITY_COMPENSATION_EXERCISE'> { id: string; date: string; security_id: string; @@ -1292,7 +1301,7 @@ export interface OcfEquityCompensationExercise { * Object - Stock Transfer Transaction Object describing a transfer or secondary sale of a stock security OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/transfer/StockTransfer.schema.json */ -export interface OcfStockTransfer { +export interface OcfStockTransfer extends OcfObjectBase<'TX_STOCK_TRANSFER'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1315,7 +1324,7 @@ export interface OcfStockTransfer { * Object - Stock Repurchase Transaction Object describing a stock repurchase transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/repurchase/StockRepurchase.schema.json */ -export interface OcfStockRepurchase { +export interface OcfStockRepurchase extends OcfObjectBase<'TX_STOCK_REPURCHASE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1340,7 +1349,7 @@ export interface OcfStockRepurchase { * Object - Warrant Transfer Transaction Object describing a warrant transfer transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/transfer/WarrantTransfer.schema.json */ -export interface OcfWarrantTransfer { +export interface OcfWarrantTransfer extends OcfObjectBase<'TX_WARRANT_TRANSFER'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1363,7 +1372,7 @@ export interface OcfWarrantTransfer { * Object - Convertible Transfer Transaction Object describing a convertible transfer transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/transfer/ConvertibleTransfer.schema.json */ -export interface OcfConvertibleTransfer { +export interface OcfConvertibleTransfer extends OcfObjectBase<'TX_CONVERTIBLE_TRANSFER'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1386,7 +1395,7 @@ export interface OcfConvertibleTransfer { * Object - Equity Compensation Transfer Transaction Object describing an equity compensation transfer transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/transfer/EquityCompensationTransfer.schema.json */ -export interface OcfEquityCompensationTransfer { +export interface OcfEquityCompensationTransfer extends OcfObjectBase<'TX_EQUITY_COMPENSATION_TRANSFER'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1411,7 +1420,7 @@ export interface OcfEquityCompensationTransfer { * Object - Stock Acceptance Transaction Object describing a stock acceptance transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/acceptance/StockAcceptance.schema.json */ -export interface OcfStockAcceptance { +export interface OcfStockAcceptance extends OcfObjectBase<'TX_STOCK_ACCEPTANCE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1426,7 +1435,7 @@ export interface OcfStockAcceptance { * Object - Warrant Acceptance Transaction Object describing a warrant acceptance transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/acceptance/WarrantAcceptance.schema.json */ -export interface OcfWarrantAcceptance { +export interface OcfWarrantAcceptance extends OcfObjectBase<'TX_WARRANT_ACCEPTANCE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1441,7 +1450,7 @@ export interface OcfWarrantAcceptance { * Object - Convertible Acceptance Transaction Object describing a convertible acceptance transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/acceptance/ConvertibleAcceptance.schema.json */ -export interface OcfConvertibleAcceptance { +export interface OcfConvertibleAcceptance extends OcfObjectBase<'TX_CONVERTIBLE_ACCEPTANCE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1457,7 +1466,7 @@ export interface OcfConvertibleAcceptance { * OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/acceptance/EquityCompensationAcceptance.schema.json */ -export interface OcfEquityCompensationAcceptance { +export interface OcfEquityCompensationAcceptance extends OcfObjectBase<'TX_EQUITY_COMPENSATION_ACCEPTANCE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1474,7 +1483,7 @@ export interface OcfEquityCompensationAcceptance { * Object - Stock Retraction Transaction Object describing a stock retraction transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/retraction/StockRetraction.schema.json */ -export interface OcfStockRetraction { +export interface OcfStockRetraction extends OcfObjectBase<'TX_STOCK_RETRACTION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1491,7 +1500,7 @@ export interface OcfStockRetraction { * Object - Warrant Retraction Transaction Object describing a warrant retraction transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/retraction/WarrantRetraction.schema.json */ -export interface OcfWarrantRetraction { +export interface OcfWarrantRetraction extends OcfObjectBase<'TX_WARRANT_RETRACTION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1508,7 +1517,7 @@ export interface OcfWarrantRetraction { * Object - Convertible Retraction Transaction Object describing a convertible retraction transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/retraction/ConvertibleRetraction.schema.json */ -export interface OcfConvertibleRetraction { +export interface OcfConvertibleRetraction extends OcfObjectBase<'TX_CONVERTIBLE_RETRACTION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1526,7 +1535,7 @@ export interface OcfConvertibleRetraction { * OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/retraction/EquityCompensationRetraction.schema.json */ -export interface OcfEquityCompensationRetraction { +export interface OcfEquityCompensationRetraction extends OcfObjectBase<'TX_EQUITY_COMPENSATION_RETRACTION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1545,7 +1554,7 @@ export interface OcfEquityCompensationRetraction { * Object - Warrant Exercise Transaction Object describing a warrant exercise transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/exercise/WarrantExercise.schema.json */ -export interface OcfWarrantExercise { +export interface OcfWarrantExercise extends OcfObjectBase<'TX_WARRANT_EXERCISE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1572,7 +1581,7 @@ export interface OcfWarrantExercise { * Object - Stock Conversion Transaction Object describing a stock conversion transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/conversion/StockConversion.schema.json */ -export interface OcfStockConversion { +export interface OcfStockConversion extends OcfObjectBase<'TX_STOCK_CONVERSION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1595,7 +1604,7 @@ export interface OcfStockConversion { * Object - Convertible Conversion Transaction Object describing a convertible conversion transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/conversion/ConvertibleConversion.schema.json */ -export interface OcfConvertibleConversion { +export interface OcfConvertibleConversion extends OcfObjectBase<'TX_CONVERTIBLE_CONVERSION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1624,7 +1633,7 @@ export interface OcfConvertibleConversion { * Object - Equity Compensation Release Transaction Object describing an equity compensation release transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/release/EquityCompensationRelease.schema.json */ -export interface OcfEquityCompensationRelease { +export interface OcfEquityCompensationRelease extends OcfObjectBase<'TX_EQUITY_COMPENSATION_RELEASE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1653,7 +1662,7 @@ export interface OcfEquityCompensationRelease { * Object - Vesting Start Transaction Object describing the start of vesting for a security OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/vesting/VestingStart.schema.json */ -export interface OcfVestingStart { +export interface OcfVestingStart extends OcfObjectBase<'TX_VESTING_START'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1670,7 +1679,7 @@ export interface OcfVestingStart { * Object - Vesting Event Transaction Object describing a vesting event for a security OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/vesting/VestingEvent.schema.json */ -export interface OcfVestingEvent { +export interface OcfVestingEvent extends OcfObjectBase<'TX_VESTING_EVENT'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1687,7 +1696,7 @@ export interface OcfVestingEvent { * Object - Vesting Acceleration Transaction Object describing a vesting acceleration for a security OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/vesting/VestingAcceleration.schema.json */ -export interface OcfVestingAcceleration { +export interface OcfVestingAcceleration extends OcfObjectBase<'TX_VESTING_ACCELERATION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1708,7 +1717,7 @@ export interface OcfVestingAcceleration { * Object - Stock Class Split Transaction Object describing a stock class split transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/adjustment/StockClassSplit.schema.json */ -export interface OcfStockClassSplit { +export interface OcfStockClassSplit extends OcfObjectBase<'TX_STOCK_CLASS_SPLIT'> { /** * At least one split-ratio representation must be provided: * - canonical `split_ratio`, or @@ -1738,7 +1747,7 @@ export interface OcfStockClassSplit { * Object - Stock Class Conversion Ratio Adjustment Transaction Object describing a conversion ratio adjustment OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/adjustment/StockClassConversionRatioAdjustment.schema.json */ -export interface OcfStockClassConversionRatioAdjustment { +export interface OcfStockClassConversionRatioAdjustment extends OcfObjectBase<'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1768,7 +1777,7 @@ export interface OcfStockClassConversionRatioAdjustment { * Object - Stock Plan Return To Pool Transaction Object describing shares returned to the stock plan pool OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/adjustment/StockPlanReturnToPool.schema.json */ -export interface OcfStockPlanReturnToPool { +export interface OcfStockPlanReturnToPool extends OcfObjectBase<'TX_STOCK_PLAN_RETURN_TO_POOL'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1791,7 +1800,7 @@ export interface OcfStockPlanReturnToPool { * Object - Stock Reissuance Transaction Object describing a stock reissuance transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/reissuance/StockReissuance.schema.json */ -export interface OcfStockReissuance { +export interface OcfStockReissuance extends OcfObjectBase<'TX_STOCK_REISSUANCE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1812,7 +1821,7 @@ export interface OcfStockReissuance { * Object - Stock Consolidation Transaction Object describing a stock consolidation transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/consolidation/StockConsolidation.schema.json */ -export interface OcfStockConsolidation { +export interface OcfStockConsolidation extends OcfObjectBase<'TX_STOCK_CONSOLIDATION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1836,7 +1845,7 @@ export interface OcfStockConsolidation { * OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/repricing/EquityCompensationRepricing.schema.json */ -export interface OcfEquityCompensationRepricing { +export interface OcfEquityCompensationRepricing extends OcfObjectBase<'TX_EQUITY_COMPENSATION_REPRICING'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1857,7 +1866,7 @@ export interface OcfEquityCompensationRepricing { * Object - Plan Security Issuance Transaction Object describing a plan security issuance transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/issuance/PlanSecurityIssuance.schema.json */ -export interface OcfPlanSecurityIssuance { +export interface OcfPlanSecurityIssuance extends OcfObjectBase<'TX_PLAN_SECURITY_ISSUANCE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1910,7 +1919,7 @@ export interface OcfPlanSecurityIssuance { * Object - Plan Security Exercise Transaction Object describing a plan security exercise transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/exercise/PlanSecurityExercise.schema.json */ -export interface OcfPlanSecurityExercise { +export interface OcfPlanSecurityExercise extends OcfObjectBase<'TX_PLAN_SECURITY_EXERCISE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1933,7 +1942,7 @@ export interface OcfPlanSecurityExercise { * Object - Plan Security Cancellation Transaction Object describing a plan security cancellation transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/cancellation/PlanSecurityCancellation.schema.json */ -export interface OcfPlanSecurityCancellation { +export interface OcfPlanSecurityCancellation extends OcfObjectBase<'TX_PLAN_SECURITY_CANCELLATION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1954,7 +1963,7 @@ export interface OcfPlanSecurityCancellation { * Object - Plan Security Acceptance Transaction Object describing a plan security acceptance transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/acceptance/PlanSecurityAcceptance.schema.json */ -export interface OcfPlanSecurityAcceptance { +export interface OcfPlanSecurityAcceptance extends OcfObjectBase<'TX_PLAN_SECURITY_ACCEPTANCE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1969,7 +1978,7 @@ export interface OcfPlanSecurityAcceptance { * Object - Plan Security Release Transaction Object describing a plan security release transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/release/PlanSecurityRelease.schema.json */ -export interface OcfPlanSecurityRelease { +export interface OcfPlanSecurityRelease extends OcfObjectBase<'TX_PLAN_SECURITY_RELEASE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1996,7 +2005,7 @@ export interface OcfPlanSecurityRelease { * Object - Plan Security Retraction Transaction Object describing a plan security retraction transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/retraction/PlanSecurityRetraction.schema.json */ -export interface OcfPlanSecurityRetraction { +export interface OcfPlanSecurityRetraction extends OcfObjectBase<'TX_PLAN_SECURITY_RETRACTION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -2013,7 +2022,7 @@ export interface OcfPlanSecurityRetraction { * Object - Plan Security Transfer Transaction Object describing a plan security transfer transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/transfer/PlanSecurityTransfer.schema.json */ -export interface OcfPlanSecurityTransfer { +export interface OcfPlanSecurityTransfer extends OcfObjectBase<'TX_PLAN_SECURITY_TRANSFER'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -2041,7 +2050,7 @@ export interface OcfPlanSecurityTransfer { * Note: The OCF schema has additionalProperties: false. The only allowed fields * are id, name, issuance_ids, date, and comments. */ -export interface OcfFinancing { +export interface OcfFinancing extends OcfObjectBase<'FINANCING'> { /** Identifier for the object */ id: string; /** Name for the financing */ @@ -2095,9 +2104,7 @@ export type StakeholderStatus = * issuer OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/change_event/StakeholderRelationshipChangeEvent.schema.json */ -export interface OcfStakeholderRelationshipChangeEvent { - /** OCF object type discriminator */ - object_type?: 'CE_STAKEHOLDER_RELATIONSHIP' | 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT'; +export interface OcfStakeholderRelationshipChangeEvent extends OcfObjectBase<'CE_STAKEHOLDER_RELATIONSHIP'> { /** Identifier for the object */ id: string; /** Date on which the event occurred */ @@ -2118,9 +2125,7 @@ export interface OcfStakeholderRelationshipChangeEvent { * Object - Stakeholder Status Change Event Object describing a change in a stakeholder's status with the issuer OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/change_event/StakeholderStatusChangeEvent.schema.json */ -export interface OcfStakeholderStatusChangeEvent { - /** OCF object type discriminator */ - object_type?: 'CE_STAKEHOLDER_STATUS' | 'TX_STAKEHOLDER_STATUS_CHANGE_EVENT'; +export interface OcfStakeholderStatusChangeEvent extends OcfObjectBase<'CE_STAKEHOLDER_STATUS'> { /** Identifier for the object */ id: string; /** Date on which the event occurred */ diff --git a/src/types/output.ts b/src/types/output.ts index c6909b76..20f75fb4 100644 --- a/src/types/output.ts +++ b/src/types/output.ts @@ -1,9 +1,9 @@ /** - * OCF output types with `object_type` discriminant. + * Named aliases for canonical OCF output objects. * - * These types extend the base OCF types from `native.ts` with a readonly `object_type` - * field, enabling TypeScript discriminated unions. All `get()` operations return - * these output types. + * Every top-level OCF type in `native.ts` already carries its required readonly + * `object_type` discriminator. These aliases preserve descriptive result names and + * assemble the public `OcfObject` union without maintaining a second object model. * * @example Discriminated union pattern * ```typescript @@ -266,6 +266,9 @@ export type OcfStockPlanReturnToPoolOutput = WithObjectType; @@ -400,14 +403,6 @@ export type OcfObject = | OcfStockClassSplitOutput | OcfStockPlanPoolAdjustmentOutput | OcfStockPlanReturnToPoolOutput - // Plan Security - | OcfPlanSecurityIssuanceOutput - | OcfPlanSecurityExerciseOutput - | OcfPlanSecurityCancellationOutput - | OcfPlanSecurityAcceptanceOutput - | OcfPlanSecurityReleaseOutput - | OcfPlanSecurityRetractionOutput - | OcfPlanSecurityTransferOutput // Other | OcfStockRepurchaseOutput | OcfStockConsolidationOutput diff --git a/src/utils/cantonOcfExtractor.ts b/src/utils/cantonOcfExtractor.ts index 323f2f7a..e58ed514 100644 --- a/src/utils/cantonOcfExtractor.ts +++ b/src/utils/cantonOcfExtractor.ts @@ -13,7 +13,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpErrorCodes } from '../errors/codes'; import { OcpValidationError } from '../errors/OcpValidationError'; -import { ENTITY_OBJECT_TYPE_MAP, type OcfEntityType } from '../functions/OpenCapTable/capTable/batchTypes'; +import type { OcfEntityType } from '../functions/OpenCapTable/capTable/batchTypes'; import type { SupportedOcfReadType } from '../functions/OpenCapTable/capTable/damlToOcf'; import { getEntityAsOcf } from '../functions/OpenCapTable/capTable/damlToOcf'; import type { CapTableState } from '../functions/OpenCapTable/capTable/getCapTableState'; @@ -514,7 +514,7 @@ export async function extractCantonOcfManifest( result.vestingTerms.push(vestingTerms as unknown as Record); } else if (entityType === 'stockIssuance') { const { stockIssuance } = await getStockIssuanceAsOcf(client, { contractId, ...readScopeOpts }); - result.transactions.push(stockIssuance); + result.transactions.push(stockIssuance as unknown as Record); } else if (entityType === 'convertibleIssuance') { const { event } = await getConvertibleIssuanceAsOcf(client, { contractId, ...readScopeOpts }); result.transactions.push(event as unknown as Record); @@ -558,25 +558,14 @@ export async function extractCantonOcfManifest( // Handle remaining transaction types with the generic dispatcher const supportedType = entityType as SupportedOcfReadType; const { data } = await getEntityAsOcf(client, supportedType, contractId, readScopeOpts); - const txData = data as unknown as Record; - - // Ensure object_type is present for correct sorting - // Generic converters may not include it, so we add it from our mapping - const existingObjectType = typeof txData.object_type === 'string' ? txData.object_type : null; - const mappedObjectType = ENTITY_OBJECT_TYPE_MAP[entityType]; - - if (!existingObjectType && !mappedObjectType) { + const txData: Record = { ...data }; + if (typeof txData.object_type !== 'string') { throw new OcpValidationError( 'object_type', - `Missing object_type mapping for transaction entity type: ${entityType}`, + `Converter returned a transaction without object_type: ${entityType}`, { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); } - - if (!existingObjectType && mappedObjectType) { - txData.object_type = mappedObjectType; - } - result.transactions.push(txData); } // Unsupported types are silently skipped diff --git a/src/utils/ocfHelpers.ts b/src/utils/ocfHelpers.ts index 5a700f49..4a8fbe3b 100644 --- a/src/utils/ocfHelpers.ts +++ b/src/utils/ocfHelpers.ts @@ -9,7 +9,13 @@ * @module ocfHelpers */ -import { getAllOcfTypes, getOcfMetadata, isValidOcfType, OCF_METADATA, type OcfObjectType } from './ocfMetadata'; +import { + getAllOcfTypes, + getOcfMetadata, + isValidOcfType, + OCF_METADATA, + type OcfMetadataObjectType, +} from './ocfMetadata'; /** * Get the data field name used in DAML contracts for a given OCF type. @@ -20,7 +26,7 @@ import { getAllOcfTypes, getOcfMetadata, isValidOcfType, OCF_METADATA, type OcfO * @param type - The OCF object type * @returns The data field name (e.g., 'stakeholder_data', 'issuance_data') */ -export function getOcfDataFieldName(type: OcfObjectType): string { +export function getOcfDataFieldName(type: OcfMetadataObjectType): string { const metadata = getOcfMetadata(type); // The first element of ocfIdPath is the data field name return metadata.ocfIdPath[0]; @@ -35,7 +41,7 @@ export function getOcfDataFieldName(type: OcfObjectType): string { * @param createArgs - The contract's create arguments from a transaction tree * @returns The OCF ID string, or undefined if not found */ -export function extractOcfIdFromCreateArgs(type: OcfObjectType, createArgs: unknown): string | undefined { +export function extractOcfIdFromCreateArgs(type: OcfMetadataObjectType, createArgs: unknown): string | undefined { if (!createArgs || typeof createArgs !== 'object') { return undefined; } @@ -62,9 +68,9 @@ export function extractOcfIdFromCreateArgs(type: OcfObjectType, createArgs: unkn * @param count - Number of items (for pluralization) * @returns Human-readable label (e.g., '1 Stakeholder', '5 Stakeholders') */ -export function getOcfTypeLabel(type: OcfObjectType, count: number): string { +export function getOcfTypeLabel(type: OcfMetadataObjectType, count: number): string { // Convert type to title case and handle special cases - const labelMap: Partial> = { + const labelMap: Partial> = { STOCK_CLASS: { singular: 'Stock Class', plural: 'Stock Classes' }, STAKEHOLDER: { singular: 'Stakeholder', plural: 'Stakeholders' }, STOCK_PLAN: { singular: 'Stock Plan', plural: 'Stock Plans' }, @@ -117,4 +123,4 @@ export function getOcfTypeLabel(type: OcfObjectType, count: number): string { } // Re-export types and functions from ocfMetadata for convenience -export { getAllOcfTypes, getOcfMetadata, isValidOcfType, OCF_METADATA, type OcfObjectType }; +export { getAllOcfTypes, getOcfMetadata, isValidOcfType, OCF_METADATA, type OcfMetadataObjectType }; diff --git a/src/utils/ocfMetadata.ts b/src/utils/ocfMetadata.ts index 866cc3b4..31136019 100644 --- a/src/utils/ocfMetadata.ts +++ b/src/utils/ocfMetadata.ts @@ -1,7 +1,7 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -/** OCF object types supported by the SDK */ -export type OcfObjectType = +/** OCF object types covered by the legacy template-metadata registry. */ +export type OcfMetadataObjectType = | 'STOCK_CLASS' | 'STAKEHOLDER' | 'STOCK_PLAN' @@ -30,7 +30,7 @@ interface OcfTypeMetadata { } /** Central registry of OCF type metadata Maps each OCF object type to its DAML template ID and OCF ID extraction path */ -export const OCF_METADATA: Record = { +export const OCF_METADATA: Record = { STOCK_CLASS: { templateId: Fairmint.OpenCapTable.OCF.StockClass.StockClass.templateId, ocfIdPath: ['stock_class_data', 'id'], @@ -107,16 +107,16 @@ export const OCF_METADATA: Record = { }; /** Get metadata for a specific OCF object type */ -export function getOcfMetadata(type: OcfObjectType): OcfTypeMetadata { +export function getOcfMetadata(type: OcfMetadataObjectType): OcfTypeMetadata { return OCF_METADATA[type]; } /** Get all supported OCF object types */ -export function getAllOcfTypes(): OcfObjectType[] { - return Object.keys(OCF_METADATA) as OcfObjectType[]; +export function getAllOcfTypes(): OcfMetadataObjectType[] { + return Object.keys(OCF_METADATA) as OcfMetadataObjectType[]; } /** Check if a given string is a valid OCF object type */ -export function isValidOcfType(type: string): type is OcfObjectType { +export function isValidOcfType(type: string): type is OcfMetadataObjectType { return type in OCF_METADATA; } diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index 3bcec462..b224986d 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -396,7 +396,8 @@ export function parseOcfObject(input: unknown): Record { /** * Parse and validate OCF input for a specific SDK entity type. * - * If object_type is missing, the canonical object_type for the entity is injected before validation. + * Typed SDK inputs must provide the exact canonical object_type for the entity. + * Legacy aliases remain supported only by the raw {@link parseOcfObject} ingestion boundary. */ export function parseOcfEntityInput(entityType: T, input: unknown): OcfDataTypeFor { if (!isRecord(input)) { @@ -409,24 +410,38 @@ export function parseOcfEntityInput(entityType: T, inpu const expectedObjectType = resolveSchemaObjectType(ENTITY_OBJECT_TYPE_MAP[entityType]); const objectInput = input; + const receivedObjectType = objectInput.object_type; + if (typeof receivedObjectType !== 'string' || receivedObjectType.length === 0) { + throw new OcpValidationError('object_type', 'Required field is missing or invalid', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: expectedObjectType, + receivedValue: receivedObjectType, + }); + } + if (receivedObjectType !== expectedObjectType) { + throw new OcpValidationError( + 'object_type', + `Entity type "${entityType}" expects object_type "${expectedObjectType}", received "${receivedObjectType}"`, + { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: expectedObjectType, + receivedValue: receivedObjectType, + } + ); + } - const withObjectType = - typeof objectInput.object_type === 'string' && objectInput.object_type.length > 0 - ? objectInput - : { ...objectInput, object_type: expectedObjectType }; - - const parsed = parseOcfObject(withObjectType); + const parsed = parseOcfObject(objectInput); if (!isParsedEntityType(parsed, expectedObjectType)) { - const receivedObjectType = parsed.object_type; + const parsedObjectType = parsed.object_type; const receivedObjectTypeMessage = - typeof receivedObjectType === 'string' ? receivedObjectType : JSON.stringify(receivedObjectType); + typeof parsedObjectType === 'string' ? parsedObjectType : JSON.stringify(parsedObjectType); throw new OcpValidationError( 'object_type', `Entity type "${entityType}" expects object_type "${expectedObjectType}", received "${receivedObjectTypeMessage}"`, { code: OcpErrorCodes.INVALID_FORMAT, expectedType: expectedObjectType, - receivedValue: receivedObjectType, + receivedValue: parsedObjectType, } ); } diff --git a/src/utils/planSecurityAliases.ts b/src/utils/planSecurityAliases.ts index 54736a1b..83da6947 100644 --- a/src/utils/planSecurityAliases.ts +++ b/src/utils/planSecurityAliases.ts @@ -1,14 +1,13 @@ -import type { CompensationType, OcfStakeholder, OcfStockPlan, StakeholderRelationshipType } from '../types/native'; +import type { CompensationType, StakeholderRelationshipType } from '../types/native'; import { normalizeNumericString } from './typeConversions'; -import { isOcfStakeholder, isOcfStockPlan } from './typeGuards'; /** * Plan Security to Equity Compensation alias mappings. * * OCF defines both "PlanSecurity" and "EquityCompensation" transaction types that are - * semantically equivalent. This module provides utilities to normalize PlanSecurity - * types to their EquityCompensation equivalents, allowing the SDK to accept both - * type families transparently. + * semantically equivalent. This module is the raw-ingestion compatibility boundary: + * legacy PlanSecurity JSON is normalized to canonical EquityCompensation objects + * before it reaches the strongly typed SDK APIs. * * Reference: The OCF standard includes both TX_PLAN_SECURITY_* and TX_EQUITY_COMPENSATION_* * object types in the OcfObjectReference schema. @@ -59,6 +58,18 @@ export type PlanSecurityEntityType = keyof typeof PLAN_SECURITY_TO_EQUITY_COMPEN export type PlanSecurityObjectType = keyof typeof PLAN_SECURITY_OBJECT_TYPE_MAP; export type LegacyObjectType = keyof typeof LEGACY_OBJECT_TYPE_MAP; +/** Canonical entity kind produced by {@link normalizeEntityType}. */ +export type NormalizedEntityType = T extends PlanSecurityEntityType + ? (typeof PLAN_SECURITY_TO_EQUITY_COMPENSATION_MAP)[T] + : T; + +/** Canonical OCF discriminator produced by {@link normalizeObjectType}. */ +export type NormalizedObjectType = T extends PlanSecurityObjectType + ? (typeof PLAN_SECURITY_OBJECT_TYPE_MAP)[T] + : T extends LegacyObjectType + ? (typeof LEGACY_OBJECT_TYPE_MAP)[T] + : T; + /** * Check if an entity type is a PlanSecurity alias. * @@ -100,11 +111,11 @@ export function isLegacyObjectType(objectType: string): objectType is LegacyObje * normalizeEntityType('stockIssuance') // => 'stockIssuance' * ``` */ -export function normalizeEntityType(type: T): string { +export function normalizeEntityType(type: T): NormalizedEntityType { if (isPlanSecurityEntityType(type)) { - return PLAN_SECURITY_TO_EQUITY_COMPENSATION_MAP[type]; + return PLAN_SECURITY_TO_EQUITY_COMPENSATION_MAP[type] as NormalizedEntityType; } - return type; + return type as NormalizedEntityType; } /** @@ -121,19 +132,46 @@ export function normalizeEntityType(type: T): string { * normalizeObjectType('TX_STOCK_ISSUANCE') // => 'TX_STOCK_ISSUANCE' * ``` */ -export function normalizeObjectType(objectType: T): string { +export function normalizeObjectType(objectType: T): NormalizedObjectType { if (isPlanSecurityObjectType(objectType)) { - return PLAN_SECURITY_OBJECT_TYPE_MAP[objectType]; + return PLAN_SECURITY_OBJECT_TYPE_MAP[objectType] as NormalizedObjectType; } if (isLegacyObjectType(objectType)) { - return LEGACY_OBJECT_TYPE_MAP[objectType]; + return LEGACY_OBJECT_TYPE_MAP[objectType] as NormalizedObjectType; } - return objectType; + return objectType as NormalizedObjectType; } type OptionGrantType = 'NSO' | 'ISO' | 'INTL'; type PlanSecurityType = 'OPTION' | 'RSU' | 'OTHER'; +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function hasStakeholderPayloadShape(value: Record): boolean { + const { name } = value; + return ( + isNonEmptyString(value.id) && + typeof name === 'object' && + name !== null && + isNonEmptyString((name as Record).legal_name) && + typeof value.stakeholder_type === 'string' && + ['INDIVIDUAL', 'INSTITUTION'].includes(value.stakeholder_type) + ); +} + +function hasStockPlanPayloadShape(value: Record): boolean { + const hasStockClassIds = Array.isArray(value.stock_class_ids) && value.stock_class_ids.length > 0; + const hasDeprecatedStockClassId = isNonEmptyString(value.stock_class_id); + return ( + isNonEmptyString(value.id) && + isNonEmptyString(value.plan_name) && + isNonEmptyString(value.initial_shares_reserved) && + (hasStockClassIds || hasDeprecatedStockClassId) + ); +} + function mapOptionGrantTypeToCompensationType(optionGrantType: OptionGrantType): CompensationType { switch (optionGrantType) { case 'NSO': @@ -287,7 +325,7 @@ function isStakeholderRelationshipType(value: string): value is StakeholderRelat * - object_type: TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT * - new_relationships: StakeholderRelationship[] */ -function normalizeStakeholderRelationshipChangeEvent>(data: T): T { +function normalizeStakeholderRelationshipChangeEvent(data: Record): Record { const normalizedObjectType = normalizeObjectType(typeof data.object_type === 'string' ? data.object_type : ''); const isRelationshipEvent = normalizedObjectType === 'CE_STAKEHOLDER_RELATIONSHIP'; if (!isRelationshipEvent) return data; @@ -366,7 +404,7 @@ function normalizeStakeholderRelationshipChangeEvent>(data: T): T { +function normalizeStakeholderStatusChangeEvent(data: Record): Record { const normalizedObjectType = normalizeObjectType(typeof data.object_type === 'string' ? data.object_type : ''); if (normalizedObjectType !== 'CE_STAKEHOLDER_STATUS') return data; @@ -411,7 +449,7 @@ function normalizeStakeholderStatusChangeEvent } delete result.reason_text; - return result as T; + return result; } /** @@ -435,7 +473,7 @@ function normalizeStakeholderStatusChangeEvent * @param data - OCF object that may have quantity and quantity_source fields * @returns Object with quantity_source normalized based on quantity presence */ -function normalizeQuantitySource>(data: T): T { +function normalizeQuantitySource(data: Record): Record { if (data.object_type !== 'TX_WARRANT_ISSUANCE') { return data; } @@ -446,7 +484,7 @@ function normalizeQuantitySource>(data: T): T // and quantity_source is 'UNSPECIFIED' (which is equivalent to "don't know") if ((quantity === null || quantity === undefined) && quantitySource === 'UNSPECIFIED') { const { quantity_source: _, ...rest } = data; - return rest as T; + return rest; } // Case 2: Add quantity_source: 'UNSPECIFIED' if quantity IS present but quantity_source is missing @@ -476,7 +514,7 @@ const DOCUMENT_NON_DAML_FIELDS: ReadonlySet = new Set(['date']); * @param data - OCF data object (only modified when object_type is DOCUMENT) * @returns Data with non-DAML Document fields removed (shallow copy if modified) */ -function stripDocumentNonDamlFields>(data: T): T { +function stripDocumentNonDamlFields(data: Record): Record { const objectType = data.object_type; if (objectType !== 'DOCUMENT') return data; @@ -490,7 +528,7 @@ function stripDocumentNonDamlFields>(data: T): result[key] = value; } } - return result as T; + return result; } /** @@ -508,10 +546,8 @@ function stripDocumentNonDamlFields>(data: T): * - If `current_relationships` is missing and legacy `current_relationship` is * a non-empty string, map it to `current_relationships: [value]`. */ -function normalizeStakeholderRelationships(data: OcfStakeholder): OcfStakeholder; -function normalizeStakeholderRelationships>(data: T): T; -function normalizeStakeholderRelationships>(data: T): T { - const isStakeholderObject = data.object_type === 'STAKEHOLDER' || isOcfStakeholder(data); +function normalizeStakeholderRelationships(data: Record): Record { + const isStakeholderObject = data.object_type === 'STAKEHOLDER' || hasStakeholderPayloadShape(data); if (!isStakeholderObject) return data; const relationshipsValue = data.current_relationships; @@ -559,7 +595,7 @@ function normalizeStakeholderRelationships>(da return { ...rest, current_relationships: [legacyRelationship], - } as T; + }; } /** @@ -576,10 +612,8 @@ function normalizeStakeholderRelationships>(da * - If `stock_class_ids` is missing and legacy `stock_class_id` is * a non-empty string, map it to `stock_class_ids: [value]`. */ -function normalizeStockPlanClassIds(data: OcfStockPlan): OcfStockPlan; -function normalizeStockPlanClassIds>(data: T): T; -function normalizeStockPlanClassIds>(data: T): T { - const isStockPlanObject = data.object_type === 'STOCK_PLAN' || isOcfStockPlan(data); +function normalizeStockPlanClassIds(data: Record): Record { + const isStockPlanObject = data.object_type === 'STOCK_PLAN' || hasStockPlanPayloadShape(data); if (!isStockPlanObject) return data; const classIdsValue = data.stock_class_ids; @@ -606,7 +640,7 @@ function normalizeStockPlanClassIds>(data: T): return { ...rest, stock_class_ids: [legacyClassId], - } as T; + }; } /** @@ -615,7 +649,7 @@ function normalizeStockPlanClassIds>(data: T): * OCF now uses singular `resulting_security_id`, while legacy payloads may still send * `resulting_security_ids` as an array. */ -function normalizeStockConsolidationResultingSecurityId>(data: T): T { +function normalizeStockConsolidationResultingSecurityId(data: Record): Record { if (data.object_type !== 'TX_STOCK_CONSOLIDATION') return data; const result: Record = { ...data }; @@ -657,7 +691,7 @@ function normalizeStockConsolidationResultingSecurityId>(data: T): T { +function normalizeStockConversionQuantityConverted(data: Record): Record { if (data.object_type !== 'TX_STOCK_CONVERSION') return data; const result: Record = { ...data }; @@ -683,7 +717,7 @@ function normalizeStockConversionQuantityConverted>(data: T): T { +function normalizeStockClassSplitRatio(data: Record): Record { if (data.object_type !== 'TX_STOCK_CLASS_SPLIT') return data; const result: Record = { ...data }; @@ -726,7 +760,7 @@ function normalizeStockClassSplitRatio>(data: delete result.split_ratio_denominator; } - return result as T; + return result; } /** @@ -735,7 +769,7 @@ function normalizeStockClassSplitRatio>(data: * OCF now uses `new_ratio_conversion_mechanism`, while legacy payloads may still send * `new_ratio_numerator` / `new_ratio_denominator`. */ -function normalizeStockClassConversionRatioAdjustmentMechanism>(data: T): T { +function normalizeStockClassConversionRatioAdjustmentMechanism(data: Record): Record { if (data.object_type !== 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT') return data; const result: Record = { ...data }; @@ -774,7 +808,7 @@ function normalizeStockClassConversionRatioAdjustmentMechanism>(data: T): T { +function normalizeStockReissuanceSplitTransactionId(data: Record): Record { if (data.object_type !== 'TX_STOCK_REISSUANCE') return data; if (data.split_transaction_id !== null) return data; const { split_transaction_id: _, ...rest } = data; - return rest as T; + return rest; } /** Matches a well-formed decimal number (no backtracking risk). */ @@ -807,19 +841,23 @@ function hasTrailingDecimalZeros(value: string): boolean { * Only modifies string values that are decimal numbers ending in '0'. * Non-numeric strings (IDs, dates, enums, names) are never touched. */ -export function deepNormalizeNumericStrings(value: T): T { +export function deepNormalizeNumericStrings(value: string): string; +export function deepNormalizeNumericStrings(value: readonly unknown[]): readonly unknown[]; +export function deepNormalizeNumericStrings(value: Record): Record; +export function deepNormalizeNumericStrings(value: unknown): unknown; +export function deepNormalizeNumericStrings(value: unknown): unknown { if (typeof value === 'string' && hasTrailingDecimalZeros(value)) { - return normalizeNumericString(value) as T; + return normalizeNumericString(value); } if (Array.isArray(value)) { - const mapped = value.map(deepNormalizeNumericStrings); - return (mapped.some((item, i) => item !== value[i]) ? mapped : value) as T; + const mapped = value.map((item) => deepNormalizeNumericStrings(item)); + return mapped.some((item, i) => item !== value[i]) ? mapped : value; } if (typeof value === 'object' && value !== null) { - const entries = Object.entries(value as Record); + const entries = Object.entries(value); const normalized = entries.map(([k, v]) => [k, deepNormalizeNumericStrings(v)] as const); if (normalized.every(([, v], i) => v === entries[i][1])) return value; - return Object.fromEntries(normalized) as T; + return Object.fromEntries(normalized); } return value; } @@ -858,9 +896,16 @@ export function deepNormalizeNumericStrings(value: T): T { * // => { object_type: 'STOCK_PLAN', stock_class_ids: ['sc-1'], id: 'sp-1', plan_name: 'Plan', initial_shares_reserved: '1000' } * ``` */ -export function normalizeOcfData>(data: T): T { +export function normalizeOcfData(data: object): Record { + if ( + Array.isArray(data) || + (Object.getPrototypeOf(data) !== Object.prototype && Object.getPrototypeOf(data) !== null) + ) { + throw new Error('Invalid OCF data: expected a plain object'); + } + const input = data as Record; // First normalize quantity_source for consistent comparison - let result: Record = normalizeQuantitySource(data); + let result: Record = normalizeQuantitySource(input); // Then normalize PlanSecurity object_type to EquityCompensation const objectType = result.object_type; @@ -914,7 +959,7 @@ export function normalizeOcfData>(data: T): T result = deepNormalizeNumericStrings(result); - return result as T; + return result; } /** @@ -948,7 +993,7 @@ const CAPITALIZATION_DEFINITION_RULES_BOOL_FIELDS = [ * This function walks `conversion_triggers[*].conversion_right.conversion_mechanism * .capitalization_definition_rules` and sets any missing boolean field to `false`. */ -function normalizeCapitalizationDefinitionRules>(data: T): T { +function normalizeCapitalizationDefinitionRules(data: Record): Record { if (data.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return data; const triggers = data.conversion_triggers; diff --git a/src/utils/replicationHelpers.ts b/src/utils/replicationHelpers.ts index 78142cae..4c141eca 100644 --- a/src/utils/replicationHelpers.ts +++ b/src/utils/replicationHelpers.ts @@ -13,7 +13,7 @@ import type { OcfEntityType } from '../functions/OpenCapTable/capTable/batchType import type { CapTableState } from '../functions/OpenCapTable/capTable/getCapTableState'; import type { OcfManifest } from './cantonOcfExtractor'; import { DEFAULT_DEPRECATED_FIELDS, DEFAULT_INTERNAL_FIELDS, ocfDeepEqual } from './ocfComparison'; -import { normalizeEntityType, normalizeObjectType, normalizeOcfData } from './planSecurityAliases'; +import { normalizeObjectType, normalizeOcfData } from './planSecurityAliases'; // ============================================================================ // Categorized Type Mapping @@ -207,15 +207,6 @@ const ENTITY_TYPE_LABELS: Record = { equityCompensationRepricing: ['Equity Compensation Repricing', 'Equity Compensation Repricings'], equityCompensationRetraction: ['Equity Compensation Retraction', 'Equity Compensation Retractions'], - // Plan Security aliases (use same labels as Equity Compensation) - planSecurityIssuance: ['Plan Security Issuance', 'Plan Security Issuances'], - planSecurityCancellation: ['Plan Security Cancellation', 'Plan Security Cancellations'], - planSecurityTransfer: ['Plan Security Transfer', 'Plan Security Transfers'], - planSecurityAcceptance: ['Plan Security Acceptance', 'Plan Security Acceptances'], - planSecurityExercise: ['Plan Security Exercise', 'Plan Security Exercises'], - planSecurityRelease: ['Plan Security Release', 'Plan Security Releases'], - planSecurityRetraction: ['Plan Security Retraction', 'Plan Security Retractions'], - // Convertibles (6 types) convertibleIssuance: ['Convertible Issuance', 'Convertible Issuances'], convertibleCancellation: ['Convertible Cancellation', 'Convertible Cancellations'], @@ -583,29 +574,24 @@ export function computeReplicationDiff( const sourceData = getSourceDataObject(item); const objectId = getSourceObjectId(item, sourceData); - // Normalize planSecurity types to equityCompensation for Canton lookup - // Canton stores planSecurity items under equity_compensation_* fields - const normalizedType = normalizeEntityType(item.entityType) as OcfEntityType; + const { entityType } = item; - // Skip duplicate items (same canonical object ID + normalized entityType) - // Use normalized type because aliased types (e.g., planSecurityIssuance and - // equityCompensationIssuance) map to the same Canton entity - const itemKey = `${normalizedType}:${objectId}`; + // Skip duplicate items with the same canonical object ID and entity type. + const itemKey = `${entityType}:${objectId}`; if (seenItems.has(itemKey)) { continue; } seenItems.add(itemKey); - // Track for delete detection (using normalized type to match Canton's storage) - let typeIds = sourceIdsByType.get(normalizedType); + // Track for delete detection. + let typeIds = sourceIdsByType.get(entityType); if (!typeIds) { typeIds = new Set(); - sourceIdsByType.set(normalizedType, typeIds); + sourceIdsByType.set(entityType, typeIds); } typeIds.add(objectId); - // Check if exists in Canton (using normalized type) - const cantonIds = cantonState.entities.get(normalizedType) ?? new Set(); + const cantonIds = cantonState.entities.get(entityType) ?? new Set(); const existsInCanton = cantonIds.has(objectId); if (!existsInCanton) { @@ -621,10 +607,10 @@ export function computeReplicationDiff( // The DAML contract enforces security_id uniqueness via *_by_security_id maps. // If the source has a new object ID but reuses a security_id already on Canton, // the create will be rejected. Detect this early with an actionable message. - if (securityIds && ISSUANCE_ENTITY_TYPES.has(normalizedType)) { + if (securityIds && ISSUANCE_ENTITY_TYPES.has(entityType)) { const securityId = typeof sourceData.security_id === 'string' ? sourceData.security_id : undefined; if (securityId) { - const cantonSecurityIds = securityIds.get(normalizedType); + const cantonSecurityIds = securityIds.get(entityType); if (cantonSecurityIds?.has(securityId)) { conflicts.push({ id: objectId, @@ -640,14 +626,14 @@ export function computeReplicationDiff( } } else if (cantonOcfData) { // Deep comparison: compare actual OCF data to detect changes - const cantonTypeData = cantonOcfData.get(normalizedType); + const cantonTypeData = cantonOcfData.get(entityType); const cantonItemData = cantonTypeData?.get(objectId); if (cantonItemData === undefined) { // cantonOcfData was provided but this item's data wasn't found // This indicates cantonOcfData is incomplete or inconsistent with cantonState.entities throw new Error( - `Inconsistent cantonOcfData: missing OCF data for entityType="${normalizedType}", ` + + `Inconsistent cantonOcfData: missing OCF data for entityType="${entityType}", ` + `id="${objectId}" even though the ID exists in cantonState.entities. ` + `Ensure cantonOcfData is built from the same Canton state.` ); diff --git a/src/utils/transactionHelpers.ts b/src/utils/transactionHelpers.ts index e4423c91..d5f14d2e 100644 --- a/src/utils/transactionHelpers.ts +++ b/src/utils/transactionHelpers.ts @@ -1,5 +1,5 @@ import { extractEventsFromTransaction } from '@fairmint/canton-node-sdk'; -import { OCF_METADATA, type OcfObjectType } from './ocfMetadata'; +import { OCF_METADATA, type OcfMetadataObjectType } from './ocfMetadata'; import { matchesTemplateIdentity } from './templateIdentity'; /** Represents a created event from a transaction tree */ @@ -74,7 +74,7 @@ export function findCreatedEventsByTemplateId(treeResponse: unknown, expectedTem * @param ocfType - The OCF object type * @returns The OCF ID string, or undefined if not found */ -export function extractOcfIdFromEvent(event: CreatedTreeEvent, ocfType: OcfObjectType): string | undefined { +export function extractOcfIdFromEvent(event: CreatedTreeEvent, ocfType: OcfMetadataObjectType): string | undefined { const metadata = OCF_METADATA[ocfType]; const ocfId = safeGet(event.CreatedTreeEvent.value.createArgument, metadata.ocfIdPath); return typeof ocfId === 'string' ? ocfId : undefined; @@ -87,7 +87,7 @@ export function extractOcfIdFromEvent(event: CreatedTreeEvent, ocfType: OcfObjec * @param ocfType - The OCF object type to extract * @returns Map of OCF ID to contract ID */ -export function buildOcfIdToContractIdMap(treeResponse: unknown, ocfType: OcfObjectType): Map { +export function buildOcfIdToContractIdMap(treeResponse: unknown, ocfType: OcfMetadataObjectType): Map { const metadata = OCF_METADATA[ocfType]; const events = findCreatedEventsByTemplateId(treeResponse, metadata.templateId); const ocfIdMap = new Map(); @@ -109,10 +109,10 @@ export function buildOcfIdToContractIdMap(treeResponse: unknown, ocfType: OcfObj * @param treeResponse - The transaction tree response * @returns Map of OCF type to (OCF ID -> contract ID) map */ -export function buildAllOcfIdMaps(treeResponse: unknown): Map> { - const allMaps = new Map>(); +export function buildAllOcfIdMaps(treeResponse: unknown): Map> { + const allMaps = new Map>(); - for (const ocfType of Object.keys(OCF_METADATA) as OcfObjectType[]) { + for (const ocfType of Object.keys(OCF_METADATA) as OcfMetadataObjectType[]) { allMaps.set(ocfType, buildOcfIdToContractIdMap(treeResponse, ocfType)); } @@ -125,11 +125,11 @@ export function buildAllOcfIdMaps(treeResponse: unknown): Map { - const allArrays = new Map(); +export function buildAllOcfEventArrays(treeResponse: unknown): Map { + const allArrays = new Map(); for (const [type, metadata] of Object.entries(OCF_METADATA)) { - const ocfType = type as OcfObjectType; + const ocfType = type as OcfMetadataObjectType; const events = findCreatedEventsByTemplateId(treeResponse, metadata.templateId); allArrays.set(ocfType, events); } diff --git a/src/utils/typeGuards.ts b/src/utils/typeGuards.ts index 57c03f86..5813e5a3 100644 --- a/src/utils/typeGuards.ts +++ b/src/utils/typeGuards.ts @@ -37,6 +37,7 @@ import type { OcfWarrantCancellation, OcfWarrantIssuance, } from '../types/native'; +import { getOcfSchema } from './ocfZodSchemas'; // ===== Primitive Type Guards ===== @@ -81,6 +82,20 @@ export function isMonetary(value: unknown): value is { amount: string; currency: return 'amount' in value && isNumericValue(value.amount) && 'currency' in value && typeof value.currency === 'string'; } +/** Validate an unknown value against the canonical OCF schema without throwing. */ +function isStrictOcfObject( + value: unknown, + objectType: T['object_type'] +): value is T { + if (!isObject(value) || Array.isArray(value) || value.object_type !== objectType) return false; + + try { + return getOcfSchema(objectType).safeParse(value).success; + } catch { + return false; + } +} + // ===== OCF Object Type Guards ===== /** @@ -95,111 +110,56 @@ export function isMonetary(value: unknown): value is { amount: string; currency: * ``` */ export function isOcfIssuer(value: unknown): value is OcfIssuer { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.legal_name) && - isNonEmptyString(value.formation_date) && - isNonEmptyString(value.country_of_formation) - ); + return isStrictOcfObject(value, 'ISSUER'); } /** * Type guard for OcfStakeholder objects. */ export function isOcfStakeholder(value: unknown): value is OcfStakeholder { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isObject(value.name) && - isNonEmptyString(value.name.legal_name) && - typeof value.stakeholder_type === 'string' && - ['INDIVIDUAL', 'INSTITUTION'].includes(value.stakeholder_type) - ); + return isStrictOcfObject(value, 'STAKEHOLDER'); } /** * Type guard for OcfStockClass objects. */ export function isOcfStockClass(value: unknown): value is OcfStockClass { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.name) && - isNonEmptyString(value.default_id_prefix) && - typeof value.class_type === 'string' && - ['COMMON', 'PREFERRED'].includes(value.class_type) && - isNumericValue(value.initial_shares_authorized) && - isNumericValue(value.votes_per_share) && - isNumericValue(value.seniority) - ); + return isStrictOcfObject(value, 'STOCK_CLASS'); } /** * Type guard for OcfStockIssuance objects. */ export function isOcfStockIssuance(value: unknown): value is OcfStockIssuance { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNonEmptyString(value.custom_id) && - isNonEmptyString(value.stakeholder_id) && - isNonEmptyString(value.stock_class_id) && - isNumericValue(value.quantity) && - isMonetary(value.share_price) - ); + return isStrictOcfObject(value, 'TX_STOCK_ISSUANCE'); } /** * Type guard for OcfStockTransfer objects. */ export function isOcfStockTransfer(value: unknown): value is OcfStockTransfer { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNumericValue(value.quantity) && - Array.isArray(value.resulting_security_ids) - ); + return isStrictOcfObject(value, 'TX_STOCK_TRANSFER'); } /** * Type guard for OcfStockCancellation objects. */ export function isOcfStockCancellation(value: unknown): value is OcfStockCancellation { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNumericValue(value.quantity) && - isNonEmptyString(value.reason_text) - ); + return isStrictOcfObject(value, 'TX_STOCK_CANCELLATION'); } /** * Type guard for OcfStockRepurchase objects. */ export function isOcfStockRepurchase(value: unknown): value is OcfStockRepurchase { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNumericValue(value.quantity) && - isMonetary(value.price) - ); + return isStrictOcfObject(value, 'TX_STOCK_REPURCHASE'); } /** * Type guard for OcfStockLegendTemplate objects. */ export function isOcfStockLegendTemplate(value: unknown): value is OcfStockLegendTemplate { - if (!isObject(value)) return false; - return isNonEmptyString(value.id) && isNonEmptyString(value.name) && isNonEmptyString(value.text); + return isStrictOcfObject(value, 'STOCK_LEGEND_TEMPLATE'); } /** @@ -208,160 +168,77 @@ export function isOcfStockLegendTemplate(value: unknown): value is OcfStockLegen * per the OCF StockPlan schema oneOf. */ export function isOcfStockPlan(value: unknown): value is OcfStockPlan { - if (!isObject(value)) return false; - const hasStockClassIds = Array.isArray(value.stock_class_ids) && value.stock_class_ids.length > 0; - const hasDeprecatedStockClassId = isNonEmptyString(value.stock_class_id); - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.plan_name) && - isNumericValue(value.initial_shares_reserved) && - (hasStockClassIds || hasDeprecatedStockClassId) - ); + return isStrictOcfObject(value, 'STOCK_PLAN'); } /** * Type guard for OcfVestingTerms objects. */ export function isOcfVestingTerms(value: unknown): value is OcfVestingTerms { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.name) && - isNonEmptyString(value.description) && - typeof value.allocation_type === 'string' && - Array.isArray(value.vesting_conditions) - ); + return isStrictOcfObject(value, 'VESTING_TERMS'); } /** * Type guard for OcfEquityCompensationIssuance objects. */ export function isOcfEquityCompensationIssuance(value: unknown): value is OcfEquityCompensationIssuance { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNonEmptyString(value.custom_id) && - isNonEmptyString(value.stakeholder_id) && - typeof value.compensation_type === 'string' && - isNumericValue(value.quantity) - ); + return isStrictOcfObject(value, 'TX_EQUITY_COMPENSATION_ISSUANCE'); } /** * Type guard for OcfEquityCompensationExercise objects. */ export function isOcfEquityCompensationExercise(value: unknown): value is OcfEquityCompensationExercise { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNumericValue(value.quantity) && - Array.isArray(value.resulting_security_ids) - ); + return isStrictOcfObject(value, 'TX_EQUITY_COMPENSATION_EXERCISE'); } /** * Type guard for OcfEquityCompensationCancellation objects. */ export function isOcfEquityCompensationCancellation(value: unknown): value is OcfEquityCompensationCancellation { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNumericValue(value.quantity) && - isNonEmptyString(value.reason_text) - ); + return isStrictOcfObject(value, 'TX_EQUITY_COMPENSATION_CANCELLATION'); } /** * Type guard for OcfWarrantIssuance objects. */ export function isOcfWarrantIssuance(value: unknown): value is OcfWarrantIssuance { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNonEmptyString(value.custom_id) && - isNonEmptyString(value.stakeholder_id) && - isMonetary(value.purchase_price) && - Array.isArray(value.exercise_triggers) - ); + return isStrictOcfObject(value, 'TX_WARRANT_ISSUANCE'); } /** * Type guard for OcfWarrantCancellation objects. */ export function isOcfWarrantCancellation(value: unknown): value is OcfWarrantCancellation { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNumericValue(value.quantity) && - isNonEmptyString(value.reason_text) - ); + return isStrictOcfObject(value, 'TX_WARRANT_CANCELLATION'); } /** * Type guard for OcfConvertibleIssuance objects. */ export function isOcfConvertibleIssuance(value: unknown): value is OcfConvertibleIssuance { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNonEmptyString(value.custom_id) && - isNonEmptyString(value.stakeholder_id) && - isMonetary(value.investment_amount) && - typeof value.convertible_type === 'string' && - Array.isArray(value.conversion_triggers) - ); + return isStrictOcfObject(value, 'TX_CONVERTIBLE_ISSUANCE'); } /** * Type guard for OcfConvertibleCancellation objects. */ export function isOcfConvertibleCancellation(value: unknown): value is OcfConvertibleCancellation { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNonEmptyString(value.reason_text) - ); + return isStrictOcfObject(value, 'TX_CONVERTIBLE_CANCELLATION'); } /** * Type guard for OcfValuation objects. */ export function isOcfValuation(value: unknown): value is OcfValuation { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.stock_class_id) && - isNonEmptyString(value.effective_date) && - typeof value.valuation_type === 'string' && - isMonetary(value.price_per_share) - ); + return isStrictOcfObject(value, 'VALUATION'); } /** * Type guard for OcfDocument objects. */ export function isOcfDocument(value: unknown): value is OcfDocument { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.md5) && - (value.path === undefined || typeof value.path === 'string') && - (value.uri === undefined || typeof value.uri === 'string') - ); + return isStrictOcfObject(value, 'DOCUMENT'); } // ===== Generic OCF Object Type Detection ===== @@ -369,8 +246,8 @@ export function isOcfDocument(value: unknown): value is OcfDocument { /** * Detected OCF object types from runtime type guards. * - * This is similar to OcfObjectType from ocfMetadata but includes an 'UNKNOWN' case - * for when the type cannot be determined from structure alone. + * Legacy category names returned by {@link detectOcfObjectType}, plus `UNKNOWN`. + * Prefer the canonical `object_type` literal on the narrowed OCF object itself. */ export type DetectedOcfType = | 'ISSUER' @@ -415,41 +292,8 @@ export type DetectedOcfType = * ``` */ export function detectOcfObjectType(value: unknown): DetectedOcfType { - // First check if the object has an explicit object_type field - if (isObject(value) && 'object_type' in value && typeof value.object_type === 'string') { - const objectType = value.object_type; - // Map common OCF object_type values - const knownTypes = [ - 'ISSUER', - 'STAKEHOLDER', - 'STOCK_CLASS', - 'STOCK_LEGEND_TEMPLATE', - 'STOCK_PLAN', - 'VESTING_TERMS', - 'VALUATION', - 'DOCUMENT', - ]; - if (knownTypes.includes(objectType)) { - return objectType as DetectedOcfType; - } - // Transaction types often have TX_ prefix - if (objectType.startsWith('TX_')) { - const txType = objectType.substring(3); // Remove TX_ prefix - if (txType.includes('STOCK_ISSUANCE')) return 'STOCK_ISSUANCE'; - if (txType.includes('STOCK_TRANSFER')) return 'STOCK_TRANSFER'; - if (txType.includes('STOCK_CANCELLATION')) return 'STOCK_CANCELLATION'; - if (txType.includes('STOCK_REPURCHASE')) return 'STOCK_REPURCHASE'; - if (txType.includes('EQUITY_COMPENSATION_ISSUANCE')) return 'EQUITY_COMPENSATION_ISSUANCE'; - if (txType.includes('EQUITY_COMPENSATION_EXERCISE')) return 'EQUITY_COMPENSATION_EXERCISE'; - if (txType.includes('EQUITY_COMPENSATION_CANCELLATION')) return 'EQUITY_COMPENSATION_CANCELLATION'; - if (txType.includes('WARRANT_ISSUANCE')) return 'WARRANT_ISSUANCE'; - if (txType.includes('WARRANT_CANCELLATION')) return 'WARRANT_CANCELLATION'; - if (txType.includes('CONVERTIBLE_ISSUANCE')) return 'CONVERTIBLE_ISSUANCE'; - if (txType.includes('CONVERTIBLE_CANCELLATION')) return 'CONVERTIBLE_CANCELLATION'; - } - } - - // Fall back to structural detection using type guards + // A discriminator identifies the candidate schema, but detection succeeds only + // after the complete object passes the corresponding sound type guard. if (isOcfIssuer(value)) return 'ISSUER'; if (isOcfStakeholder(value)) return 'STAKEHOLDER'; if (isOcfStockClass(value)) return 'STOCK_CLASS'; diff --git a/test/batch/CapTableBatch.test.ts b/test/batch/CapTableBatch.test.ts index 8be6184b..852ae4b0 100644 --- a/test/batch/CapTableBatch.test.ts +++ b/test/batch/CapTableBatch.test.ts @@ -29,6 +29,7 @@ describe('CapTableBatch', () => { }); const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -47,14 +48,19 @@ describe('CapTableBatch', () => { }); const invalidStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', unexpected_field: 'not allowed by strict schema', }; - expect(() => batch.create('stakeholder', invalidStakeholder as OcfStakeholder)).toThrow(OcpValidationError); - expect(() => batch.create('stakeholder', invalidStakeholder as OcfStakeholder)).toThrow('unexpected_field'); + expect(() => batch.create('stakeholder', invalidStakeholder as unknown as OcfStakeholder)).toThrow( + OcpValidationError + ); + expect(() => batch.create('stakeholder', invalidStakeholder as unknown as OcfStakeholder)).toThrow( + 'unexpected_field' + ); }); it('should accept deprecated stakeholder relationship field via canonicalization', () => { @@ -64,6 +70,7 @@ describe('CapTableBatch', () => { }); const stakeholderWithDeprecatedField = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -81,6 +88,7 @@ describe('CapTableBatch', () => { }); const legacySplitData = { + object_type: 'TX_STOCK_CLASS_SPLIT', id: 'split-123', date: '2024-01-15', stock_class_id: 'sc-123', @@ -105,6 +113,7 @@ describe('CapTableBatch', () => { }); const legacyRatioData: OcfStockClassConversionRatioAdjustment = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', id: 'ratio-123', date: '2024-01-15', stock_class_id: 'sc-123', @@ -133,12 +142,14 @@ describe('CapTableBatch', () => { }); const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', }; const stockClassData: OcfStockClass = { + object_type: 'STOCK_CLASS', id: 'sc-123', name: 'Common Stock', class_type: 'COMMON', @@ -164,6 +175,7 @@ describe('CapTableBatch', () => { }); const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -183,6 +195,7 @@ describe('CapTableBatch', () => { }); const issuerData = { + object_type: 'ISSUER' as const, id: 'issuer-123', legal_name: 'Test Corp', formation_date: '2024-01-01', @@ -229,6 +242,7 @@ describe('CapTableBatch', () => { }); const issuerData = { + object_type: 'ISSUER' as const, id: 'issuer-123', legal_name: 'Updated Test Corp', formation_date: '2024-01-01', @@ -273,6 +287,7 @@ describe('CapTableBatch', () => { }); const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -312,6 +327,7 @@ describe('CapTableBatch', () => { }); const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'Jane Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -403,6 +419,7 @@ describe('CapTableBatch', () => { ); batch.create('stakeholder', { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -427,11 +444,13 @@ describe('CapTableBatch', () => { batch .create('stakeholder', { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', }) .edit('stockClass', { + object_type: 'STOCK_CLASS', id: 'sc-123', name: 'Common Stock', class_type: 'COMMON', @@ -709,6 +728,7 @@ describe('JSON-safety guard', () => { }); const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -748,6 +768,7 @@ describe('JSON-safety guard', () => { describe('buildUpdateCapTableCommand', () => { it('should build command from operations object', () => { const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -803,11 +824,10 @@ describe('ENTITY_TAG_MAP', () => { }); }); - it('should have all 55 entity types (47 base + 7 PlanSecurity aliases + 1 issuer)', () => { - // The DAML contract supports 47 entity types in the CapTable maps - // Plus 7 PlanSecurity alias types that map to EquityCompensation types - // Plus 1 issuer type (edit-only, stored as a single reference not a map) - expect(Object.keys(ENTITY_TAG_MAP)).toHaveLength(55); + it('should have all 48 canonical entity types', () => { + // Legacy PlanSecurity values normalize to EquityCompensation before typed batch operations. + // Issuer is the one edit-only entity stored as a single reference rather than a map. + expect(Object.keys(ENTITY_TAG_MAP)).toHaveLength(48); }); it('should have correct tags for stakeholder event types', () => { diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index 1a3d30a5..b7ce2cb1 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -5,9 +5,11 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpContractError, OcpErrorCodes, OcpParseError } from '../../src/errors'; +import { ENTITY_REGISTRY, isOcfEntityType } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { convertToOcf, ENTITY_DATA_FIELD_MAP, + ENTITY_TEMPLATE_ID_MAP, extractCreateArgument, extractEntityData, getEntityAsOcf, @@ -97,6 +99,45 @@ describe('damlToOcf dispatcher', () => { readAs: ['issuer::party-123'], }); }); + + it('rejects a same-field contract from the wrong generated template before conversion', async () => { + const getEventsByContractId = jest.fn().mockResolvedValue( + buildCreatedEventsResponse( + { + retraction_data: { + id: 'warrant-retraction-1', + date: '2025-01-01T00:00:00Z', + security_id: 'warrant-1', + reason_text: 'Wrong reader', + comments: [], + }, + }, + Fairmint.OpenCapTable.OCF.WarrantRetraction.WarrantRetraction.templateId + ) + ); + const mockClient = { getEventsByContractId } as unknown as LedgerJsonApiClient; + + await expect(getEntityAsOcf(mockClient, 'stockRetraction', 'wrong-template-cid')).rejects.toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'module_entity_mismatch', + }); + }); + }); + + describe('ENTITY_TEMPLATE_ID_MAP', () => { + const entityTypes = Object.keys(ENTITY_REGISTRY).filter(isOcfEntityType); + + it('covers every registered entity exactly once', () => { + expect(Object.keys(ENTITY_TEMPLATE_ID_MAP).sort()).toEqual([...entityTypes].sort()); + }); + + it.each(entityTypes)('maps %s to its generated OCF template identity', (entityType) => { + const generatedName = `${entityType[0].toUpperCase()}${entityType.slice(1)}`; + const expectedModuleEntityPath = `Fairmint.OpenCapTable.OCF.${generatedName}:${generatedName}`; + + expect(ENTITY_TEMPLATE_ID_MAP[entityType]).toBe(ENTITY_REGISTRY[entityType].templateId); + expect(ENTITY_TEMPLATE_ID_MAP[entityType].split(':').slice(1).join(':')).toBe(expectedModuleEntityPath); + }); }); describe('get*AsOcf readAs forwarding', () => { @@ -185,14 +226,17 @@ describe('damlToOcf dispatcher', () => { 'getEntityAsOcf(stockAcceptance)', async (client: LedgerJsonApiClient) => getEntityAsOcf(client, 'stockAcceptance', 'stock-acceptance-cid', { readAs: ['issuer::p'] }), - buildCreatedEventsResponse({ - acceptance_data: { - id: 'acc-1', - date: '2025-01-01T00:00:00Z', - security_id: 'sec-1', - comments: [], + buildCreatedEventsResponse( + { + acceptance_data: { + id: 'acc-1', + date: '2025-01-01T00:00:00Z', + security_id: 'sec-1', + comments: [], + }, }, - }), + Fairmint.OpenCapTable.OCF.StockAcceptance.StockAcceptance.templateId + ), ], ])('%s forwards readAs to getEventsByContractId', async (_name, invoke, response) => { const getEventsByContractId = jest.fn().mockResolvedValue(response); @@ -494,7 +538,7 @@ describe('damlToOcf dispatcher', () => { stockholder_approval_date: null, price_per_share: { amount: '10.00', currency: 'USD' }, effective_date: '2025-01-15T00:00:00Z', - valuation_type: 'OcfValuationType409A', + valuation_type: 'OcfValuationType409A' as const, comments: [], }; @@ -587,6 +631,8 @@ describe('damlToOcf dispatcher', () => { security_id: 'conv-sec-1', amount: { amount: '5000.00', currency: 'USD' }, resulting_security_ids: ['conv-sec-2'], + balance_security_id: null, + consideration_text: null, comments: [], }; @@ -641,11 +687,13 @@ describe('damlToOcf dispatcher', () => { describe('error handling', () => { it('throws OcpParseError for unsupported entity type', () => { + // @ts-expect-error exercise the runtime guard for an untyped unsupported caller expect(() => convertToOcf('unsupported' as SupportedOcfReadType, {})).toThrow(OcpParseError); }); it('includes entity type in error message', () => { try { + // @ts-expect-error exercise the runtime guard for an untyped unsupported caller convertToOcf('unsupported' as SupportedOcfReadType, {}); } catch (e) { const error = e as OcpParseError; diff --git a/test/batch/remainingOcfTypes.test.ts b/test/batch/remainingOcfTypes.test.ts index ef934404..a53ef21a 100644 --- a/test/batch/remainingOcfTypes.test.ts +++ b/test/batch/remainingOcfTypes.test.ts @@ -30,6 +30,7 @@ describe('Retraction Type Converters', () => { }); const data: OcfStockRetraction = { + object_type: 'TX_STOCK_RETRACTION', id: 'sr-123', date: '2024-01-15', security_id: 'sec-001', @@ -73,6 +74,7 @@ describe('Retraction Type Converters', () => { }); const data: OcfWarrantRetraction = { + object_type: 'TX_WARRANT_RETRACTION', id: 'wr-123', date: '2024-02-20', security_id: 'warrant-001', @@ -114,6 +116,7 @@ describe('Retraction Type Converters', () => { }); const data: OcfConvertibleRetraction = { + object_type: 'TX_CONVERTIBLE_RETRACTION', id: 'cr-123', date: '2024-03-10', security_id: 'conv-001', @@ -151,6 +154,7 @@ describe('Retraction Type Converters', () => { }); const data: OcfEquityCompensationRetraction = { + object_type: 'TX_EQUITY_COMPENSATION_RETRACTION', id: 'ecr-123', date: '2024-04-05', security_id: 'option-001', @@ -189,6 +193,7 @@ describe('Equity Compensation Event Converters', () => { }); const data: OcfEquityCompensationRelease = { + object_type: 'TX_EQUITY_COMPENSATION_RELEASE', id: 'rel-123', date: '2024-05-15', security_id: 'rsu-001', @@ -239,6 +244,7 @@ describe('Equity Compensation Event Converters', () => { }); const data: OcfEquityCompensationRepricing = { + object_type: 'TX_EQUITY_COMPENSATION_REPRICING', id: 'rep-123', date: '2024-06-01', security_id: 'option-underwater-001', @@ -285,6 +291,7 @@ describe('Stock Plan Event Converters', () => { }); const data: OcfStockPlanReturnToPool = { + object_type: 'TX_STOCK_PLAN_RETURN_TO_POOL', id: 'rtp-123', date: '2024-07-10', security_id: 'sec-123', @@ -333,6 +340,7 @@ describe('Stakeholder Change Event Converters', () => { }); const data: OcfStakeholderRelationshipChangeEvent = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', id: 'rce-123', date: '2024-08-01', stakeholder_id: 'sh-001', @@ -366,6 +374,7 @@ describe('Stakeholder Change Event Converters', () => { }); const data: OcfStakeholderRelationshipChangeEvent = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', id: 'rce-456', date: '2024-08-15', stakeholder_id: 'sh-002', @@ -394,6 +403,7 @@ describe('Stakeholder Change Event Converters', () => { }); const data: OcfStakeholderRelationshipChangeEvent = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', id: 'rce-invalid', date: '2024-08-20', stakeholder_id: 'sh-003', @@ -422,6 +432,7 @@ describe('Stakeholder Change Event Converters', () => { }); const data: OcfStakeholderStatusChangeEvent = { + object_type: 'CE_STAKEHOLDER_STATUS', id: 'sce-123', date: '2024-09-01', stakeholder_id: 'sh-001', @@ -476,6 +487,7 @@ describe('Stakeholder Change Event Converters', () => { }); const data: OcfStakeholderStatusChangeEvent = { + object_type: 'CE_STAKEHOLDER_STATUS', id: `sce-${input}`, date: '2024-09-01', stakeholder_id: 'sh-001', @@ -515,12 +527,14 @@ describe('Batch operations with remaining types', () => { batch .create('stockRetraction', { + object_type: 'TX_STOCK_RETRACTION', id: 'sr-1', date: '2024-01-01', security_id: 'sec-1', reason_text: 'Error correction', }) .create('equityCompensationRelease', { + object_type: 'TX_EQUITY_COMPENSATION_RELEASE', id: 'rel-1', date: '2024-01-02', security_id: 'rsu-1', @@ -530,6 +544,7 @@ describe('Batch operations with remaining types', () => { release_price: { amount: '0.00', currency: 'USD' }, }) .create('stockPlanReturnToPool', { + object_type: 'TX_STOCK_PLAN_RETURN_TO_POOL', id: 'rtp-1', date: '2024-01-03', security_id: 'sec-1', @@ -538,6 +553,7 @@ describe('Batch operations with remaining types', () => { reason_text: 'Termination', }) .create('stakeholderStatusChangeEvent', { + object_type: 'CE_STAKEHOLDER_STATUS', id: 'sce-1', date: '2024-01-04', stakeholder_id: 'sh-1', diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index a5b434f9..52f1633a 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -510,6 +510,7 @@ describe('OcpClient OpenCapTable entity facade', () => { getEventsByContractId: jest.fn().mockResolvedValue({ created: { createdEvent: { + templateId: Fairmint.OpenCapTable.OCF.StockRetraction.StockRetraction.templateId, createArgument: { retraction_data: { id: 'ret-1', diff --git a/test/converters/acceptanceConverters.test.ts b/test/converters/acceptanceConverters.test.ts index f08ebe10..21e2f3d5 100644 --- a/test/converters/acceptanceConverters.test.ts +++ b/test/converters/acceptanceConverters.test.ts @@ -46,6 +46,7 @@ describe('Acceptance Type Converters', () => { describe('stockAcceptance', () => { test('converts minimal stockAcceptance to DAML format', () => { const ocfData: OcfStockAcceptance = { + object_type: 'TX_STOCK_ACCEPTANCE', id: 'stock-accept-001', date: '2024-01-15', security_id: 'stock-sec-001', @@ -63,6 +64,7 @@ describe('Acceptance Type Converters', () => { test('converts stockAcceptance with comments to DAML format', () => { const ocfData: OcfStockAcceptance = { + object_type: 'TX_STOCK_ACCEPTANCE', id: 'stock-accept-002', date: '2024-02-20', security_id: 'stock-sec-002', @@ -81,6 +83,7 @@ describe('Acceptance Type Converters', () => { test('throws error when id is missing', () => { const ocfData = { + object_type: 'TX_STOCK_ACCEPTANCE', id: '', date: '2024-01-15', security_id: 'stock-sec-001', @@ -92,6 +95,7 @@ describe('Acceptance Type Converters', () => { test('rejects date values that include time portion', () => { const ocfData: OcfStockAcceptance = { + object_type: 'TX_STOCK_ACCEPTANCE', id: 'stock-accept-003', date: '2024-01-15T10:30:00.000Z', security_id: 'stock-sec-003', @@ -102,6 +106,7 @@ describe('Acceptance Type Converters', () => { test('filters empty comments', () => { const ocfData: OcfStockAcceptance = { + object_type: 'TX_STOCK_ACCEPTANCE', id: 'stock-accept-004', date: '2024-01-15', security_id: 'stock-sec-004', @@ -117,6 +122,7 @@ describe('Acceptance Type Converters', () => { describe('warrantAcceptance', () => { test('converts minimal warrantAcceptance to DAML format', () => { const ocfData: OcfWarrantAcceptance = { + object_type: 'TX_WARRANT_ACCEPTANCE', id: 'warrant-accept-001', date: '2024-03-10', security_id: 'warrant-sec-001', @@ -134,6 +140,7 @@ describe('Acceptance Type Converters', () => { test('converts warrantAcceptance with comments', () => { const ocfData: OcfWarrantAcceptance = { + object_type: 'TX_WARRANT_ACCEPTANCE', id: 'warrant-accept-002', date: '2024-03-15', security_id: 'warrant-sec-002', @@ -152,6 +159,7 @@ describe('Acceptance Type Converters', () => { test('throws error when id is missing', () => { const ocfData = { + object_type: 'TX_WARRANT_ACCEPTANCE', id: '', date: '2024-03-10', security_id: 'warrant-sec-001', @@ -165,6 +173,7 @@ describe('Acceptance Type Converters', () => { describe('convertibleAcceptance', () => { test('converts minimal convertibleAcceptance to DAML format', () => { const ocfData: OcfConvertibleAcceptance = { + object_type: 'TX_CONVERTIBLE_ACCEPTANCE', id: 'conv-accept-001', date: '2024-04-05', security_id: 'conv-sec-001', @@ -182,6 +191,7 @@ describe('Acceptance Type Converters', () => { test('converts convertibleAcceptance with comments', () => { const ocfData: OcfConvertibleAcceptance = { + object_type: 'TX_CONVERTIBLE_ACCEPTANCE', id: 'conv-accept-002', date: '2024-04-10', security_id: 'conv-sec-002', @@ -200,6 +210,7 @@ describe('Acceptance Type Converters', () => { test('throws error when id is missing', () => { const ocfData = { + object_type: 'TX_CONVERTIBLE_ACCEPTANCE', id: '', date: '2024-04-05', security_id: 'conv-sec-001', @@ -213,6 +224,7 @@ describe('Acceptance Type Converters', () => { describe('equityCompensationAcceptance', () => { test('converts minimal equityCompensationAcceptance to DAML format', () => { const ocfData: OcfEquityCompensationAcceptance = { + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', id: 'equity-accept-001', date: '2024-05-01', security_id: 'equity-sec-001', @@ -230,6 +242,7 @@ describe('Acceptance Type Converters', () => { test('converts equityCompensationAcceptance with comments', () => { const ocfData: OcfEquityCompensationAcceptance = { + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', id: 'equity-accept-002', date: '2024-05-15', security_id: 'equity-sec-002', @@ -248,6 +261,7 @@ describe('Acceptance Type Converters', () => { test('throws error when id is missing', () => { const ocfData = { + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', id: '', date: '2024-05-01', security_id: 'equity-sec-001', @@ -279,6 +293,7 @@ describe('Acceptance Type Converters', () => { const result = damlStockAcceptanceToNative(baseDamlData); expect(result).toEqual({ + object_type: 'TX_STOCK_ACCEPTANCE', id: 'test-accept-001', date: '2024-06-15', security_id: 'test-sec-001', @@ -289,6 +304,7 @@ describe('Acceptance Type Converters', () => { const result = damlStockAcceptanceToNative(damlDataWithComments); expect(result).toEqual({ + object_type: 'TX_STOCK_ACCEPTANCE', id: 'test-accept-002', date: '2024-06-20', security_id: 'test-sec-002', @@ -308,6 +324,7 @@ describe('Acceptance Type Converters', () => { const result = damlWarrantAcceptanceToNative(baseDamlData); expect(result).toEqual({ + object_type: 'TX_WARRANT_ACCEPTANCE', id: 'test-accept-001', date: '2024-06-15', security_id: 'test-sec-001', @@ -318,6 +335,7 @@ describe('Acceptance Type Converters', () => { const result = damlWarrantAcceptanceToNative(damlDataWithComments); expect(result).toEqual({ + object_type: 'TX_WARRANT_ACCEPTANCE', id: 'test-accept-002', date: '2024-06-20', security_id: 'test-sec-002', @@ -331,6 +349,7 @@ describe('Acceptance Type Converters', () => { const result = damlConvertibleAcceptanceToNative(baseDamlData); expect(result).toEqual({ + object_type: 'TX_CONVERTIBLE_ACCEPTANCE', id: 'test-accept-001', date: '2024-06-15', security_id: 'test-sec-001', @@ -341,6 +360,7 @@ describe('Acceptance Type Converters', () => { const result = damlConvertibleAcceptanceToNative(damlDataWithComments); expect(result).toEqual({ + object_type: 'TX_CONVERTIBLE_ACCEPTANCE', id: 'test-accept-002', date: '2024-06-20', security_id: 'test-sec-002', @@ -354,6 +374,7 @@ describe('Acceptance Type Converters', () => { const result = damlEquityCompensationAcceptanceToNative(baseDamlData); expect(result).toEqual({ + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', id: 'test-accept-001', date: '2024-06-15', security_id: 'test-sec-001', @@ -364,6 +385,7 @@ describe('Acceptance Type Converters', () => { const result = damlEquityCompensationAcceptanceToNative(damlDataWithComments); expect(result).toEqual({ + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', id: 'test-accept-002', date: '2024-06-20', security_id: 'test-sec-002', @@ -376,6 +398,7 @@ describe('Acceptance Type Converters', () => { describe('Round-trip conversion', () => { test('OCF → DAML → OCF preserves stockAcceptance data', () => { const original: OcfStockAcceptance = { + object_type: 'TX_STOCK_ACCEPTANCE', id: 'round-trip-001', date: '2024-07-01', security_id: 'rt-sec-001', @@ -390,6 +413,7 @@ describe('Acceptance Type Converters', () => { test('OCF → DAML → OCF preserves warrantAcceptance data', () => { const original: OcfWarrantAcceptance = { + object_type: 'TX_WARRANT_ACCEPTANCE', id: 'round-trip-002', date: '2024-07-02', security_id: 'rt-sec-002', @@ -404,6 +428,7 @@ describe('Acceptance Type Converters', () => { test('OCF → DAML → OCF preserves convertibleAcceptance data', () => { const original: OcfConvertibleAcceptance = { + object_type: 'TX_CONVERTIBLE_ACCEPTANCE', id: 'round-trip-003', date: '2024-07-03', security_id: 'rt-sec-003', @@ -418,6 +443,7 @@ describe('Acceptance Type Converters', () => { test('OCF → DAML → OCF preserves equityCompensationAcceptance data', () => { const original: OcfEquityCompensationAcceptance = { + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', id: 'round-trip-004', date: '2024-07-04', security_id: 'rt-sec-004', @@ -434,6 +460,7 @@ describe('Acceptance Type Converters', () => { test('OCF → DAML → OCF preserves data without optional comments', () => { const original: OcfStockAcceptance = { + object_type: 'TX_STOCK_ACCEPTANCE', id: 'round-trip-005', date: '2024-07-05', security_id: 'rt-sec-005', diff --git a/test/converters/convertibleCancellationConverters.test.ts b/test/converters/convertibleCancellationConverters.test.ts new file mode 100644 index 00000000..de5c4213 --- /dev/null +++ b/test/converters/convertibleCancellationConverters.test.ts @@ -0,0 +1,65 @@ +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { OcpValidationError } from '../../src/errors'; +import { getConvertibleCancellationAsOcf } from '../../src/functions/OpenCapTable/convertibleCancellation'; + +function createMockClient(createArgument: Record): LedgerJsonApiClient { + return { + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { + createdEvent: { + createArgument, + }, + }, + }), + } as unknown as LedgerJsonApiClient; +} + +describe('Convertible cancellation converters', () => { + test('the dedicated getter returns the canonical monetary amount', async () => { + const client = createMockClient({ + cancellation_data: { + id: 'convertible-cancellation-1', + date: '2026-07-09T00:00:00.000Z', + security_id: 'convertible-security-1', + amount: { amount: '1250.5000000000', currency: 'USD' }, + balance_security_id: 'convertible-security-balance-1', + reason_text: 'Partial repayment', + comments: ['Board approved'], + }, + }); + + const result = await getConvertibleCancellationAsOcf(client, { + contractId: 'convertible-cancellation-contract-1', + }); + + expect(result).toEqual({ + contractId: 'convertible-cancellation-contract-1', + event: { + object_type: 'TX_CONVERTIBLE_CANCELLATION', + id: 'convertible-cancellation-1', + date: '2026-07-09', + security_id: 'convertible-security-1', + amount: { amount: '1250.5', currency: 'USD' }, + balance_security_id: 'convertible-security-balance-1', + reason_text: 'Partial repayment', + comments: ['Board approved'], + }, + }); + }); + + test('the dedicated getter rejects a cancellation without an amount', async () => { + const client = createMockClient({ + cancellation_data: { + id: 'convertible-cancellation-2', + date: '2026-07-09T00:00:00.000Z', + security_id: 'convertible-security-2', + reason_text: 'Missing amount', + comments: [], + }, + }); + + await expect( + getConvertibleCancellationAsOcf(client, { contractId: 'convertible-cancellation-contract-2' }) + ).rejects.toThrow(OcpValidationError); + }); +}); diff --git a/test/converters/coreObjectReadValidation.test.ts b/test/converters/coreObjectReadValidation.test.ts new file mode 100644 index 00000000..cc2675c3 --- /dev/null +++ b/test/converters/coreObjectReadValidation.test.ts @@ -0,0 +1,60 @@ +import { damlDocumentDataToNative, damlStakeholderDataToNative, OcpValidationError } from '../../src'; + +const minimalStakeholder = { + id: 'stakeholder-read-1', + name: { legal_name: 'Ada Lovelace', first_name: 'Ada', last_name: 'Lovelace' }, + stakeholder_type: 'OcfStakeholderTypeIndividual', + issuer_assigned_id: null, + current_relationships: [], + current_status: null, + primary_contact: null, + contact_info: null, + addresses: [], + tax_ids: [], + comments: [], +}; + +const minimalDocument = { + id: 'document-read-1', + path: null, + uri: 'https://example.com/document.pdf', + md5: 'd7acc4c968bdff9bc9369b0c34703814', + related_objects: [], + comments: [], +}; + +function asDamlStakeholder(value: object): Parameters[0] { + return value as unknown as Parameters[0]; +} + +function asDamlDocument(value: object): Parameters[0] { + return value as unknown as Parameters[0]; +} + +describe('core DAML read converter required fields', () => { + test('returns complete canonical stakeholder and document objects', () => { + const stakeholder = damlStakeholderDataToNative(asDamlStakeholder(minimalStakeholder)); + const document = damlDocumentDataToNative(asDamlDocument(minimalDocument)); + + expect(stakeholder).toMatchObject({ + object_type: 'STAKEHOLDER', + id: minimalStakeholder.id, + name: { legal_name: minimalStakeholder.name.legal_name }, + }); + expect(document).toMatchObject({ object_type: 'DOCUMENT', id: minimalDocument.id }); + }); + + test.each([ + ['stakeholder id', () => damlStakeholderDataToNative(asDamlStakeholder({ ...minimalStakeholder, id: '' }))], + [ + 'stakeholder legal name', + () => + damlStakeholderDataToNative( + asDamlStakeholder({ ...minimalStakeholder, name: { ...minimalStakeholder.name, legal_name: '' } }) + ), + ], + ['document id', () => damlDocumentDataToNative(asDamlDocument({ ...minimalDocument, id: '' }))], + ])('rejects a missing %s instead of synthesizing an empty required field', (_field, convert) => { + expect(convert).toThrow(OcpValidationError); + }); +}); diff --git a/test/converters/exerciseConversionConverters.test.ts b/test/converters/exerciseConversionConverters.test.ts index e564d2ce..4d6185cf 100644 --- a/test/converters/exerciseConversionConverters.test.ts +++ b/test/converters/exerciseConversionConverters.test.ts @@ -74,6 +74,7 @@ describe('Exercise and Conversion Type Converters', () => { describe('WarrantExercise', () => { describe('OCF → DAML (convertToDaml)', () => { const validWarrantExerciseData: OcfWarrantExercise = { + object_type: 'TX_WARRANT_EXERCISE', id: 'we-001', date: '2024-01-15', security_id: 'warrant-sec-001', @@ -105,6 +106,7 @@ describe('Exercise and Conversion Type Converters', () => { test('handles optional fields as null', () => { const minimalData: OcfWarrantExercise = { + object_type: 'TX_WARRANT_EXERCISE', id: 'we-002', date: '2024-01-15', security_id: 'warrant-sec-003', @@ -259,6 +261,7 @@ describe('Exercise and Conversion Type Converters', () => { describe('ConvertibleConversion', () => { describe('OCF → DAML (convertToDaml)', () => { const validConvertibleConversionData: OcfConvertibleConversion = { + object_type: 'TX_CONVERTIBLE_CONVERSION', id: 'cc-001', date: '2024-02-20', reason_text: 'Automatic conversion at qualified financing', @@ -284,6 +287,7 @@ describe('Exercise and Conversion Type Converters', () => { test('handles optional fields as null', () => { const minimalData: OcfConvertibleConversion = { + object_type: 'TX_CONVERTIBLE_CONVERSION', id: 'cc-002', date: '2024-02-20', reason_text: 'Board-approved conversion', @@ -405,6 +409,7 @@ describe('Exercise and Conversion Type Converters', () => { describe('StockConversion', () => { describe('OCF → DAML (convertToDaml)', () => { const validStockConversionData: OcfStockConversion = { + object_type: 'TX_STOCK_CONVERSION', id: 'sc-001', date: '2024-03-10', security_id: 'stock-sec-001', @@ -438,6 +443,7 @@ describe('Exercise and Conversion Type Converters', () => { test('handles optional fields as null', () => { const minimalData: OcfStockConversion = { + object_type: 'TX_STOCK_CONVERSION', id: 'sc-002', date: '2024-03-10', security_id: 'stock-sec-003', diff --git a/test/converters/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index f4bbfebd..6b93a329 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -26,6 +26,7 @@ describe('Issuer Converters', () => { const input = { ...baseIssuerData, tax_ids: null, + object_type: 'ISSUER' as const, }; const result = normalizeIssuerData(input); @@ -37,6 +38,7 @@ describe('Issuer Converters', () => { const input = { ...baseIssuerData, // tax_ids is undefined + object_type: 'ISSUER' as const, }; const result = normalizeIssuerData(input); @@ -49,6 +51,7 @@ describe('Issuer Converters', () => { const input = { ...baseIssuerData, tax_ids: taxIds, + object_type: 'ISSUER' as const, }; const result = normalizeIssuerData(input); @@ -61,6 +64,7 @@ describe('Issuer Converters', () => { const input = { ...baseIssuerData, tax_ids: taxIds, + object_type: 'ISSUER' as const, }; const result = normalizeIssuerData(input); @@ -75,6 +79,7 @@ describe('Issuer Converters', () => { dba: 'Test DBA', country_subdivision_of_formation: 'DE', comments: ['comment 1'], + object_type: 'ISSUER' as const, }; const result = normalizeIssuerData(input); @@ -111,6 +116,7 @@ describe('Issuer Converters', () => { issuerData: { ...baseIssuerData, tax_ids: null, + object_type: 'ISSUER' as const, }, }; @@ -128,6 +134,7 @@ describe('Issuer Converters', () => { issuerData: { ...baseIssuerData, // tax_ids is undefined + object_type: 'ISSUER' as const, }, }; @@ -145,6 +152,7 @@ describe('Issuer Converters', () => { issuerData: { ...baseIssuerData, tax_ids: [], + object_type: 'ISSUER' as const, }, }; @@ -162,6 +170,7 @@ describe('Issuer Converters', () => { issuerData: { ...baseIssuerData, tax_ids: [{ country: 'US', tax_id: '12-3456789' }], + object_type: 'ISSUER' as const, }, }; diff --git a/test/converters/planSecurityConverters.test.ts b/test/converters/planSecurityConverters.test.ts index cbfbbf0b..2b41b5bf 100644 --- a/test/converters/planSecurityConverters.test.ts +++ b/test/converters/planSecurityConverters.test.ts @@ -13,15 +13,17 @@ * 4. Fields are properly transformed to DAML format */ -import { OcpValidationError } from '../../src/errors'; -import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; +import { OcpParseError, OcpValidationError } from '../../src/errors'; +import { planSecurityExerciseDataToDaml } from '../../src/functions/OpenCapTable/planSecurityExercise'; +import { planSecurityIssuanceDataToDaml } from '../../src/functions/OpenCapTable/planSecurityIssuance'; import type { OcfPlanSecurityExercise, OcfPlanSecurityIssuance } from '../../src/types/native'; describe('PlanSecurity Type Converters', () => { - describe('OCF→DAML Converters (convertToDaml)', () => { + describe('standalone legacy OCF→DAML converters', () => { describe('planSecurityIssuance', () => { it('converts OPTION plan security type to OcfCompensationTypeOption', () => { const input: OcfPlanSecurityIssuance = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', id: 'psi-001', date: '2025-01-15', security_id: 'sec-001', @@ -42,7 +44,7 @@ describe('PlanSecurity Type Converters', () => { comments: ['Initial grant'], }; - const result = convertToDaml('planSecurityIssuance', input); + const result = planSecurityIssuanceDataToDaml(input); // Verify compensation_type mapping from plan_security_type expect(result.compensation_type).toBe('OcfCompensationTypeOption'); @@ -83,6 +85,7 @@ describe('PlanSecurity Type Converters', () => { it('preserves supported equity-compensation fields when provided', () => { const input: OcfPlanSecurityIssuance = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', id: 'psi-extended', date: '2025-01-15', security_id: 'sec-extended', @@ -102,7 +105,7 @@ describe('PlanSecurity Type Converters', () => { security_law_exemptions: [], }; - const result = convertToDaml('planSecurityIssuance', input); + const result = planSecurityIssuanceDataToDaml(input); expect(result.base_price).toEqual({ amount: '0.25', currency: 'USD' }); expect(result.early_exercisable).toBe(true); @@ -115,6 +118,7 @@ describe('PlanSecurity Type Converters', () => { it('converts RSU plan security type to OcfCompensationTypeRSU', () => { const input: OcfPlanSecurityIssuance = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', id: 'psi-002', date: '2025-02-01', security_id: 'sec-002', @@ -128,7 +132,7 @@ describe('PlanSecurity Type Converters', () => { security_law_exemptions: [], }; - const result = convertToDaml('planSecurityIssuance', input); + const result = planSecurityIssuanceDataToDaml(input); // Verify RSU compensation_type mapping expect(result.compensation_type).toBe('OcfCompensationTypeRSU'); @@ -142,8 +146,9 @@ describe('PlanSecurity Type Converters', () => { expect(result.exercise_price).toBeNull(); }); - it('throws OcpValidationError when compensation_type is missing', () => { + it('throws OcpParseError when compensation_type is missing', () => { const input = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', id: 'psi-003', date: '2025-03-01', security_id: 'sec-003', @@ -156,19 +161,20 @@ describe('PlanSecurity Type Converters', () => { security_law_exemptions: [], } as unknown as OcfPlanSecurityIssuance; - expect(() => convertToDaml('planSecurityIssuance', input)).toThrow(OcpValidationError); + expect(() => planSecurityIssuanceDataToDaml(input)).toThrow(OcpParseError); try { - convertToDaml('planSecurityIssuance', input); + planSecurityIssuanceDataToDaml(input); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - const validationError = error as OcpValidationError; - expect(validationError.fieldPath).toContain('compensation_type'); + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.source).toContain('compensation_type'); } }); it('throws OcpValidationError when id is missing', () => { const input = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', date: '2025-01-15', security_id: 'sec-001', custom_id: 'custom-001', @@ -180,11 +186,12 @@ describe('PlanSecurity Type Converters', () => { security_law_exemptions: [], } as unknown as OcfPlanSecurityIssuance; - expect(() => convertToDaml('planSecurityIssuance', input)).toThrow(OcpValidationError); + expect(() => planSecurityIssuanceDataToDaml(input)).toThrow(OcpValidationError); }); - it('throws OcpValidationError when compensation_type is undefined', () => { + it('throws OcpParseError when compensation_type is undefined', () => { const input = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', id: 'psi-004', date: '2025-01-15', security_id: 'sec-001', @@ -197,19 +204,20 @@ describe('PlanSecurity Type Converters', () => { security_law_exemptions: [], } as unknown as OcfPlanSecurityIssuance; - expect(() => convertToDaml('planSecurityIssuance', input)).toThrow(OcpValidationError); + expect(() => planSecurityIssuanceDataToDaml(input)).toThrow(OcpParseError); try { - convertToDaml('planSecurityIssuance', input); + planSecurityIssuanceDataToDaml(input); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - const validationError = error as OcpValidationError; - expect(validationError.fieldPath).toContain('compensation_type'); + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.source).toContain('compensation_type'); } }); it('handles minimal required fields', () => { const input: OcfPlanSecurityIssuance = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', id: 'psi-minimal', date: '2025-05-01', security_id: 'sec-minimal', @@ -224,7 +232,7 @@ describe('PlanSecurity Type Converters', () => { security_law_exemptions: [], }; - const result = convertToDaml('planSecurityIssuance', input); + const result = planSecurityIssuanceDataToDaml(input); expect(result.id).toBe('psi-minimal'); expect(result.compensation_type).toBe('OcfCompensationTypeOption'); @@ -245,6 +253,7 @@ describe('PlanSecurity Type Converters', () => { describe('planSecurityExercise', () => { it('converts plan security exercise with all fields', () => { const input: OcfPlanSecurityExercise = { + object_type: 'TX_PLAN_SECURITY_EXERCISE', id: 'pse-001', date: '2026-01-15', security_id: 'sec-001', @@ -254,7 +263,7 @@ describe('PlanSecurity Type Converters', () => { comments: ['Partial exercise'], }; - const result = convertToDaml('planSecurityExercise', input); + const result = planSecurityExerciseDataToDaml(input); expect(result.id).toBe('pse-001'); expect(result.security_id).toBe('sec-001'); @@ -269,6 +278,7 @@ describe('PlanSecurity Type Converters', () => { it('converts plan security exercise with minimal fields', () => { const input: OcfPlanSecurityExercise = { + object_type: 'TX_PLAN_SECURITY_EXERCISE', id: 'pse-002', date: '2026-02-01', security_id: 'sec-002', @@ -276,7 +286,7 @@ describe('PlanSecurity Type Converters', () => { resulting_security_ids: ['result-003'], }; - const result = convertToDaml('planSecurityExercise', input); + const result = planSecurityExerciseDataToDaml(input); expect(result.id).toBe('pse-002'); expect(result.security_id).toBe('sec-002'); @@ -290,25 +300,27 @@ describe('PlanSecurity Type Converters', () => { it('throws OcpValidationError when id is missing', () => { const input = { + object_type: 'TX_PLAN_SECURITY_EXERCISE', date: '2026-01-15', security_id: 'sec-001', quantity: '2500', resulting_security_ids: ['result-001'], } as OcfPlanSecurityExercise; - expect(() => convertToDaml('planSecurityExercise', input)).toThrow(OcpValidationError); + expect(() => planSecurityExerciseDataToDaml(input)).toThrow(OcpValidationError); try { - convertToDaml('planSecurityExercise', input); + planSecurityExerciseDataToDaml(input); } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); const validationError = error as OcpValidationError; - expect(validationError.fieldPath).toBe('id'); + expect(validationError.fieldPath).toContain('id'); } }); it('handles numeric quantity values', () => { const input: OcfPlanSecurityExercise = { + object_type: 'TX_PLAN_SECURITY_EXERCISE', id: 'pse-numeric', date: '2026-03-01', security_id: 'sec-numeric', @@ -316,7 +328,7 @@ describe('PlanSecurity Type Converters', () => { resulting_security_ids: ['result-004'], }; - const result = convertToDaml('planSecurityExercise', input); + const result = planSecurityExerciseDataToDaml(input); // Quantity should be converted to string for DAML expect(result.quantity).toBe('5000'); diff --git a/test/converters/stockClassAdjustmentConverters.test.ts b/test/converters/stockClassAdjustmentConverters.test.ts index 8fb0a8fe..f31abbff 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -30,6 +30,7 @@ describe('Stock Class Adjustment Converters', () => { describe('OCF to DAML (ocfToDaml)', () => { describe('stockClassSplit', () => { const baseData: OcfStockClassSplit = { + object_type: 'TX_STOCK_CLASS_SPLIT', id: 'split-001', date: '2024-01-15', stock_class_id: 'class-001', @@ -106,6 +107,7 @@ describe('Stock Class Adjustment Converters', () => { describe('stockClassConversionRatioAdjustment', () => { const baseData: OcfStockClassConversionRatioAdjustment = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', id: 'adj-001', date: '2024-02-01', stock_class_id: 'class-002', @@ -184,6 +186,7 @@ describe('Stock Class Adjustment Converters', () => { describe('stockConsolidation', () => { const baseData: OcfStockConsolidation = { + object_type: 'TX_STOCK_CONSOLIDATION', id: 'consolidation-001', date: '2024-03-01', security_ids: ['sec-001', 'sec-002', 'sec-003'], @@ -228,6 +231,7 @@ describe('Stock Class Adjustment Converters', () => { describe('stockReissuance', () => { const baseData: OcfStockReissuance = { + object_type: 'TX_STOCK_REISSUANCE', id: 'reissue-001', date: '2024-04-01', security_id: 'sec-cancelled-001', @@ -295,6 +299,7 @@ describe('Stock Class Adjustment Converters', () => { const result = damlStockClassSplitToNative(damlData); expect(result).toEqual({ + object_type: 'TX_STOCK_CLASS_SPLIT', id: 'split-001', date: '2024-01-15', stock_class_id: 'class-001', @@ -341,6 +346,7 @@ describe('Stock Class Adjustment Converters', () => { const result = damlStockClassConversionRatioAdjustmentToNative(damlData); expect(result).toEqual({ + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', id: 'adj-001', date: '2024-02-01', stock_class_id: 'class-002', @@ -372,6 +378,7 @@ describe('Stock Class Adjustment Converters', () => { const result = damlStockConsolidationToNative(damlData); expect(result).toEqual({ + object_type: 'TX_STOCK_CONSOLIDATION', id: 'consolidation-001', date: '2024-03-01', security_ids: ['sec-001', 'sec-002'], @@ -412,6 +419,7 @@ describe('Stock Class Adjustment Converters', () => { const result = damlStockReissuanceToNative(damlData); expect(result).toEqual({ + object_type: 'TX_STOCK_REISSUANCE', id: 'reissue-001', date: '2024-04-01', security_id: 'sec-cancelled-001', diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index 5032b58c..108c02f7 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -70,6 +70,7 @@ describe('StockClass Converters', () => { describe('OCF to DAML (convertToDaml stockClass)', () => { const baseData: OcfStockClass = { + object_type: 'STOCK_CLASS', id: 'class-001', name: 'Common Stock', class_type: 'COMMON', diff --git a/test/converters/stockPlanConverters.test.ts b/test/converters/stockPlanConverters.test.ts index f541b0cc..af8fc4e1 100644 --- a/test/converters/stockPlanConverters.test.ts +++ b/test/converters/stockPlanConverters.test.ts @@ -15,6 +15,7 @@ describe('StockPlan Converters', () => { describe('OCF → DAML (stockPlanDataToDaml)', () => { test('converts minimal stock plan data', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-001', plan_name: 'Employee Stock Option Pool', initial_shares_reserved: '1000000', @@ -31,6 +32,7 @@ describe('StockPlan Converters', () => { test('converts stock plan with all optional fields', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-002', plan_name: 'Full Stock Plan', initial_shares_reserved: '2000000', @@ -53,6 +55,7 @@ describe('StockPlan Converters', () => { test('handles numeric initial_shares_reserved', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-003', plan_name: 'Test Plan', initial_shares_reserved: '500000', @@ -66,6 +69,7 @@ describe('StockPlan Converters', () => { test('throws OcpValidationError when id is missing', () => { const ocfData = { + object_type: 'STOCK_PLAN', id: '', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -79,6 +83,7 @@ describe('StockPlan Converters', () => { describe('stock_class_ids field handling (incl. deprecated stock_class_id)', () => { test('passes through stock_class_ids directly', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-001', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -94,6 +99,7 @@ describe('StockPlan Converters', () => { // Reproduces the DEV-MEZ incident: OCF schema allows stock_class_id (deprecated) // as a valid alternative to stock_class_ids via oneOf. const ocfData = { + object_type: 'STOCK_PLAN', id: 'stock-plan_eca1ad4ba4d9', plan_name: 'Stock Option and Equity Incentive Plan', initial_shares_reserved: '900000', @@ -114,6 +120,7 @@ describe('StockPlan Converters', () => { test('prefers stock_class_ids over deprecated stock_class_id when both present', () => { const ocfData = { + object_type: 'STOCK_PLAN', id: 'sp-both', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -128,6 +135,7 @@ describe('StockPlan Converters', () => { test('throws OcpValidationError when neither stock_class_ids nor stock_class_id is present', () => { const ocfData = { + object_type: 'STOCK_PLAN', id: 'sp-missing', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -139,6 +147,7 @@ describe('StockPlan Converters', () => { test('throws OcpValidationError when stock_class_ids is empty and stock_class_id is absent', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-empty', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -152,6 +161,7 @@ describe('StockPlan Converters', () => { describe('cancellation behavior enum conversion', () => { test('converts RETIRE cancellation behavior', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-cancel-001', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -166,6 +176,7 @@ describe('StockPlan Converters', () => { test('converts RETURN_TO_POOL cancellation behavior', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-cancel-002', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -180,6 +191,7 @@ describe('StockPlan Converters', () => { test('converts HOLD_AS_CAPITAL_STOCK cancellation behavior', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-cancel-003', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -194,6 +206,7 @@ describe('StockPlan Converters', () => { test('converts DEFINED_PER_PLAN_SECURITY cancellation behavior', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-cancel-004', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -208,6 +221,7 @@ describe('StockPlan Converters', () => { test('handles undefined cancellation behavior', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-cancel-005', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -224,6 +238,7 @@ describe('StockPlan Converters', () => { describe('convertToDaml dispatches correctly', () => { test('converts stock plan via convertToDaml', () => { const data: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-batch-001', plan_name: 'Batch Plan', initial_shares_reserved: '1000000', diff --git a/test/converters/transferConverters.test.ts b/test/converters/transferConverters.test.ts index 33631341..eb964438 100644 --- a/test/converters/transferConverters.test.ts +++ b/test/converters/transferConverters.test.ts @@ -22,6 +22,7 @@ describe('Transfer Type Converters', () => { describe('stockTransfer', () => { it('converts stock transfer with all fields', () => { const input: OcfStockTransfer = { + object_type: 'TX_STOCK_TRANSFER', id: 'st-001', date: '2025-08-05', security_id: 'sec-001', @@ -47,6 +48,7 @@ describe('Transfer Type Converters', () => { it('converts stock transfer with minimal fields', () => { const input: OcfStockTransfer = { + object_type: 'TX_STOCK_TRANSFER', id: 'st-002', date: '2025-08-06', security_id: 'sec-002', @@ -66,6 +68,7 @@ describe('Transfer Type Converters', () => { it('throws OcpValidationError when resulting_security_ids is empty', () => { const input: OcfStockTransfer = { + object_type: 'TX_STOCK_TRANSFER', id: 'st-error-1', date: '2025-08-05', security_id: 'sec-001', @@ -87,6 +90,7 @@ describe('Transfer Type Converters', () => { describe('convertibleTransfer', () => { it('converts convertible transfer with all fields', () => { const input: OcfConvertibleTransfer = { + object_type: 'TX_CONVERTIBLE_TRANSFER', id: 'ct-001', date: '2025-08-10', security_id: 'conv-sec-001', @@ -109,6 +113,7 @@ describe('Transfer Type Converters', () => { it('converts convertible transfer with minimal fields', () => { const input: OcfConvertibleTransfer = { + object_type: 'TX_CONVERTIBLE_TRANSFER', id: 'ct-002', date: '2025-08-11', security_id: 'conv-sec-002', @@ -126,6 +131,7 @@ describe('Transfer Type Converters', () => { it('throws OcpValidationError when id is missing', () => { const input = { + object_type: 'TX_CONVERTIBLE_TRANSFER', date: '2025-08-10', security_id: 'conv-sec-001', amount: { amount: '50000', currency: 'USD' }, @@ -144,6 +150,7 @@ describe('Transfer Type Converters', () => { it('throws OcpValidationError when resulting_security_ids is empty', () => { const input: OcfConvertibleTransfer = { + object_type: 'TX_CONVERTIBLE_TRANSFER', id: 'ct-error-1', date: '2025-08-10', security_id: 'conv-sec-001', @@ -165,6 +172,7 @@ describe('Transfer Type Converters', () => { describe('equityCompensationTransfer', () => { it('converts equity compensation transfer with all fields', () => { const input: OcfEquityCompensationTransfer = { + object_type: 'TX_EQUITY_COMPENSATION_TRANSFER', id: 'ect-001', date: '2025-08-15', security_id: 'eq-sec-001', @@ -187,6 +195,7 @@ describe('Transfer Type Converters', () => { it('converts equity compensation transfer with minimal fields', () => { const input: OcfEquityCompensationTransfer = { + object_type: 'TX_EQUITY_COMPENSATION_TRANSFER', id: 'ect-002', date: '2025-08-16', security_id: 'eq-sec-002', @@ -204,6 +213,7 @@ describe('Transfer Type Converters', () => { it('throws OcpValidationError when id is missing', () => { const input = { + object_type: 'TX_EQUITY_COMPENSATION_TRANSFER', date: '2025-08-15', security_id: 'eq-sec-001', quantity: '10000', @@ -222,6 +232,7 @@ describe('Transfer Type Converters', () => { it('throws OcpValidationError when resulting_security_ids is empty', () => { const input: OcfEquityCompensationTransfer = { + object_type: 'TX_EQUITY_COMPENSATION_TRANSFER', id: 'ect-error-1', date: '2025-08-15', security_id: 'eq-sec-001', @@ -243,6 +254,7 @@ describe('Transfer Type Converters', () => { describe('warrantTransfer', () => { it('converts warrant transfer with all fields', () => { const input: OcfWarrantTransfer = { + object_type: 'TX_WARRANT_TRANSFER', id: 'wt-001', date: '2025-08-20', security_id: 'warrant-sec-001', @@ -265,6 +277,7 @@ describe('Transfer Type Converters', () => { it('converts warrant transfer with minimal fields', () => { const input: OcfWarrantTransfer = { + object_type: 'TX_WARRANT_TRANSFER', id: 'wt-002', date: '2025-08-21', security_id: 'warrant-sec-002', @@ -282,6 +295,7 @@ describe('Transfer Type Converters', () => { it('throws OcpValidationError when id is missing', () => { const input = { + object_type: 'TX_WARRANT_TRANSFER', date: '2025-08-20', security_id: 'warrant-sec-001', quantity: '2500', @@ -300,6 +314,7 @@ describe('Transfer Type Converters', () => { it('throws OcpValidationError when resulting_security_ids is empty', () => { const input: OcfWarrantTransfer = { + object_type: 'TX_WARRANT_TRANSFER', id: 'wt-error-1', date: '2025-08-20', security_id: 'warrant-sec-001', @@ -322,6 +337,7 @@ describe('Transfer Type Converters', () => { describe('Numeric quantity handling', () => { it('handles string quantity', () => { const input: OcfStockTransfer = { + object_type: 'TX_STOCK_TRANSFER', id: 'st-num-1', date: '2025-08-05', security_id: 'sec-001', @@ -335,6 +351,7 @@ describe('Transfer Type Converters', () => { it('handles string quantity', () => { const input: OcfStockTransfer = { + object_type: 'TX_STOCK_TRANSFER', id: 'st-num-2', date: '2025-08-05', security_id: 'sec-001', @@ -350,6 +367,7 @@ describe('Transfer Type Converters', () => { describe('Date conversion', () => { it('converts date string to DAML time format', () => { const input: OcfStockTransfer = { + object_type: 'TX_STOCK_TRANSFER', id: 'st-date-1', date: '2025-08-05', security_id: 'sec-001', @@ -366,6 +384,7 @@ describe('Transfer Type Converters', () => { describe('Comments handling', () => { it('filters empty comments array', () => { const input: OcfStockTransfer = { + object_type: 'TX_STOCK_TRANSFER', id: 'st-comments-1', date: '2025-08-05', security_id: 'sec-001', @@ -380,6 +399,7 @@ describe('Transfer Type Converters', () => { it('preserves non-empty comments', () => { const input: OcfStockTransfer = { + object_type: 'TX_STOCK_TRANSFER', id: 'st-comments-2', date: '2025-08-05', security_id: 'sec-001', diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index dd72a7bb..bccab1ab 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -44,6 +44,7 @@ describe('Valuation Converters', () => { describe('OCF → DAML (valuationDataToDaml)', () => { test('converts minimal valuation data', () => { const ocfData: OcfValuation = { + object_type: 'VALUATION', id: 'val-001', stock_class_id: 'sc-001', price_per_share: { amount: '1.50', currency: 'USD' }, @@ -62,6 +63,7 @@ describe('Valuation Converters', () => { test('converts valuation with all optional fields', () => { const ocfData: OcfValuation = { + object_type: 'VALUATION', id: 'val-002', stock_class_id: 'sc-002', price_per_share: { amount: '2.00', currency: 'USD' }, @@ -83,6 +85,7 @@ describe('Valuation Converters', () => { test('throws error when id is missing', () => { const ocfData = { + object_type: 'VALUATION', id: '', stock_class_id: 'sc-001', price_per_share: { amount: '1.50', currency: 'USD' }, @@ -96,6 +99,7 @@ describe('Valuation Converters', () => { test('handles string amount', () => { const ocfData: OcfValuation = { + object_type: 'VALUATION', id: 'val-003', stock_class_id: 'sc-003', price_per_share: { amount: '1.5', currency: 'USD' }, @@ -189,6 +193,7 @@ describe('Valuation Converters', () => { test('OCF → DAML → OCF preserves data', () => { // Use a value without trailing zeros to avoid normalization differences const originalOcf: OcfValuation = { + object_type: 'VALUATION', id: 'val-roundtrip', stock_class_id: 'sc-roundtrip', price_per_share: { amount: '3.5', currency: 'USD' }, @@ -220,6 +225,7 @@ describe('VestingStart Converters', () => { describe('OCF → DAML (vestingStartDataToDaml)', () => { test('converts minimal vesting start data', () => { const ocfData: OcfVestingStart = { + object_type: 'TX_VESTING_START', id: 'vs-001', date: '2024-01-01', security_id: 'sec-001', @@ -236,6 +242,7 @@ describe('VestingStart Converters', () => { test('converts vesting start with comments', () => { const ocfData: OcfVestingStart = { + object_type: 'TX_VESTING_START', id: 'vs-002', date: '2024-02-01', security_id: 'sec-002', @@ -250,6 +257,7 @@ describe('VestingStart Converters', () => { test('throws error when id is missing', () => { const ocfData = { + object_type: 'TX_VESTING_START', id: '', date: '2024-01-01', security_id: 'sec-001', @@ -300,6 +308,7 @@ describe('VestingTerms Converters', () => { describe('OCF -> DAML (vestingTermsDataToDaml)', () => { test('defaults portion.remainder to false when omitted', () => { const ocfData = { + object_type: 'VESTING_TERMS', id: 'vt-001', name: 'Standard Vesting', description: '4-year vesting with cliff', @@ -336,6 +345,7 @@ describe('VestingTerms Converters', () => { test('preserves provided portion.remainder value', () => { const ocfData: OcfVestingTerms = { + object_type: 'VESTING_TERMS', id: 'vt-002', name: 'Remainder Vesting', description: 'Remainder flag explicitly set', @@ -371,6 +381,7 @@ describe('VestingEvent Converters', () => { describe('OCF → DAML (vestingEventDataToDaml)', () => { test('converts minimal vesting event data', () => { const ocfData: OcfVestingEvent = { + object_type: 'TX_VESTING_EVENT', id: 've-001', date: '2024-06-01', security_id: 'sec-001', @@ -387,6 +398,7 @@ describe('VestingEvent Converters', () => { test('converts vesting event with comments', () => { const ocfData: OcfVestingEvent = { + object_type: 'TX_VESTING_EVENT', id: 've-002', date: '2024-07-01', security_id: 'sec-002', @@ -401,6 +413,7 @@ describe('VestingEvent Converters', () => { test('throws error when id is missing', () => { const ocfData = { + object_type: 'TX_VESTING_EVENT', id: '', date: '2024-06-01', security_id: 'sec-001', @@ -451,6 +464,7 @@ describe('VestingAcceleration Converters', () => { describe('OCF → DAML (vestingAccelerationDataToDaml)', () => { test('converts minimal vesting acceleration data', () => { const ocfData: OcfVestingAcceleration = { + object_type: 'TX_VESTING_ACCELERATION', id: 'va-001', date: '2024-12-01', security_id: 'sec-001', @@ -469,6 +483,7 @@ describe('VestingAcceleration Converters', () => { test('converts vesting acceleration with all fields', () => { const ocfData: OcfVestingAcceleration = { + object_type: 'TX_VESTING_ACCELERATION', id: 'va-002', date: '2024-12-15', security_id: 'sec-002', @@ -485,6 +500,7 @@ describe('VestingAcceleration Converters', () => { test('handles numeric quantity', () => { const ocfData: OcfVestingAcceleration = { + object_type: 'TX_VESTING_ACCELERATION', id: 'va-003', date: '2024-12-01', security_id: 'sec-003', @@ -499,6 +515,7 @@ describe('VestingAcceleration Converters', () => { test('throws error when id is missing', () => { const ocfData = { + object_type: 'TX_VESTING_ACCELERATION', id: '', date: '2024-12-01', security_id: 'sec-001', @@ -567,6 +584,7 @@ describe('VestingAcceleration Converters', () => { describe('round-trip conversion', () => { test('OCF → DAML → OCF preserves data', () => { const originalOcf: OcfVestingAcceleration = { + object_type: 'TX_VESTING_ACCELERATION', id: 'va-roundtrip', date: '2024-12-31', security_id: 'sec-roundtrip', @@ -652,6 +670,7 @@ describe('VestingTerms drift regression', () => { test('round-trip OCF → DAML → OCF preserves remainder when DAML has it', () => { const ocfInput: OcfVestingTerms = { + object_type: 'VESTING_TERMS', id: 'vt-rt-001', name: 'Standard Vesting', description: '4-year vesting with cliff', @@ -678,6 +697,7 @@ describe('VestingTerms drift regression', () => { test('round-trip OCF → DAML → OCF preserves omitted comments', () => { const ocfInput: OcfVestingTerms = { + object_type: 'VESTING_TERMS', id: 'vt-rt-002', name: 'Standard Vesting', description: '4-year vesting with cliff', diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index f8522277..10073d8a 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -16,7 +16,7 @@ function roundTrip(ocfInput: Parameters[0]): R const daml = warrantIssuanceDataToDaml(ocfInput); // daml is the DAML representation. Convert it back via the readback function. const native = damlWarrantIssuanceDataToNative(daml); - return { object_type: 'TX_WARRANT_ISSUANCE', ...native }; + return { ...native, object_type: 'TX_WARRANT_ISSUANCE' }; } describe('WarrantIssuance round-trip equivalence', () => { @@ -46,10 +46,11 @@ describe('WarrantIssuance round-trip equivalence', () => { }, }, ], + object_type: 'TX_WARRANT_ISSUANCE' as const, }; test('basic warrant issuance survives round-trip', () => { - const dbData = { object_type: 'TX_WARRANT_ISSUANCE', ...baseWarrantIssuance } as Record; + const dbData = { ...baseWarrantIssuance, object_type: 'TX_WARRANT_ISSUANCE' } as Record; const cantonData = roundTrip(baseWarrantIssuance); expect(ocfDeepEqual(dbData, cantonData)).toBe(true); @@ -58,9 +59,9 @@ describe('WarrantIssuance round-trip equivalence', () => { test('warrant issuance with numeric amount as JS number survives round-trip', () => { // DB JSONB can store amount as a number instead of a string const dbData = { - object_type: 'TX_WARRANT_ISSUANCE', ...baseWarrantIssuance, purchase_price: { amount: 22500, currency: 'USD' }, + object_type: 'TX_WARRANT_ISSUANCE', }; const cantonData = roundTrip(baseWarrantIssuance); @@ -69,7 +70,7 @@ describe('WarrantIssuance round-trip equivalence', () => { test('warrant issuance with undefined quantity and no quantity_source survives round-trip', () => { const input = { ...baseWarrantIssuance }; - const dbData = { object_type: 'TX_WARRANT_ISSUANCE', ...input }; + const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' }; const cantonData = roundTrip(input); expect(ocfDeepEqual(dbData, cantonData)).toBe(true); @@ -80,7 +81,7 @@ describe('WarrantIssuance round-trip equivalence', () => { // The OCF-to-DAML converter must treat null the same as undefined to avoid // injecting quantity_source: UNSPECIFIED that the DB doesn't have. const input = { ...baseWarrantIssuance, quantity: null as unknown as string }; - const dbData = { object_type: 'TX_WARRANT_ISSUANCE', ...input } as Record; + const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' } as Record; const cantonData = roundTrip(input); expect(ocfDeepEqual(dbData, cantonData)).toBe(true); @@ -91,7 +92,7 @@ describe('WarrantIssuance round-trip equivalence', () => { // The OCF-to-DAML converter sets quantity_source: OcfQuantityUnspecified, // and the readback must include it for the comparison to pass. const input = { ...baseWarrantIssuance, quantity_source: 'UNSPECIFIED' as const }; - const dbData = { object_type: 'TX_WARRANT_ISSUANCE', ...input }; + const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' }; const cantonData = roundTrip(input); expect(ocfDeepEqual(dbData as Record, cantonData)).toBe(true); @@ -103,7 +104,7 @@ describe('WarrantIssuance round-trip equivalence', () => { quantity: '1000', quantity_source: 'INSTRUMENT_FIXED' as const, }; - const dbData = { object_type: 'TX_WARRANT_ISSUANCE', ...input }; + const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' }; const cantonData = roundTrip(input); expect(ocfDeepEqual(dbData as Record, cantonData)).toBe(true); @@ -111,7 +112,7 @@ describe('WarrantIssuance round-trip equivalence', () => { test('warrant issuance with empty comments array survives round-trip', () => { const input = { ...baseWarrantIssuance, comments: [] as string[] }; - const dbData = { object_type: 'TX_WARRANT_ISSUANCE', ...input }; + const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' }; const cantonData = roundTrip(input); expect(ocfDeepEqual(dbData, cantonData)).toBe(true); @@ -122,7 +123,6 @@ describe('WarrantIssuance round-trip equivalence', () => { // The comparison must treat null as undefined-like. const input = { ...baseWarrantIssuance }; const dbData = { - object_type: 'TX_WARRANT_ISSUANCE', ...input, exercise_triggers: [ { @@ -133,6 +133,7 @@ describe('WarrantIssuance round-trip equivalence', () => { }, }, ], + object_type: 'TX_WARRANT_ISSUANCE', }; const cantonData = roundTrip(input); @@ -147,7 +148,7 @@ describe('WarrantIssuance round-trip equivalence', () => { consideration_text: 'Cash and services', vestings: [{ date: '2024-01-01', amount: '100' }], }; - const dbData = { object_type: 'TX_WARRANT_ISSUANCE', ...input }; + const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' }; const cantonData = roundTrip(input); expect(ocfDeepEqual(dbData as Record, cantonData)).toBe(true); @@ -221,7 +222,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(sr.conversion_price.amount).toBe('10'); expect(sr.conversion_price.currency).toBe('USD'); - const dbData = { object_type: 'TX_WARRANT_ISSUANCE', ...input } as Record; + const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' } as Record; const cantonData = roundTrip(input); expect(ocfDeepEqual(dbData, cantonData)).toBe(true); }); @@ -387,7 +388,6 @@ describe('WarrantIssuance round-trip equivalence', () => { }; // DB stores the quantity as a number const dbData = { - object_type: 'TX_WARRANT_ISSUANCE', ...input, exercise_triggers: [ { @@ -401,6 +401,7 @@ describe('WarrantIssuance round-trip equivalence', () => { }, }, ], + object_type: 'TX_WARRANT_ISSUANCE', }; const cantonData = roundTrip(input); diff --git a/test/createOcf/stockIssuanceReadConversions.test.ts b/test/createOcf/stockIssuanceReadConversions.test.ts index d4541e41..9948f4f2 100644 --- a/test/createOcf/stockIssuanceReadConversions.test.ts +++ b/test/createOcf/stockIssuanceReadConversions.test.ts @@ -55,6 +55,14 @@ describe('damlStockIssuanceDataToNative', () => { }); describe('required field extraction', () => { + test.each([undefined, null, ''])('rejects missing or invalid id %p', (id) => { + const daml = makeMinimalDamlStockIssuance({ id }); + + expect(() => damlStockIssuanceDataToNative(daml as Parameters[0])).toThrow( + 'stockIssuance.id' + ); + }); + test('extracts all required fields correctly', () => { const daml = makeMinimalDamlStockIssuance(); const result = damlStockIssuanceDataToNative(daml as Parameters[0]); diff --git a/test/declarations/damlReadDispatch.types.ts b/test/declarations/damlReadDispatch.types.ts new file mode 100644 index 00000000..2b92afef --- /dev/null +++ b/test/declarations/damlReadDispatch.types.ts @@ -0,0 +1,31 @@ +/** Built-declaration contracts for generated DAML read dispatch. */ + +import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { + convertToOcf, + decodeDamlEntityData, + ENTITY_TEMPLATE_ID_MAP, + type DamlDataTypeFor, + type OcfStakeholder, +} from '../../dist'; + +declare const stakeholderDamlData: DamlDataTypeFor<'stakeholder'>; +declare const stockClassDamlData: DamlDataTypeFor<'stockClass'>; +declare const unknownLedgerData: unknown; + +const stakeholder: OcfStakeholder = convertToOcf('stakeholder', stakeholderDamlData); +const decodedStakeholder: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData = decodeDamlEntityData( + 'stakeholder', + unknownLedgerData +); +const stakeholderTemplateId: string = ENTITY_TEMPLATE_ID_MAP.stakeholder; + +void stakeholder; +void decodedStakeholder; +void stakeholderTemplateId; + +// @ts-expect-error the entity kind and generated DAML payload must remain correlated +convertToOcf('stakeholder', stockClassDamlData); + +// @ts-expect-error unsupported entity kinds have no registered generated template +ENTITY_TEMPLATE_ID_MAP.planSecurityIssuance; diff --git a/test/declarations/normalization.types.ts b/test/declarations/normalization.types.ts new file mode 100644 index 00000000..7186140a --- /dev/null +++ b/test/declarations/normalization.types.ts @@ -0,0 +1,36 @@ +/** Compile-time contracts for canonicalization helpers in the built SDK declarations. */ + +import { + deepNormalizeNumericStrings, + normalizeEntityType, + normalizeObjectType, + normalizeOcfData, + type OcfPlanSecurityIssuance, +} from '../../dist'; + +const normalizedNumericString: string = deepNormalizeNumericStrings('1.00' as const); +void normalizedNumericString; + +// @ts-expect-error built declarations must not preserve a numeric string literal that can be rewritten +const staleNumericLiteral: '1.00' = deepNormalizeNumericStrings('1.00' as const); +void staleNumericLiteral; + +const normalizedEntity = normalizeEntityType('planSecurityIssuance'); +const exactEntity: 'equityCompensationIssuance' = normalizedEntity; +void exactEntity; + +const normalizedObjectType = normalizeObjectType('TX_PLAN_SECURITY_ISSUANCE'); +const exactObjectType: 'TX_EQUITY_COMPENSATION_ISSUANCE' = normalizedObjectType; +void exactObjectType; + +const normalizedLegacyEvent = normalizeObjectType('TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT'); +const exactLegacyEvent: 'CE_STAKEHOLDER_RELATIONSHIP' = normalizedLegacyEvent; +void exactLegacyEvent; + +declare const legacyIssuance: OcfPlanSecurityIssuance; +const normalizedData: Record = normalizeOcfData(legacyIssuance); +void normalizedData; + +// @ts-expect-error built declarations must not claim normalization preserves a legacy object shape +const unsoundLegacyClaim: OcfPlanSecurityIssuance = normalizeOcfData(legacyIssuance); +void unsoundLegacyClaim; diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 3718de5f..c51dbf76 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -4,16 +4,49 @@ import { convertToDaml, type CapTableBatch, type CapTableBatchOperations, + type OcfCreateOperation, + type OcfEntityDataMap, + type OcfEntityType, + type OcfFinancing, type OcfIssuer, + type OcfObject, type OcfStakeholder, + type OcfStockAcceptance, type OcfStockClass, + type OcfVestingEvent, + type OcfVestingStart, + type OcfWarrantAcceptance, } from '../../dist'; +type Assert = T; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type IntendedCanonicalOcfObject = OcfEntityDataMap[OcfEntityType] | OcfFinancing; +type LegacyPlanSecurityObjectType = + | 'TX_PLAN_SECURITY_ACCEPTANCE' + | 'TX_PLAN_SECURITY_CANCELLATION' + | 'TX_PLAN_SECURITY_EXERCISE' + | 'TX_PLAN_SECURITY_ISSUANCE' + | 'TX_PLAN_SECURITY_RELEASE' + | 'TX_PLAN_SECURITY_RETRACTION' + | 'TX_PLAN_SECURITY_TRANSFER'; + +const publishedOcfObjectIsExact: Assert> = true; +const publishedOcfObjectExcludesLegacyPlanSecurity: Assert< + IsExactly, never> +> = true; + +void publishedOcfObjectIsExact; +void publishedOcfObjectExcludesLegacyPlanSecurity; + function verifyPublishedBatchApi( batch: CapTableBatch, stakeholder: OcfStakeholder, stockClass: OcfStockClass, - issuer: OcfIssuer + issuer: OcfIssuer, + stockAcceptance: OcfStockAcceptance, + warrantAcceptance: OcfWarrantAcceptance, + vestingStart: OcfVestingStart, + vestingEvent: OcfVestingEvent ): void { batch.create('stakeholder', stakeholder); batch.create('stockClass', stockClass); @@ -40,12 +73,48 @@ function verifyPublishedBatchApi( // @ts-expect-error a union-valued kind cannot bypass converter payload correlation convertToDaml(widenedKind, stakeholder); + // @ts-expect-error published types preserve stock vs warrant identity even with identical fields + batch.create('warrantAcceptance', stockAcceptance); + + // @ts-expect-error published types preserve vesting start vs vesting event identity + batch.create('vestingEvent', vestingStart); + + // @ts-expect-error converter declarations cannot reinterpret a warrant acceptance as stock + convertToDaml('stockAcceptance', warrantAcceptance); + + // @ts-expect-error converter declarations cannot reinterpret a vesting event as vesting start + convertToDaml('vestingStart', vestingEvent); + + // @ts-expect-error published entity declarations require object_type + const missingObjectType: OcfStockAcceptance = { + id: 'acceptance-1', + date: '2026-01-01', + security_id: 'security-1', + }; + void missingObjectType; + + const wrongObjectType: OcfStockAcceptance = { + // @ts-expect-error published literal rejects another entity discriminator + object_type: 'TX_WARRANT_ACCEPTANCE', + id: 'acceptance-2', + date: '2026-01-01', + security_id: 'security-2', + }; + void wrongObjectType; + const operations: CapTableBatchOperations = { creates: [{ type: 'stakeholder', data: stakeholder }], edits: [{ type: 'issuer', data: issuer }], deletes: [{ type: 'stockClass', id: stockClass.id }], }; void operations; + + // @ts-expect-error published operation declarations preserve exact payload identity + const invalidIdentityOperation: OcfCreateOperation = { + type: 'warrantAcceptance', + data: stockAcceptance, + }; + void invalidIdentityOperation; } void verifyPublishedBatchApi; diff --git a/test/integration/entities/acceptanceTypes.integration.test.ts b/test/integration/entities/acceptanceTypes.integration.test.ts index b12a607c..d27d132d 100644 --- a/test/integration/entities/acceptanceTypes.integration.test.ts +++ b/test/integration/entities/acceptanceTypes.integration.test.ts @@ -38,26 +38,32 @@ import { /** * Create test stock acceptance data with optional overrides. */ -function createTestStockAcceptanceData(overrides: Partial = {}): OcfStockAcceptance { +function createTestStockAcceptanceData( + overrides: Omit, 'object_type'> = {} +): OcfStockAcceptance { const id = overrides.id ?? generateTestId('stock-accept'); return { id, date: generateDateString(0), security_id: overrides.security_id ?? generateTestId('stock-security'), ...overrides, + object_type: 'TX_STOCK_ACCEPTANCE', }; } /** * Create test warrant acceptance data with optional overrides. */ -function createTestWarrantAcceptanceData(overrides: Partial = {}): OcfWarrantAcceptance { +function createTestWarrantAcceptanceData( + overrides: Omit, 'object_type'> = {} +): OcfWarrantAcceptance { const id = overrides.id ?? generateTestId('warrant-accept'); return { id, date: generateDateString(0), security_id: overrides.security_id ?? generateTestId('warrant-security'), ...overrides, + object_type: 'TX_WARRANT_ACCEPTANCE', }; } @@ -65,7 +71,7 @@ function createTestWarrantAcceptanceData(overrides: Partial = {} + overrides: Omit, 'object_type'> = {} ): OcfConvertibleAcceptance { const id = overrides.id ?? generateTestId('conv-accept'); return { @@ -73,6 +79,7 @@ function createTestConvertibleAcceptanceData( date: generateDateString(0), security_id: overrides.security_id ?? generateTestId('convertible-security'), ...overrides, + object_type: 'TX_CONVERTIBLE_ACCEPTANCE', }; } @@ -80,7 +87,7 @@ function createTestConvertibleAcceptanceData( * Create test equity compensation acceptance data with optional overrides. */ function createTestEquityCompensationAcceptanceData( - overrides: Partial = {} + overrides: Omit, 'object_type'> = {} ): OcfEquityCompensationAcceptance { const id = overrides.id ?? generateTestId('equity-accept'); return { @@ -88,6 +95,7 @@ function createTestEquityCompensationAcceptanceData( date: generateDateString(0), security_id: overrides.security_id ?? generateTestId('equity-security'), ...overrides, + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', }; } @@ -446,6 +454,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { id: generateTestId('no-comments-accept'), date: generateDateString(0), security_id: stockSecurity.securityId, + object_type: 'TX_STOCK_ACCEPTANCE', }; const batch = ctx.ocp.OpenCapTable.capTable.update({ diff --git a/test/integration/entities/capTableBatch.integration.test.ts b/test/integration/entities/capTableBatch.integration.test.ts index 91ad9caf..b51bdbbb 100644 --- a/test/integration/entities/capTableBatch.integration.test.ts +++ b/test/integration/entities/capTableBatch.integration.test.ts @@ -348,6 +348,7 @@ createIntegrationTestSuite('CapTableBatch operations', (getContext) => { id: '', // Empty ID should fail validation name: { legal_name: 'Test' }, stakeholder_type: 'INDIVIDUAL' as const, + object_type: 'STAKEHOLDER' as const, }; // Should throw during create() due to validation (validation happens synchronously) diff --git a/test/integration/entities/exerciseConversionTypes.integration.test.ts b/test/integration/entities/exerciseConversionTypes.integration.test.ts index 34dac009..77688344 100644 --- a/test/integration/entities/exerciseConversionTypes.integration.test.ts +++ b/test/integration/entities/exerciseConversionTypes.integration.test.ts @@ -294,6 +294,7 @@ createIntegrationTestSuite('Exercise and Conversion Types', (getContext) => { security_id: 'warrant-sec', trigger_id: 'trigger-001', resulting_security_ids: ['stock-sec'], + object_type: 'TX_WARRANT_EXERCISE', }) ).toThrow('warrantExercise.id'); @@ -306,6 +307,7 @@ createIntegrationTestSuite('Exercise and Conversion Types', (getContext) => { security_id: 'convertible-sec', trigger_id: 'trigger-002', resulting_security_ids: ['stock-sec'], + object_type: 'TX_CONVERTIBLE_CONVERSION', }) ).toThrow('convertibleConversion.id'); @@ -317,6 +319,7 @@ createIntegrationTestSuite('Exercise and Conversion Types', (getContext) => { security_id: 'stock-sec', quantity_converted: '5000', resulting_security_ids: ['preferred-sec'], + object_type: 'TX_STOCK_CONVERSION', }) ).toThrow('stockConversion.id'); }); diff --git a/test/integration/entities/stockClassAdjustments.integration.test.ts b/test/integration/entities/stockClassAdjustments.integration.test.ts index 483c4ce3..92e1fab6 100644 --- a/test/integration/entities/stockClassAdjustments.integration.test.ts +++ b/test/integration/entities/stockClassAdjustments.integration.test.ts @@ -75,6 +75,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { stock_class_id: stockSecurity.stockClassId, split_ratio: { numerator: '2', denominator: '1' }, comments: ['2-for-1 stock split'], + object_type: 'TX_STOCK_CLASS_SPLIT', }) .execute(); @@ -132,6 +133,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { rounding_type: 'NORMAL', }, comments: ['Anti-dilution adjustment'], + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', }) .execute(); @@ -221,6 +223,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { security_ids: [stockSecurity1.securityId, stockSecurity2.securityId, stockSecurity3.securityId], resulting_security_id: 'new-sec-001', comments: ['10-for-1 reverse split consolidation'], + object_type: 'TX_STOCK_CONSOLIDATION', }) .execute(); @@ -277,6 +280,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { security_id: stockSecurity.securityId, resulting_security_ids: ['sec-new-001'], comments: ['Reissued after forfeiture period'], + object_type: 'TX_STOCK_REISSUANCE', }) .execute(); @@ -365,6 +369,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { security_ids: [stockSecurity1.securityId, stockSecurity2.securityId], resulting_security_id: 'batch-new-sec-001', comments: ['Batch consolidation'], + object_type: 'TX_STOCK_CONSOLIDATION', }) .create('stockReissuance', { id: generateTestId('batch-reissue'), @@ -372,6 +377,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { security_id: stockSecurity3.securityId, resulting_security_ids: ['batch-new-sec-002'], comments: ['Batch reissuance'], + object_type: 'TX_STOCK_REISSUANCE', }) .execute(); @@ -413,6 +419,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { board_approval_date: generateDateString(-5), stockholder_approval_date: generateDateString(-2), comments: ['Split with full approval chain'], + object_type: 'TX_STOCK_CLASS_SPLIT', }) ).toThrow('board_approval_date'); }); diff --git a/test/integration/utils/setupTestData.ts b/test/integration/utils/setupTestData.ts index 7511a86b..9ffe1873 100644 --- a/test/integration/utils/setupTestData.ts +++ b/test/integration/utils/setupTestData.ts @@ -114,7 +114,7 @@ export function generateDateString(daysFromNow = 0): string { } /** Create test issuer data with optional overrides. */ -export function createTestIssuerData(overrides: Partial = {}): OcfIssuer { +export function createTestIssuerData(overrides: Omit, 'object_type'> = {}): OcfIssuer { const id = overrides.id ?? generateTestId('issuer'); return { id, @@ -124,11 +124,14 @@ export function createTestIssuerData(overrides: Partial = {}): OcfIss country_subdivision_of_formation: 'DE', tax_ids: [], ...overrides, + object_type: 'ISSUER', }; } /** Create test stakeholder data with optional overrides. */ -export function createTestStakeholderData(overrides: Partial = {}): OcfStakeholder { +export function createTestStakeholderData( + overrides: Omit, 'object_type'> = {} +): OcfStakeholder { const id = overrides.id ?? generateTestId('stakeholder'); return { id, @@ -137,11 +140,12 @@ export function createTestStakeholderData(overrides: Partial = { }, stakeholder_type: 'INDIVIDUAL', ...overrides, + object_type: 'STAKEHOLDER', }; } /** Create test stock class data with optional overrides. */ -export function createTestStockClassData(overrides: Partial = {}): OcfStockClass { +export function createTestStockClassData(overrides: Omit, 'object_type'> = {}): OcfStockClass { const id = overrides.id ?? generateTestId('stock-class'); return { id, @@ -153,12 +157,13 @@ export function createTestStockClassData(overrides: Partial = {}) votes_per_share: '1', price_per_share: { amount: '1.00', currency: 'USD' }, ...overrides, + object_type: 'STOCK_CLASS', }; } /** Create test stock legend template data with optional overrides. */ export function createTestStockLegendTemplateData( - overrides: Partial = {} + overrides: Omit, 'object_type'> = {} ): OcfStockLegendTemplate { const id = overrides.id ?? generateTestId('legend'); return { @@ -166,22 +171,26 @@ export function createTestStockLegendTemplateData( name: `Legend Template ${id}`, text: 'This is a test stock legend template text.', ...overrides, + object_type: 'STOCK_LEGEND_TEMPLATE', }; } /** Create test document data with optional overrides. */ -export function createTestDocumentData(overrides: Partial = {}): OcfDocument { +export function createTestDocumentData(overrides: Omit, 'object_type'> = {}): OcfDocument { const id = overrides.id ?? generateTestId('document'); return { id, md5: '00000000000000000000000000000000', // Placeholder MD5 hash path: `/documents/${id}.pdf`, // Default path (required: document must have path or uri) ...overrides, + object_type: 'DOCUMENT', }; } /** Create test valuation data with optional overrides. */ -export function createTestValuationData(overrides: Partial & { stock_class_id: string }): OcfValuation { +export function createTestValuationData( + overrides: Omit, 'object_type'> & { stock_class_id: string } +): OcfValuation { const id = overrides.id ?? generateTestId('valuation'); const { stock_class_id, ...rest } = overrides; return { @@ -193,12 +202,13 @@ export function createTestValuationData(overrides: Partial & { sto provider: 'Test Valuation Provider', board_approval_date: generateDateString(-35), ...rest, + object_type: 'VALUATION', }; } /** Create test vesting start data with optional overrides. */ export function createTestVestingStartData( - overrides: Partial & { security_id: string; vesting_condition_id: string } + overrides: Omit, 'object_type'> & { security_id: string; vesting_condition_id: string } ): OcfVestingStart { const id = overrides.id ?? generateTestId('vesting-start'); const { security_id, vesting_condition_id, ...rest } = overrides; @@ -208,12 +218,13 @@ export function createTestVestingStartData( security_id, vesting_condition_id, ...rest, + object_type: 'TX_VESTING_START', }; } /** Create test vesting event data with optional overrides. */ export function createTestVestingEventData( - overrides: Partial & { security_id: string; vesting_condition_id: string } + overrides: Omit, 'object_type'> & { security_id: string; vesting_condition_id: string } ): OcfVestingEvent { const id = overrides.id ?? generateTestId('vesting-event'); const { security_id, vesting_condition_id, ...rest } = overrides; @@ -223,12 +234,13 @@ export function createTestVestingEventData( security_id, vesting_condition_id, ...rest, + object_type: 'TX_VESTING_EVENT', }; } /** Create test vesting acceleration data with optional overrides. */ export function createTestVestingAccelerationData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfVestingAcceleration { const id = overrides.id ?? generateTestId('vesting-acceleration'); const { security_id, ...rest } = overrides; @@ -239,11 +251,14 @@ export function createTestVestingAccelerationData( quantity: '10000', reason_text: 'Company acquisition - single-trigger acceleration', ...rest, + object_type: 'TX_VESTING_ACCELERATION', }; } /** Create test vesting terms data with optional overrides. */ -export function createTestVestingTermsData(overrides: Partial = {}): OcfVestingTerms { +export function createTestVestingTermsData( + overrides: Omit, 'object_type'> = {} +): OcfVestingTerms { const id = overrides.id ?? generateTestId('vesting-terms'); return { id, @@ -292,12 +307,13 @@ export function createTestVestingTermsData(overrides: Partial = }, ], ...overrides, + object_type: 'VESTING_TERMS', }; } /** Create test stock plan data with optional overrides. */ export function createTestStockPlanData( - overrides: Partial & { stock_class_ids: string[] } + overrides: Omit, 'object_type'> & { stock_class_ids: string[] } ): OcfStockPlan { const id = overrides.id ?? generateTestId('stock-plan'); const { stock_class_ids, ...rest } = overrides; @@ -309,12 +325,13 @@ export function createTestStockPlanData( default_cancellation_behavior: 'RETURN_TO_POOL', board_approval_date: generateDateString(-90), ...rest, + object_type: 'STOCK_PLAN', }; } /** Create test stock issuance data with optional overrides. */ export function createTestStockIssuanceData( - overrides: Partial & { + overrides: Omit, 'object_type'> & { stakeholder_id: string; stock_class_id: string; } @@ -334,12 +351,13 @@ export function createTestStockIssuanceData( security_law_exemptions: [], stock_legend_ids: [], ...rest, + object_type: 'TX_STOCK_ISSUANCE', }; } /** Create test equity compensation issuance data with optional overrides. */ export function createTestEquityCompensationIssuanceData( - overrides: Partial & { + overrides: Omit, 'object_type'> & { stakeholder_id: string; stock_plan_id?: string; stock_class_id?: string; @@ -363,6 +381,7 @@ export function createTestEquityCompensationIssuanceData( security_law_exemptions: [], termination_exercise_windows: [], ...rest, + object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', }; } @@ -370,7 +389,7 @@ export function createTestEquityCompensationIssuanceData( /** Create test warrant exercise data with optional overrides. */ export function createTestWarrantExerciseData( - overrides: Partial & { + overrides: Omit, 'object_type'> & { security_id: string; resulting_security_ids: string[]; } @@ -384,12 +403,13 @@ export function createTestWarrantExerciseData( trigger_id: trigger_id ?? generateTestId('trigger'), resulting_security_ids, ...rest, + object_type: 'TX_WARRANT_EXERCISE', }; } /** Create test convertible conversion data with optional overrides. */ export function createTestConvertibleConversionData( - overrides: Partial & { + overrides: Omit, 'object_type'> & { security_id: string; resulting_security_ids: string[]; } @@ -404,12 +424,13 @@ export function createTestConvertibleConversionData( trigger_id: generateTestId('trigger'), resulting_security_ids, ...rest, + object_type: 'TX_CONVERTIBLE_CONVERSION', }; } /** Create test stock conversion data with optional overrides. */ export function createTestStockConversionData( - overrides: Partial & { + overrides: Omit, 'object_type'> & { security_id: string; resulting_security_ids: string[]; } @@ -423,6 +444,7 @@ export function createTestStockConversionData( quantity_converted: '1000', resulting_security_ids, ...rest, + object_type: 'TX_STOCK_CONVERSION', }; } @@ -430,7 +452,7 @@ export function createTestStockConversionData( /** Create test stock retraction data with optional overrides. */ export function createTestStockRetractionData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfStockRetraction { const id = overrides.id ?? generateTestId('stock-retraction'); const { security_id, ...rest } = overrides; @@ -440,12 +462,13 @@ export function createTestStockRetractionData( security_id, reason_text: 'Issued in error', ...rest, + object_type: 'TX_STOCK_RETRACTION', }; } /** Create test warrant retraction data with optional overrides. */ export function createTestWarrantRetractionData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfWarrantRetraction { const id = overrides.id ?? generateTestId('warrant-retraction'); const { security_id, ...rest } = overrides; @@ -455,12 +478,13 @@ export function createTestWarrantRetractionData( security_id, reason_text: 'Warrant voided', ...rest, + object_type: 'TX_WARRANT_RETRACTION', }; } /** Create test convertible retraction data with optional overrides. */ export function createTestConvertibleRetractionData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfConvertibleRetraction { const id = overrides.id ?? generateTestId('convertible-retraction'); const { security_id, ...rest } = overrides; @@ -470,12 +494,13 @@ export function createTestConvertibleRetractionData( security_id, reason_text: 'Terms renegotiated', ...rest, + object_type: 'TX_CONVERTIBLE_RETRACTION', }; } /** Create test equity compensation retraction data with optional overrides. */ export function createTestEquityCompensationRetractionData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfEquityCompensationRetraction { const id = overrides.id ?? generateTestId('equity-comp-retraction'); const { security_id, ...rest } = overrides; @@ -485,12 +510,13 @@ export function createTestEquityCompensationRetractionData( security_id, reason_text: 'Grant voided due to termination', ...rest, + object_type: 'TX_EQUITY_COMPENSATION_RETRACTION', }; } /** Create test equity compensation release data with optional overrides. */ export function createTestEquityCompensationReleaseData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfEquityCompensationRelease { const id = overrides.id ?? generateTestId('equity-comp-release'); const { security_id, ...rest } = overrides; @@ -503,12 +529,13 @@ export function createTestEquityCompensationReleaseData( quantity: '1000', resulting_security_ids: [generateTestId('resulting-security')], ...rest, + object_type: 'TX_EQUITY_COMPENSATION_RELEASE', }; } /** Create test equity compensation repricing data with optional overrides. */ export function createTestEquityCompensationRepricingData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfEquityCompensationRepricing { const id = overrides.id ?? generateTestId('equity-comp-repricing'); const { security_id, ...rest } = overrides; @@ -518,12 +545,13 @@ export function createTestEquityCompensationRepricingData( security_id, new_exercise_price: { amount: '0.25', currency: 'USD' }, ...rest, + object_type: 'TX_EQUITY_COMPENSATION_REPRICING', }; } /** Create test stock plan return to pool data with optional overrides. */ export function createTestStockPlanReturnToPoolData( - overrides: Partial & { stock_plan_id: string; security_id: string } + overrides: Omit, 'object_type'> & { stock_plan_id: string; security_id: string } ): OcfStockPlanReturnToPool { const id = overrides.id ?? generateTestId('stock-plan-return'); const { stock_plan_id, security_id, ...rest } = overrides; @@ -535,12 +563,13 @@ export function createTestStockPlanReturnToPoolData( quantity: '5000', reason_text: 'Employee termination - unvested shares returned', ...rest, + object_type: 'TX_STOCK_PLAN_RETURN_TO_POOL', }; } /** Create test stakeholder relationship change event data with optional overrides. */ export function createTestStakeholderRelationshipChangeData( - overrides: Partial & { stakeholder_id: string } + overrides: Omit, 'object_type'> & { stakeholder_id: string } ): OcfStakeholderRelationshipChangeEvent { const id = overrides.id ?? generateTestId('relationship-change'); const { stakeholder_id, ...rest } = overrides; @@ -550,12 +579,13 @@ export function createTestStakeholderRelationshipChangeData( stakeholder_id, relationship_started: 'EMPLOYEE', ...rest, + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', }; } /** Create test stakeholder status change event data with optional overrides. */ export function createTestStakeholderStatusChangeData( - overrides: Partial & { stakeholder_id: string } + overrides: Omit, 'object_type'> & { stakeholder_id: string } ): OcfStakeholderStatusChangeEvent { const id = overrides.id ?? generateTestId('status-change'); const { stakeholder_id, ...rest } = overrides; @@ -565,6 +595,7 @@ export function createTestStakeholderStatusChangeData( stakeholder_id, new_status: 'ACTIVE', ...rest, + object_type: 'CE_STAKEHOLDER_STATUS', }; } @@ -793,7 +824,7 @@ export async function setupTestStakeholder( /** Create test stock transfer data with optional overrides. */ export function createTestStockTransferData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfStockTransfer { const id = overrides.id ?? generateTestId('stock-transfer'); const { security_id, ...rest } = overrides; @@ -804,12 +835,13 @@ export function createTestStockTransferData( quantity: rest.quantity ?? '1000', resulting_security_ids: rest.resulting_security_ids ?? [generateTestId('result-security')], ...rest, + object_type: 'TX_STOCK_TRANSFER', }; } /** Create test convertible transfer data with optional overrides. */ export function createTestConvertibleTransferData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfConvertibleTransfer { const id = overrides.id ?? generateTestId('convertible-transfer'); const { security_id, ...rest } = overrides; @@ -820,12 +852,13 @@ export function createTestConvertibleTransferData( amount: rest.amount ?? { amount: '50000', currency: 'USD' }, resulting_security_ids: rest.resulting_security_ids ?? [generateTestId('result-security')], ...rest, + object_type: 'TX_CONVERTIBLE_TRANSFER', }; } /** Create test equity compensation transfer data with optional overrides. */ export function createTestEquityCompensationTransferData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfEquityCompensationTransfer { const id = overrides.id ?? generateTestId('equity-comp-transfer'); const { security_id, ...rest } = overrides; @@ -836,12 +869,13 @@ export function createTestEquityCompensationTransferData( quantity: rest.quantity ?? '5000', resulting_security_ids: rest.resulting_security_ids ?? [generateTestId('result-security')], ...rest, + object_type: 'TX_EQUITY_COMPENSATION_TRANSFER', }; } /** Create test warrant transfer data with optional overrides. */ export function createTestWarrantTransferData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfWarrantTransfer { const id = overrides.id ?? generateTestId('warrant-transfer'); const { security_id, ...rest } = overrides; @@ -852,6 +886,7 @@ export function createTestWarrantTransferData( quantity: rest.quantity ?? '2500', resulting_security_ids: rest.resulting_security_ids ?? [generateTestId('result-security')], ...rest, + object_type: 'TX_WARRANT_TRANSFER', }; } @@ -913,7 +948,7 @@ export interface ConvertibleSecuritySetup { /** Create test warrant issuance data with optional overrides. */ export function createTestWarrantIssuanceData( - overrides: Partial & { stakeholder_id: string } + overrides: Omit, 'object_type'> & { stakeholder_id: string } ): OcfWarrantIssuance { const id = overrides.id ?? generateTestId('warrant-issuance'); const securityId = overrides.security_id ?? generateTestId('warrant-security'); @@ -930,12 +965,13 @@ export function createTestWarrantIssuanceData( exercise_triggers: [], security_law_exemptions: [], ...rest, + object_type: 'TX_WARRANT_ISSUANCE', }; } /** Create test convertible issuance data with optional overrides. */ export function createTestConvertibleIssuanceData( - overrides: Partial & { stakeholder_id: string } + overrides: Omit, 'object_type'> & { stakeholder_id: string } ): OcfConvertibleIssuance { const id = overrides.id ?? generateTestId('convertible-issuance'); const securityId = overrides.security_id ?? generateTestId('convertible-security'); @@ -966,6 +1002,7 @@ export function createTestConvertibleIssuanceData( ], seniority: 1, ...rest, + object_type: 'TX_CONVERTIBLE_ISSUANCE', }; } diff --git a/test/integration/workflows/batchOperations.integration.test.ts b/test/integration/workflows/batchOperations.integration.test.ts index d4bef7fe..841a87bc 100644 --- a/test/integration/workflows/batchOperations.integration.test.ts +++ b/test/integration/workflows/batchOperations.integration.test.ts @@ -69,6 +69,7 @@ createIntegrationTestSuite('Batch operations', (getContext) => { id: generateTestId('batch-sh-1'), name: { legal_name: 'Batch Stakeholder 1' }, stakeholder_type: 'INDIVIDUAL' as const, + object_type: 'STAKEHOLDER' as const, }; const cmd1 = buildUpdateCapTableCommand( @@ -131,6 +132,7 @@ createIntegrationTestSuite('Batch operations', (getContext) => { id: generateTestId('batch-stakeholder'), name: { legal_name: 'Batch Stakeholder' }, stakeholder_type: 'INDIVIDUAL' as const, + object_type: 'STAKEHOLDER' as const, }; const stakeholderCmd = buildUpdateCapTableCommand( @@ -310,6 +312,7 @@ createIntegrationTestSuite('Batch operations', (getContext) => { id: generateTestId('txbatch-stakeholder'), name: { legal_name: 'TxBatch Stakeholder' }, stakeholder_type: 'INDIVIDUAL' as const, + object_type: 'STAKEHOLDER' as const, }; const cmd = buildUpdateCapTableCommand( diff --git a/test/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index e5a033a7..ed172fea 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -11,16 +11,48 @@ import { type CapTableBatchOperations, convertToDaml, type OcfCreateOperation, + type OcfEntityDataMap, + type OcfEntityType, + type OcfFinancing, type OcfIssuer, + type OcfObject, type OcfStakeholder, + type OcfStockAcceptance, type OcfStockClass, + type OcfVestingEvent, + type OcfVestingStart, + type OcfWarrantAcceptance, } from '../../src'; +type Assert = T; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type IntendedCanonicalOcfObject = OcfEntityDataMap[OcfEntityType] | OcfFinancing; +type LegacyPlanSecurityObjectType = + | 'TX_PLAN_SECURITY_ACCEPTANCE' + | 'TX_PLAN_SECURITY_CANCELLATION' + | 'TX_PLAN_SECURITY_EXERCISE' + | 'TX_PLAN_SECURITY_ISSUANCE' + | 'TX_PLAN_SECURITY_RELEASE' + | 'TX_PLAN_SECURITY_RETRACTION' + | 'TX_PLAN_SECURITY_TRANSFER'; + +const publicOcfObjectIsExact: Assert> = true; +const publicOcfObjectExcludesLegacyPlanSecurity: Assert< + IsExactly, never> +> = true; + +void publicOcfObjectIsExact; +void publicOcfObjectExcludesLegacyPlanSecurity; + function verifyCapTableBatchContract( batch: CapTableBatch, stakeholder: OcfStakeholder, stockClass: OcfStockClass, - issuer: OcfIssuer + issuer: OcfIssuer, + stockAcceptance: OcfStockAcceptance, + warrantAcceptance: OcfWarrantAcceptance, + vestingStart: OcfVestingStart, + vestingEvent: OcfVestingEvent ): void { batch.create('stakeholder', stakeholder); batch.create('stockClass', stockClass); @@ -50,12 +82,48 @@ function verifyCapTableBatchContract( // @ts-expect-error the converter uses the same kind/payload correlation as the batch API convertToDaml(widenedKind, stakeholder); + // @ts-expect-error identical payload fields cannot erase stock vs warrant identity + batch.create('warrantAcceptance', stockAcceptance); + + // @ts-expect-error the discriminator also separates vesting start from vesting event + batch.create('vestingEvent', vestingStart); + + // @ts-expect-error converter dispatch cannot reinterpret a warrant acceptance as stock + convertToDaml('stockAcceptance', warrantAcceptance); + + // @ts-expect-error converter dispatch cannot reinterpret a vesting event as vesting start + convertToDaml('vestingStart', vestingEvent); + + // @ts-expect-error every top-level OCF object requires its canonical discriminator + const missingObjectType: OcfStockAcceptance = { + id: 'acceptance-1', + date: '2026-01-01', + security_id: 'security-1', + }; + void missingObjectType; + + const wrongObjectType: OcfStockAcceptance = { + // @ts-expect-error stock acceptance cannot carry the warrant discriminator + object_type: 'TX_WARRANT_ACCEPTANCE', + id: 'acceptance-2', + date: '2026-01-01', + security_id: 'security-2', + }; + void wrongObjectType; + const createOperation: OcfCreateOperation = { type: 'stakeholder', data: stakeholder, }; void createOperation; + // @ts-expect-error operation objects preserve identity for structurally identical payloads + const invalidIdentityOperation: OcfCreateOperation = { + type: 'warrantAcceptance', + data: stockAcceptance, + }; + void invalidIdentityOperation; + const operations: CapTableBatchOperations = { creates: [ { type: 'stakeholder', data: stakeholder }, diff --git a/test/types/damlReadDispatch.types.ts b/test/types/damlReadDispatch.types.ts new file mode 100644 index 00000000..2b24a664 --- /dev/null +++ b/test/types/damlReadDispatch.types.ts @@ -0,0 +1,31 @@ +/** Compile-time contracts for generated DAML read dispatch. */ + +import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { + convertToOcf, + decodeDamlEntityData, + ENTITY_TEMPLATE_ID_MAP, + type DamlDataTypeFor, + type OcfStakeholder, +} from '../../src'; + +declare const stakeholderDamlData: DamlDataTypeFor<'stakeholder'>; +declare const stockClassDamlData: DamlDataTypeFor<'stockClass'>; +declare const unknownLedgerData: unknown; + +const stakeholder: OcfStakeholder = convertToOcf('stakeholder', stakeholderDamlData); +const decodedStakeholder: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData = decodeDamlEntityData( + 'stakeholder', + unknownLedgerData +); +const stakeholderTemplateId: string = ENTITY_TEMPLATE_ID_MAP.stakeholder; + +void stakeholder; +void decodedStakeholder; +void stakeholderTemplateId; + +// @ts-expect-error the entity kind and generated DAML payload must remain correlated +convertToOcf('stakeholder', stockClassDamlData); + +// @ts-expect-error unsupported entity kinds have no registered generated template +ENTITY_TEMPLATE_ID_MAP.planSecurityIssuance; diff --git a/test/types/normalization.types.ts b/test/types/normalization.types.ts new file mode 100644 index 00000000..d97ffaae --- /dev/null +++ b/test/types/normalization.types.ts @@ -0,0 +1,44 @@ +/** Compile-time contracts for canonicalization helpers exported from source. */ + +import { + deepNormalizeNumericStrings, + normalizeEntityType, + normalizeObjectType, + normalizeOcfData, + type OcfPlanSecurityIssuance, +} from '../../src'; + +const normalizedNumericString: string = deepNormalizeNumericStrings('1.00' as const); +void normalizedNumericString; + +// @ts-expect-error numeric normalization can change a string literal's value +const staleNumericLiteral: '1.00' = deepNormalizeNumericStrings('1.00' as const); +void staleNumericLiteral; + +const normalizedEntity = normalizeEntityType('planSecurityIssuance'); +const exactEntity: 'equityCompensationIssuance' = normalizedEntity; +void exactEntity; + +const unchangedEntity = normalizeEntityType('stockIssuance'); +const exactUnchangedEntity: 'stockIssuance' = unchangedEntity; +void exactUnchangedEntity; + +const normalizedPlanSecurity = normalizeObjectType('TX_PLAN_SECURITY_ISSUANCE'); +const exactPlanSecurity: 'TX_EQUITY_COMPENSATION_ISSUANCE' = normalizedPlanSecurity; +void exactPlanSecurity; + +const normalizedLegacyEvent = normalizeObjectType('TX_STAKEHOLDER_STATUS_CHANGE_EVENT'); +const exactLegacyEvent: 'CE_STAKEHOLDER_STATUS' = normalizedLegacyEvent; +void exactLegacyEvent; + +const unchangedObjectType = normalizeObjectType('TX_STOCK_ISSUANCE'); +const exactUnchangedObjectType: 'TX_STOCK_ISSUANCE' = unchangedObjectType; +void exactUnchangedObjectType; + +declare const legacyIssuance: OcfPlanSecurityIssuance; +const normalizedData: Record = normalizeOcfData(legacyIssuance); +void normalizedData; + +// @ts-expect-error normalization may rename the discriminator and fields, so the result cannot retain the input type +const unsoundLegacyClaim: OcfPlanSecurityIssuance = normalizeOcfData(legacyIssuance); +void unsoundLegacyClaim; diff --git a/test/utils/ocfZodSchemas.test.ts b/test/utils/ocfZodSchemas.test.ts index cc79f2fd..0185827b 100644 --- a/test/utils/ocfZodSchemas.test.ts +++ b/test/utils/ocfZodSchemas.test.ts @@ -1,4 +1,9 @@ import { OcpValidationError } from '../../src/errors'; +import { + ENTITY_OBJECT_TYPE_MAP, + ENTITY_REGISTRY, + type OcfEntityType, +} from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { parseOcfEntityInput, parseOcfObject, resolveOcfSchemaDir } from '../../src/utils/ocfZodSchemas'; import { loadProductionFixture, loadSyntheticFixture, stripSourceMetadata } from './productionFixtures'; @@ -15,6 +20,27 @@ function toRecord(value: unknown): Record { return value as Record; } +function captureValidationError(parse: () => unknown): OcpValidationError { + try { + parse(); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + return error as OcpValidationError; + } + + throw new Error('Expected parsing to throw OcpValidationError'); +} + +const entityTypes = Object.keys(ENTITY_REGISTRY) as OcfEntityType[]; +const entityDiscriminatorCases = entityTypes.map((entityType, index) => { + const mismatchedEntityType = entityTypes[(index + 1) % entityTypes.length]; + return { + entityType, + expectedObjectType: ENTITY_OBJECT_TYPE_MAP[entityType], + mismatchedObjectType: ENTITY_OBJECT_TYPE_MAP[mismatchedEntityType], + }; +}); + describe('ocfZodSchemas', () => { beforeAll(() => { if (schemaAvailabilityError) { @@ -42,7 +68,57 @@ describe('ocfZodSchemas', () => { expect(parseInvalid).toThrow('__unexpected_field'); }); - it('canonicalizes legacy plan security issuance + option_grant_type before validation', () => { + describe('typed entity discriminator preflight', () => { + it('derives one unique canonical discriminator for every registry entry', () => { + expect(entityDiscriminatorCases).toHaveLength(entityTypes.length); + expect(new Set(entityDiscriminatorCases.map(({ expectedObjectType }) => expectedObjectType)).size).toBe( + entityTypes.length + ); + }); + + it.each(entityDiscriminatorCases)( + 'rejects missing object_type for $entityType before schema validation', + ({ entityType, expectedObjectType }) => { + const error = captureValidationError(() => parseOcfEntityInput(entityType, { id: 'preflight-id' })); + + expect(error.fieldPath).toBe('object_type'); + expect(error.expectedType).toBe(expectedObjectType); + expect(error.receivedValue).toBeUndefined(); + } + ); + + it.each(entityDiscriminatorCases)( + 'rejects mismatched object_type for $entityType before schema validation', + ({ entityType, expectedObjectType, mismatchedObjectType }) => { + const error = captureValidationError(() => + parseOcfEntityInput(entityType, { + object_type: mismatchedObjectType, + }) + ); + + expect(error.fieldPath).toBe('object_type'); + expect(error.expectedType).toBe(expectedObjectType); + expect(error.receivedValue).toBe(mismatchedObjectType); + expect(error.message).toContain(`Entity type "${entityType}" expects object_type "${expectedObjectType}"`); + } + ); + + it.each(entityDiscriminatorCases)( + 'accepts the exact object_type preflight for $entityType', + ({ entityType, expectedObjectType }) => { + try { + const parsed = parseOcfEntityInput(entityType, { object_type: expectedObjectType }); + expect(parsed.object_type).toBe(expectedObjectType); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect((error as OcpValidationError).fieldPath).not.toBe('object_type'); + expect((error as Error).message).not.toContain('expects object_type'); + } + } + ); + }); + + it('raw parsing canonicalizes legacy plan security issuance + option_grant_type before validation', () => { const fixture = stripSourceMetadata( loadProductionFixture>('equityCompensationIssuance', 'option-iso') ); @@ -54,7 +130,7 @@ describe('ocfZodSchemas', () => { delete legacyFixture.compensation_type; delete legacyFixture.quantity_source; - const parsed = parseOcfEntityInput('planSecurityIssuance', legacyFixture); + const parsed = parseOcfObject(legacyFixture); const parsedRecord = toRecord(parsed); expect(parsedRecord.object_type).toBe('TX_EQUITY_COMPENSATION_ISSUANCE'); @@ -62,7 +138,7 @@ describe('ocfZodSchemas', () => { expect(parsedRecord).not.toHaveProperty('option_grant_type'); }); - it('canonicalizes legacy plan security issuance + plan_security_type before validation', () => { + it('raw parsing canonicalizes legacy plan security issuance + plan_security_type before validation', () => { const fixture = stripSourceMetadata( loadProductionFixture>('equityCompensationIssuance', 'option-nso') ); @@ -75,7 +151,7 @@ describe('ocfZodSchemas', () => { delete legacyFixture.option_grant_type; delete legacyFixture.quantity_source; - const parsed = parseOcfEntityInput('planSecurityIssuance', legacyFixture); + const parsed = parseOcfObject(legacyFixture); const parsedRecord = toRecord(parsed); expect(parsedRecord.object_type).toBe('TX_EQUITY_COMPENSATION_ISSUANCE'); @@ -91,7 +167,7 @@ describe('ocfZodSchemas', () => { reason_text: 'Legacy reason', }; - const parsed = parseOcfEntityInput('stakeholderStatusChangeEvent', legacyFixture); + const parsed = parseOcfObject(legacyFixture); const parsedRecord = toRecord(parsed); expect(parsedRecord.object_type).toBe('CE_STAKEHOLDER_STATUS'); @@ -109,7 +185,7 @@ describe('ocfZodSchemas', () => { new_relationships: ['ADVISOR'], }; - const parsed = parseOcfEntityInput('stakeholderRelationshipChangeEvent', legacyFixture); + const parsed = parseOcfObject(legacyFixture); const parsedRecord = toRecord(parsed); expect(parsedRecord.object_type).toBe('CE_STAKEHOLDER_RELATIONSHIP'); @@ -127,9 +203,7 @@ describe('ocfZodSchemas', () => { new_relationships: ['ADVISOR', 'INVESTOR'], }; - expect(() => parseOcfEntityInput('stakeholderRelationshipChangeEvent', legacyFixture)).toThrow( - 'legacy new_relationships with multiple entries is ambiguous' - ); + expect(() => parseOcfObject(legacyFixture)).toThrow('legacy new_relationships with multiple entries is ambiguous'); }); it('canonicalizes stock consolidation legacy resulting_security_ids field', () => { @@ -150,4 +224,37 @@ describe('ocfZodSchemas', () => { expect(parseMismatched).toThrow(OcpValidationError); expect(parseMismatched).toThrow('expects object_type "TX_STOCK_ISSUANCE"'); }); + + it('typed parsing accepts the exact canonical object_type', () => { + const fixture = stripSourceMetadata( + loadProductionFixture>('equityCompensationIssuance', 'option-iso') + ); + + const parsed = parseOcfEntityInput('equityCompensationIssuance', fixture); + + expect(parsed.object_type).toBe('TX_EQUITY_COMPENSATION_ISSUANCE'); + }); + + it('typed parsing rejects a missing object_type', () => { + const fixture = stripSourceMetadata(loadProductionFixture>('stakeholder', 'individual')); + const { object_type: _, ...withoutObjectType } = fixture; + + const parseMissing = () => parseOcfEntityInput('stakeholder', withoutObjectType); + expect(parseMissing).toThrow(OcpValidationError); + expect(parseMissing).toThrow('Required field is missing or invalid'); + }); + + it('typed parsing rejects legacy object_type aliases', () => { + const fixture = stripSourceMetadata( + loadProductionFixture>('equityCompensationIssuance', 'option-iso') + ); + const legacyFixture = { + ...fixture, + object_type: 'TX_PLAN_SECURITY_ISSUANCE', + }; + + const parseLegacy = () => parseOcfEntityInput('equityCompensationIssuance', legacyFixture); + expect(parseLegacy).toThrow(OcpValidationError); + expect(parseLegacy).toThrow('expects object_type "TX_EQUITY_COMPENSATION_ISSUANCE"'); + }); }); diff --git a/test/utils/planSecurityAliases.test.ts b/test/utils/planSecurityAliases.test.ts index 335ef763..28080239 100644 --- a/test/utils/planSecurityAliases.test.ts +++ b/test/utils/planSecurityAliases.test.ts @@ -160,6 +160,10 @@ describe('PlanSecurity alias utilities', () => { expect(result).toBe(input); }); + it.each([[], new Date('2026-01-01T00:00:00.000Z')])('rejects non-plain object input', (input) => { + expect(() => normalizeOcfData(input)).toThrow('Invalid OCF data: expected a plain object'); + }); + it('strips date field from DOCUMENT objects (not modeled in DAML)', () => { const input = { object_type: 'DOCUMENT', @@ -225,7 +229,7 @@ describe('PlanSecurity alias utilities', () => { current_relationship: 'INVESTOR', }; - const result = normalizeOcfData(input) as Record; + const result = normalizeOcfData(input); expect(result.current_relationships).toEqual(['INVESTOR']); expect(result).not.toHaveProperty('current_relationship'); @@ -560,7 +564,7 @@ describe('PlanSecurity alias utilities', () => { }; const result = normalizeOcfData(input); - const resultRecord = result as Record; + const resultRecord = result; await validateOcfObject(resultRecord); expect(resultRecord.compensation_type).toBe('OPTION'); @@ -612,7 +616,7 @@ describe('PlanSecurity alias utilities', () => { }; const result = normalizeOcfData(input); - const resultRecord = result as Record; + const resultRecord = result; await validateOcfObject(resultRecord); expect(resultRecord.object_type).toBe('CE_STAKEHOLDER_RELATIONSHIP'); @@ -673,7 +677,7 @@ describe('PlanSecurity alias utilities', () => { }; const result = normalizeOcfData(input); - const resultRecord = result as Record; + const resultRecord = result; await validateOcfObject(resultRecord); expect(resultRecord.resulting_security_id).toBe('sec-new-1'); @@ -704,7 +708,7 @@ describe('PlanSecurity alias utilities', () => { }; const result = normalizeOcfData(input); - const resultRecord = result as Record; + const resultRecord = result; await validateOcfObject(resultRecord); expect(resultRecord.quantity_converted).toBe('100'); @@ -722,7 +726,7 @@ describe('PlanSecurity alias utilities', () => { }; const result = normalizeOcfData(input); - const resultRecord = result as Record; + const resultRecord = result; await validateOcfObject(resultRecord); expect(resultRecord.split_ratio).toEqual({ @@ -744,7 +748,7 @@ describe('PlanSecurity alias utilities', () => { }; const result = normalizeOcfData(input); - const resultRecord = result as Record; + const resultRecord = result; await validateOcfObject(resultRecord); expect(resultRecord.new_ratio_conversion_mechanism).toEqual({ diff --git a/test/utils/replicationHelpers.test.ts b/test/utils/replicationHelpers.test.ts index 137dc87c..b89d1886 100644 --- a/test/utils/replicationHelpers.test.ts +++ b/test/utils/replicationHelpers.test.ts @@ -851,45 +851,44 @@ describe('computeReplicationDiff', () => { }); }); - describe('planSecurity normalization', () => { - it('normalizes planSecurity types to equityCompensation for Canton lookup', () => { + describe('equity compensation entity kinds', () => { + it('uses the canonical equityCompensation type for Canton lookup', () => { const sourceItems: SourceReplicationItem[] = [ - { entityType: 'planSecurityIssuance', data: { id: 'ps-1', date: '2024-01-01' } }, + { entityType: 'equityCompensationIssuance', data: { id: 'eq-1', date: '2024-01-01' } }, ]; const cantonState = createEmptyCantonState(); - // Canton stores under equityCompensation (normalized name) - cantonState.entities.set('equityCompensationIssuance', new Set(['ps-1'])); + cantonState.entities.set('equityCompensationIssuance', new Set(['eq-1'])); // Canton has same data - should not trigger edit const cantonOcfData: CantonOcfDataMap = new Map([ - ['equityCompensationIssuance', new Map([['ps-1', { id: 'ps-1', date: '2024-01-01' }]])], + ['equityCompensationIssuance', new Map([['eq-1', { id: 'eq-1', date: '2024-01-01' }]])], ]); const diff = computeReplicationDiff(sourceItems, cantonState, { cantonOcfData }); - // Should NOT create (exists in Canton under normalized type) + // Should NOT create (exists in Canton under the canonical type) expect(diff.creates).toHaveLength(0); // Should NOT edit (data is identical) expect(diff.edits).toHaveLength(0); }); - it('detects edits for planSecurity items with data changes', () => { + it('detects edits for canonical equityCompensation items with data changes', () => { const sourceItems: SourceReplicationItem[] = [ - { entityType: 'planSecurityIssuance', data: { id: 'ps-1', date: '2024-02-01' } }, // Updated date + { entityType: 'equityCompensationIssuance', data: { id: 'eq-1', date: '2024-02-01' } }, // Updated date ]; const cantonState = createEmptyCantonState(); - cantonState.entities.set('equityCompensationIssuance', new Set(['ps-1'])); + cantonState.entities.set('equityCompensationIssuance', new Set(['eq-1'])); const cantonOcfData: CantonOcfDataMap = new Map([ - ['equityCompensationIssuance', new Map([['ps-1', { id: 'ps-1', date: '2024-01-01' }]])], + ['equityCompensationIssuance', new Map([['eq-1', { id: 'eq-1', date: '2024-01-01' }]])], ]); const diff = computeReplicationDiff(sourceItems, cantonState, { cantonOcfData }); expect(diff.creates).toHaveLength(0); expect(diff.edits).toHaveLength(1); - expect(diff.edits[0].id).toBe('ps-1'); - expect(diff.edits[0].entityType).toBe('planSecurityIssuance'); // Original type preserved + expect(diff.edits[0].id).toBe('eq-1'); + expect(diff.edits[0].entityType).toBe('equityCompensationIssuance'); }); }); @@ -933,9 +932,9 @@ describe('computeReplicationDiff', () => { expect(diff.conflicts[0].entityType).toBe('convertibleIssuance'); }); - it('preserves the source entity type for planSecurity conflicts', () => { + it('detects conflict for equityCompensationIssuance', () => { const sourceItems: SourceReplicationItem[] = [ - { entityType: 'planSecurityIssuance', data: { id: 'ps-dup', security_id: 'sec-dup' } }, + { entityType: 'equityCompensationIssuance', data: { id: 'eq-dup', security_id: 'sec-dup' } }, ]; const cantonState = createEmptyCantonState(); cantonState.securityIds.set('equityCompensationIssuance', new Set(['sec-dup'])); @@ -945,8 +944,8 @@ describe('computeReplicationDiff', () => { }); expect(diff.conflicts).toHaveLength(1); - expect(diff.conflicts[0].entityType).toBe('planSecurityIssuance'); - expect(diff.conflicts[0].message).toContain('Plan Security Issuance'); + expect(diff.conflicts[0].entityType).toBe('equityCompensationIssuance'); + expect(diff.conflicts[0].message).toContain('Equity Compensation Issuance'); }); it('no conflict when security_id is new', () => { diff --git a/test/utils/typeGuards.test.ts b/test/utils/typeGuards.test.ts index 56766c12..f92007ab 100644 --- a/test/utils/typeGuards.test.ts +++ b/test/utils/typeGuards.test.ts @@ -11,7 +11,12 @@ import { isNonEmptyString, isNumericValue, isObject, + isOcfConvertibleCancellation, + isOcfConvertibleIssuance, isOcfDocument, + isOcfEquityCompensationCancellation, + isOcfEquityCompensationExercise, + isOcfEquityCompensationIssuance, isOcfIssuer, isOcfStakeholder, isOcfStockCancellation, @@ -19,9 +24,15 @@ import { isOcfStockIssuance, isOcfStockLegendTemplate, isOcfStockPlan, + isOcfStockRepurchase, + isOcfStockTransfer, isOcfValuation, isOcfVestingTerms, + isOcfWarrantCancellation, + isOcfWarrantIssuance, + type DetectedOcfType, } from '../../src/utils/typeGuards'; +import { loadFixture, stripSourceMetadata } from './productionFixtures'; describe('Primitive Type Guards', () => { describe('isObject', () => { @@ -142,9 +153,267 @@ describe('Primitive Type Guards', () => { }); }); +type KnownDetectedOcfType = Exclude; + +interface OcfGuardCase { + name: string; + guard: (value: unknown) => boolean; + detectedType: KnownDetectedOcfType; + fixturePath: string; + requiredFields: readonly string[]; +} + +const ocfGuardCases = [ + { + name: 'issuer', + guard: isOcfIssuer, + detectedType: 'ISSUER', + fixturePath: 'production/issuer/basic.json', + requiredFields: ['object_type', 'id', 'legal_name', 'formation_date', 'country_of_formation'], + }, + { + name: 'stakeholder', + guard: isOcfStakeholder, + detectedType: 'STAKEHOLDER', + fixturePath: 'production/stakeholder/individual.json', + requiredFields: ['object_type', 'id', 'name', 'stakeholder_type'], + }, + { + name: 'stock class', + guard: isOcfStockClass, + detectedType: 'STOCK_CLASS', + fixturePath: 'production/stockClass/common.json', + requiredFields: [ + 'object_type', + 'id', + 'class_type', + 'default_id_prefix', + 'initial_shares_authorized', + 'name', + 'seniority', + 'votes_per_share', + ], + }, + { + name: 'stock issuance', + guard: isOcfStockIssuance, + detectedType: 'STOCK_ISSUANCE', + fixturePath: 'production/stockIssuance/founders-stock.json', + requiredFields: [ + 'object_type', + 'id', + 'date', + 'security_id', + 'custom_id', + 'stakeholder_id', + 'security_law_exemptions', + 'stock_class_id', + 'share_price', + 'quantity', + 'stock_legend_ids', + ], + }, + { + name: 'stock transfer', + guard: isOcfStockTransfer, + detectedType: 'STOCK_TRANSFER', + fixturePath: 'production/stockTransfer.json', + requiredFields: ['object_type', 'id', 'date', 'security_id', 'quantity', 'resulting_security_ids'], + }, + { + name: 'stock cancellation', + guard: isOcfStockCancellation, + detectedType: 'STOCK_CANCELLATION', + fixturePath: 'production/stockCancellation.json', + requiredFields: ['object_type', 'id', 'date', 'security_id', 'quantity', 'reason_text'], + }, + { + name: 'stock repurchase', + guard: isOcfStockRepurchase, + detectedType: 'STOCK_REPURCHASE', + fixturePath: 'production/stockRepurchase.json', + requiredFields: ['object_type', 'id', 'date', 'security_id', 'quantity', 'price'], + }, + { + name: 'stock legend template', + guard: isOcfStockLegendTemplate, + detectedType: 'STOCK_LEGEND_TEMPLATE', + fixturePath: 'production/stockLegendTemplate/rule-144.json', + requiredFields: ['object_type', 'id', 'name', 'text'], + }, + { + name: 'stock plan', + guard: isOcfStockPlan, + detectedType: 'STOCK_PLAN', + fixturePath: 'production/stockPlan/basic.json', + requiredFields: ['object_type', 'id', 'plan_name', 'initial_shares_reserved'], + }, + { + name: 'vesting terms', + guard: isOcfVestingTerms, + detectedType: 'VESTING_TERMS', + fixturePath: 'production/vestingTerms/time-based-cliff.json', + requiredFields: ['object_type', 'id', 'name', 'description', 'allocation_type', 'vesting_conditions'], + }, + { + name: 'equity compensation issuance', + guard: isOcfEquityCompensationIssuance, + detectedType: 'EQUITY_COMPENSATION_ISSUANCE', + fixturePath: 'production/equityCompensationIssuance/option-iso.json', + requiredFields: [ + 'object_type', + 'id', + 'date', + 'security_id', + 'custom_id', + 'stakeholder_id', + 'compensation_type', + 'quantity', + 'security_law_exemptions', + 'expiration_date', + 'termination_exercise_windows', + ], + }, + { + name: 'equity compensation exercise', + guard: isOcfEquityCompensationExercise, + detectedType: 'EQUITY_COMPENSATION_EXERCISE', + fixturePath: 'production/equityCompensationExercise.json', + requiredFields: ['object_type', 'id', 'date', 'security_id', 'quantity', 'resulting_security_ids'], + }, + { + name: 'equity compensation cancellation', + guard: isOcfEquityCompensationCancellation, + detectedType: 'EQUITY_COMPENSATION_CANCELLATION', + fixturePath: 'production/equityCompensationCancellation.json', + requiredFields: ['object_type', 'id', 'date', 'security_id', 'quantity', 'reason_text'], + }, + { + name: 'warrant issuance', + guard: isOcfWarrantIssuance, + detectedType: 'WARRANT_ISSUANCE', + fixturePath: 'production/warrantIssuance.json', + requiredFields: [ + 'object_type', + 'id', + 'date', + 'security_id', + 'custom_id', + 'stakeholder_id', + 'security_law_exemptions', + 'purchase_price', + 'exercise_triggers', + ], + }, + { + name: 'warrant cancellation', + guard: isOcfWarrantCancellation, + detectedType: 'WARRANT_CANCELLATION', + fixturePath: 'synthetic/warrantCancellation.json', + requiredFields: ['object_type', 'id', 'date', 'security_id', 'quantity', 'reason_text'], + }, + { + name: 'convertible issuance', + guard: isOcfConvertibleIssuance, + detectedType: 'CONVERTIBLE_ISSUANCE', + fixturePath: 'production/convertibleIssuance/safe-post-money.json', + requiredFields: [ + 'object_type', + 'id', + 'date', + 'security_id', + 'custom_id', + 'stakeholder_id', + 'security_law_exemptions', + 'investment_amount', + 'convertible_type', + 'conversion_triggers', + 'seniority', + ], + }, + { + name: 'convertible cancellation', + guard: isOcfConvertibleCancellation, + detectedType: 'CONVERTIBLE_CANCELLATION', + fixturePath: 'production/convertibleCancellation.json', + requiredFields: ['object_type', 'id', 'date', 'security_id', 'amount', 'reason_text'], + }, + { + name: 'valuation', + guard: isOcfValuation, + detectedType: 'VALUATION', + fixturePath: 'production/valuation/409a.json', + requiredFields: ['object_type', 'id', 'stock_class_id', 'price_per_share', 'effective_date', 'valuation_type'], + }, + { + name: 'document', + guard: isOcfDocument, + detectedType: 'DOCUMENT', + fixturePath: 'production/document/basic.json', + requiredFields: ['object_type', 'id', 'md5'], + }, +] as const satisfies readonly OcfGuardCase[]; + +const missingRequiredFieldCases = ocfGuardCases.flatMap((guardCase) => + guardCase.requiredFields.map((requiredField) => ({ ...guardCase, requiredField })) +); + +function loadOcfGuardFixture(fixturePath: string): Record { + return stripSourceMetadata(loadFixture>(fixturePath)); +} + +function withoutField(value: Record, field: string): Record { + const copy = { ...value }; + delete copy[field]; + return copy; +} + +describe('OCF type guard schema soundness', () => { + test.each(ocfGuardCases)('$name accepts a complete canonical fixture', ({ guard, fixturePath }) => { + expect(guard(loadOcfGuardFixture(fixturePath))).toBe(true); + }); + + test.each(missingRequiredFieldCases)( + '$name rejects missing required member $requiredField', + ({ guard, fixturePath, requiredField }) => { + const fixture = loadOcfGuardFixture(fixturePath); + expect(guard(withoutField(fixture, requiredField))).toBe(false); + } + ); + + test.each(ocfGuardCases)('$name rejects a malformed present optional member', ({ guard, fixturePath }) => { + const fixture = loadOcfGuardFixture(fixturePath); + expect(guard({ ...fixture, comments: [42] })).toBe(false); + }); + + test.each(ocfGuardCases)('$name rejects non-objects without throwing', ({ guard }) => { + expect(() => guard(null)).not.toThrow(); + expect(guard(null)).toBe(false); + expect(guard([])).toBe(false); + }); + + test.each(ocfGuardCases)( + 'detector validates the complete $name object before identifying it', + ({ detectedType, fixturePath }) => { + const fixture = loadOcfGuardFixture(fixturePath); + expect(detectOcfObjectType(fixture)).toBe(detectedType); + expect(detectOcfObjectType({ ...fixture, comments: [42] })).toBe('UNKNOWN'); + } + ); + + test.each(missingRequiredFieldCases)( + 'detector rejects $name missing required member $requiredField', + ({ fixturePath, requiredField }) => { + const fixture = loadOcfGuardFixture(fixturePath); + expect(detectOcfObjectType(withoutField(fixture, requiredField))).toBe('UNKNOWN'); + } + ); +}); + describe('OCF Type Guards', () => { describe('isOcfIssuer', () => { const validIssuer = { + object_type: 'ISSUER', id: 'issuer-1', legal_name: 'Acme Corp', formation_date: '2024-01-01', @@ -163,9 +432,9 @@ describe('OCF Type Guards', () => { expect(isOcfIssuer({ ...validIssuer, country_of_formation: undefined })).toBe(false); }); - it('returns false when required fields are empty', () => { - expect(isOcfIssuer({ ...validIssuer, id: '' })).toBe(false); - expect(isOcfIssuer({ ...validIssuer, legal_name: '' })).toBe(false); + it('returns false when required fields have the wrong type', () => { + expect(isOcfIssuer({ ...validIssuer, id: 42 })).toBe(false); + expect(isOcfIssuer({ ...validIssuer, legal_name: false })).toBe(false); }); it('returns false for non-objects', () => { @@ -176,6 +445,7 @@ describe('OCF Type Guards', () => { describe('isOcfStakeholder', () => { const validStakeholder = { + object_type: 'STAKEHOLDER', id: 'stakeholder-1', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -195,10 +465,17 @@ describe('OCF Type Guards', () => { it('returns false for invalid stakeholder_type', () => { expect(isOcfStakeholder({ ...validStakeholder, stakeholder_type: 'OTHER' })).toBe(false); }); + + it('requires the canonical discriminator', () => { + const { object_type: _, ...shapeOnly } = validStakeholder; + expect(isOcfStakeholder(shapeOnly)).toBe(false); + expect(isOcfStakeholder({ ...validStakeholder, object_type: 'STOCK_CLASS' })).toBe(false); + }); }); describe('isOcfStockClass', () => { const validStockClass = { + object_type: 'STOCK_CLASS', id: 'stock-class-1', name: 'Common Stock', default_id_prefix: 'CS-', @@ -226,6 +503,7 @@ describe('OCF Type Guards', () => { describe('isOcfStockIssuance', () => { const validIssuance = { + object_type: 'TX_STOCK_ISSUANCE', id: 'issuance-1', date: '2024-01-15', security_id: 'sec-1', @@ -234,6 +512,8 @@ describe('OCF Type Guards', () => { stock_class_id: 'class-1', quantity: '1000', share_price: { amount: '1.00', currency: 'USD' }, + security_law_exemptions: [], + stock_legend_ids: ['legend-1'], }; it('returns true for valid stock issuance objects', () => { @@ -248,6 +528,7 @@ describe('OCF Type Guards', () => { describe('isOcfStockCancellation', () => { const validCancellation = { + object_type: 'TX_STOCK_CANCELLATION', id: 'cancel-1', date: '2024-01-20', security_id: 'sec-1', @@ -266,6 +547,7 @@ describe('OCF Type Guards', () => { describe('isOcfStockLegendTemplate', () => { const validTemplate = { + object_type: 'STOCK_LEGEND_TEMPLATE', id: 'legend-1', name: 'Standard Legend', text: 'These securities have not been registered...', @@ -282,6 +564,7 @@ describe('OCF Type Guards', () => { describe('isOcfStockPlan', () => { const validPlan = { + object_type: 'STOCK_PLAN', id: 'plan-1', plan_name: '2024 Equity Incentive Plan', initial_shares_reserved: '1000000', @@ -298,13 +581,7 @@ describe('OCF Type Guards', () => { }); describe('isOcfVestingTerms', () => { - const validVestingTerms = { - id: 'vesting-1', - name: '4-year standard', - description: 'Standard 4-year vesting with 1-year cliff', - allocation_type: 'CUMULATIVE_ROUNDING', - vesting_conditions: [], - }; + const validVestingTerms = loadOcfGuardFixture('production/vestingTerms/time-based-cliff.json'); it('returns true for valid vesting terms objects', () => { expect(isOcfVestingTerms(validVestingTerms)).toBe(true); @@ -317,6 +594,7 @@ describe('OCF Type Guards', () => { describe('isOcfValuation', () => { const validValuation = { + object_type: 'VALUATION', id: 'val-1', stock_class_id: 'class-1', effective_date: '2024-01-01', @@ -335,14 +613,17 @@ describe('OCF Type Guards', () => { describe('isOcfDocument', () => { const validDocument = { + object_type: 'DOCUMENT', id: 'doc-1', - md5: 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: '/docs/agreement.pdf', }; it('returns true for valid document objects', () => { expect(isOcfDocument(validDocument)).toBe(true); expect(isOcfDocument({ ...validDocument, path: '/docs/agreement.pdf' })).toBe(true); - expect(isOcfDocument({ ...validDocument, uri: 'https://example.com/doc.pdf' })).toBe(true); + const { path: _, ...documentWithoutPath } = validDocument; + expect(isOcfDocument({ ...documentWithoutPath, uri: 'https://example.com/doc.pdf' })).toBe(true); }); it('returns false when required fields are missing', () => { @@ -352,8 +633,9 @@ describe('OCF Type Guards', () => { }); describe('detectOcfObjectType', () => { - it('detects issuer from structure', () => { + it('detects issuer from its discriminator', () => { const issuer = { + object_type: 'ISSUER', id: 'issuer-1', legal_name: 'Test Corp', formation_date: '2024-01-01', @@ -362,8 +644,9 @@ describe('detectOcfObjectType', () => { expect(detectOcfObjectType(issuer)).toBe('ISSUER'); }); - it('detects stakeholder from structure', () => { + it('detects stakeholder from its discriminator', () => { const stakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-1', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -371,8 +654,9 @@ describe('detectOcfObjectType', () => { expect(detectOcfObjectType(stakeholder)).toBe('STAKEHOLDER'); }); - it('detects stock class from structure', () => { + it('detects stock class from its discriminator', () => { const stockClass = { + object_type: 'STOCK_CLASS', id: 'class-1', name: 'Common', default_id_prefix: 'CS-', @@ -391,12 +675,23 @@ describe('detectOcfObjectType', () => { expect(detectOcfObjectType('string')).toBe('UNKNOWN'); }); - it('uses object_type field when available', () => { + it('does not infer identity from an untagged shape', () => { + expect( + detectOcfObjectType({ + id: 'issuer-1', + legal_name: 'Test Corp', + formation_date: '2024-01-01', + country_of_formation: 'US', + }) + ).toBe('UNKNOWN'); + }); + + it('does not identify an incomplete object from object_type alone', () => { const withObjectType = { object_type: 'ISSUER', id: 'issuer-1', }; - expect(detectOcfObjectType(withObjectType)).toBe('ISSUER'); + expect(detectOcfObjectType(withObjectType)).toBe('UNKNOWN'); }); }); @@ -404,6 +699,7 @@ describe('Assertion Functions', () => { describe('assertOcfIssuer', () => { it('does not throw for valid issuer', () => { const issuer = { + object_type: 'ISSUER', id: 'issuer-1', legal_name: 'Test Corp', formation_date: '2024-01-01', @@ -425,6 +721,7 @@ describe('Assertion Functions', () => { describe('assertOcfStakeholder', () => { it('does not throw for valid stakeholder', () => { const stakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-1', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -440,6 +737,7 @@ describe('Assertion Functions', () => { describe('assertOcfStockClass', () => { it('does not throw for valid stock class', () => { const stockClass = { + object_type: 'STOCK_CLASS', id: 'class-1', name: 'Common', default_id_prefix: 'CS-', diff --git a/test/validation/boundaries.test.ts b/test/validation/boundaries.test.ts index ce9bf5a5..a1889f2e 100644 --- a/test/validation/boundaries.test.ts +++ b/test/validation/boundaries.test.ts @@ -75,6 +75,7 @@ describe('Boundary Condition Tests', () => { test('handles Unicode strings correctly', () => { const unicodeData: OcfStakeholder = { id: 'sh-unicode', + object_type: 'STAKEHOLDER', name: { legal_name: '日本語の名前' }, stakeholder_type: 'INDIVIDUAL', }; @@ -86,6 +87,7 @@ describe('Boundary Condition Tests', () => { test('handles emoji in names correctly', () => { const emojiData: OcfStakeholder = { id: 'sh-emoji', + object_type: 'STAKEHOLDER', name: { legal_name: 'Test 🚀 Corp' }, stakeholder_type: 'INSTITUTION', }; @@ -99,6 +101,7 @@ describe('Boundary Condition Tests', () => { test('stakeholder handles empty arrays correctly', () => { const dataWithEmptyArrays: OcfStakeholder = { id: 'sh-empty-arrays', + object_type: 'STAKEHOLDER', name: { legal_name: 'Test' }, stakeholder_type: 'INDIVIDUAL', addresses: [], @@ -117,6 +120,7 @@ describe('Boundary Condition Tests', () => { test('stakeholder handles undefined arrays correctly', () => { const dataWithUndefinedArrays: OcfStakeholder = { id: 'sh-undefined-arrays', + object_type: 'STAKEHOLDER', name: { legal_name: 'Test' }, stakeholder_type: 'INDIVIDUAL', // Arrays intentionally not provided @@ -132,6 +136,7 @@ describe('Boundary Condition Tests', () => { test('maps legacy current_relationship when current_relationships is missing', () => { const legacyRelationshipData: OcfStakeholder = { id: 'sh-legacy-relationship', + object_type: 'STAKEHOLDER', name: { legal_name: 'Legacy Stakeholder' }, stakeholder_type: 'INDIVIDUAL', current_relationship: 'FOUNDER', @@ -144,6 +149,7 @@ describe('Boundary Condition Tests', () => { test('uses current_relationships when both relationship fields are present', () => { const bothFieldsData: OcfStakeholder = { id: 'sh-both-relationship-fields', + object_type: 'STAKEHOLDER', name: { legal_name: 'Both Fields Stakeholder' }, stakeholder_type: 'INDIVIDUAL', current_relationship: 'FOUNDER', @@ -157,6 +163,7 @@ describe('Boundary Condition Tests', () => { test('preserves explicit empty current_relationships when both fields are present', () => { const bothFieldsWithExplicitEmptyArray: OcfStakeholder = { id: 'sh-both-empty-relationships', + object_type: 'STAKEHOLDER', name: { legal_name: 'Explicit Empty Relationships' }, stakeholder_type: 'INDIVIDUAL', current_relationship: 'FOUNDER', @@ -170,6 +177,7 @@ describe('Boundary Condition Tests', () => { test('comments array filters out empty strings', () => { const dataWithComments: OcfStakeholder = { id: 'sh-comments', + object_type: 'STAKEHOLDER', name: { legal_name: 'Test' }, stakeholder_type: 'INDIVIDUAL', comments: ['Valid comment', '', 'Another comment', ' '], @@ -186,6 +194,7 @@ describe('Boundary Condition Tests', () => { test('DAML optional fields use null, not undefined', () => { const data: OcfStakeholder = { id: 'sh-null-test', + object_type: 'STAKEHOLDER', name: { legal_name: 'Test' }, stakeholder_type: 'INDIVIDUAL', issuer_assigned_id: undefined, // Should become null in DAML @@ -205,12 +214,14 @@ describe('Boundary Condition Tests', () => { test('stakeholder type handles both enum values', () => { const individual: OcfStakeholder = { id: 'sh-individual', + object_type: 'STAKEHOLDER', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', }; const institution: OcfStakeholder = { id: 'sh-institution', + object_type: 'STAKEHOLDER', name: { legal_name: 'Acme Corp' }, stakeholder_type: 'INSTITUTION', }; @@ -222,6 +233,7 @@ describe('Boundary Condition Tests', () => { test('relationship types are normalized correctly', () => { const dataWithRelationships: OcfStakeholder = { id: 'sh-relationships', + object_type: 'STAKEHOLDER', name: { legal_name: 'Test' }, stakeholder_type: 'INDIVIDUAL', current_relationships: ['EMPLOYEE', 'INVESTOR', 'FOUNDER', 'BOARD_MEMBER', 'ADVISOR', 'OFFICER', 'OTHER'], @@ -240,6 +252,7 @@ describe('Boundary Condition Tests', () => { test('fails fast for invalid current_relationships values', () => { const invalidRelationshipArrayData: OcfStakeholder = { id: 'sh-invalid-array-relationship', + object_type: 'STAKEHOLDER', name: { legal_name: 'Invalid Relationship Array' }, stakeholder_type: 'INDIVIDUAL', current_relationships: ['INVALID_RELATIONSHIP' as never], @@ -252,6 +265,7 @@ describe('Boundary Condition Tests', () => { test('fails fast for invalid legacy current_relationship values', () => { const invalidLegacyRelationshipData: OcfStakeholder = { id: 'sh-invalid-legacy-relationship', + object_type: 'STAKEHOLDER', name: { legal_name: 'Invalid Legacy Relationship' }, stakeholder_type: 'INDIVIDUAL', current_relationship: '' as never, @@ -266,6 +280,7 @@ describe('Boundary Condition Tests', () => { test('legacy DB relationship resolves to same dashboard bucket after conversion', () => { const legacyDbStakeholder: OcfStakeholder = { id: 'sh-parity-legacy', + object_type: 'STAKEHOLDER', name: { legal_name: 'Parity Stakeholder' }, stakeholder_type: 'INDIVIDUAL', current_relationship: 'ADVISOR', @@ -281,6 +296,7 @@ describe('Boundary Condition Tests', () => { test('missing relationship stays OTHER bucket after conversion', () => { const noRelationshipStakeholder: OcfStakeholder = { id: 'sh-parity-empty', + object_type: 'STAKEHOLDER', name: { legal_name: 'No Relationship Stakeholder' }, stakeholder_type: 'INDIVIDUAL', }; diff --git a/test/validation/requiredFields.test.ts b/test/validation/requiredFields.test.ts index 7c6a6f62..9a6e7460 100644 --- a/test/validation/requiredFields.test.ts +++ b/test/validation/requiredFields.test.ts @@ -14,6 +14,7 @@ describe('Required Field Validation', () => { describe('stakeholderDataToDaml', () => { test('throws OcpValidationError when id is missing', () => { const invalidData = { + object_type: 'STAKEHOLDER', name: { legal_name: 'Test Stakeholder' }, stakeholder_type: 'INDIVIDUAL', } as unknown as OcfStakeholder; @@ -25,6 +26,7 @@ describe('Required Field Validation', () => { test('throws OcpValidationError when id is empty string', () => { const invalidData = { id: '', + object_type: 'STAKEHOLDER', name: { legal_name: 'Test Stakeholder' }, stakeholder_type: 'INDIVIDUAL', } as OcfStakeholder; @@ -36,6 +38,7 @@ describe('Required Field Validation', () => { test('succeeds with valid minimal data', () => { const validData: OcfStakeholder = { id: 'sh-001', + object_type: 'STAKEHOLDER', name: { legal_name: 'Test Stakeholder' }, stakeholder_type: 'INDIVIDUAL', }; @@ -49,6 +52,7 @@ describe('Required Field Validation', () => { describe('stockIssuanceDataToDaml', () => { const validBaseData = { id: 'iss-001', + object_type: 'TX_STOCK_ISSUANCE' as const, security_id: 'sec-001', custom_id: 'CS-001', stakeholder_id: 'sh-001', From 68a5c5c8b00180284b8d45e4960505bd34b67f0f Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 21:33:01 -0400 Subject: [PATCH 03/47] Validate generated DAML batch operations --- .../OpenCapTable/capTable/CapTableBatch.ts | 197 +++++++++++++----- src/functions/OpenCapTable/capTable/index.ts | 3 + .../warrantIssuance/createWarrantIssuance.ts | 4 +- .../generatedOperationConstruction.test.ts | 152 ++++++++++++++ .../generatedOperationConstruction.types.ts | 34 +++ .../setup/integrationTestHarness.ts | 39 ---- test/mocks/fairmint-canton-node-sdk.ts | 6 +- .../generatedOperationConstruction.types.ts | 56 +++++ tsconfig.json | 4 + 9 files changed, 403 insertions(+), 92 deletions(-) create mode 100644 test/batch/generatedOperationConstruction.test.ts create mode 100644 test/declarations/generatedOperationConstruction.types.ts create mode 100644 test/types/generatedOperationConstruction.types.ts diff --git a/src/functions/OpenCapTable/capTable/CapTableBatch.ts b/src/functions/OpenCapTable/capTable/CapTableBatch.ts index 9d9109c9..eb0942fa 100644 --- a/src/functions/OpenCapTable/capTable/CapTableBatch.ts +++ b/src/functions/OpenCapTable/capTable/CapTableBatch.ts @@ -7,7 +7,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api'; import type { Command } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/commands'; -import { OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; +import { Fairmint, OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; import { OcpContractError, OcpErrorCodes, OcpValidationError } from '../../../errors'; import { mergeCommandContext, @@ -24,17 +24,21 @@ import { type CapTableBatchOperations, type OcfCreateArguments, type OcfCreateData, + type OcfCreateDataFor, type OcfCreateOperation, type OcfDataTypeFor, type OcfDeletableEntityType, type OcfDeleteData, + type OcfDeleteDataFor, + type OcfDeleteOperation, type OcfEditArguments, type OcfEditData, + type OcfEditDataFor, type OcfEditOperation, type OcfEntityType, type UpdateCapTableResult, } from './batchTypes'; -import { convertToDaml } from './ocfToDaml'; +import { convertOperationToDaml, convertToDaml } from './ocfToDaml'; /** Parameters for initializing a batch update. */ export interface CapTableBatchParams extends CommandObservabilityOptions { @@ -67,6 +71,124 @@ export interface BatchItemMeta { securityId?: string; } +function decodeGeneratedOperation( + decoder: { runWithException: (input: unknown) => T }, + input: unknown, + operation: 'create' | 'edit' | 'delete', + entityType: OcfEntityType +): T { + try { + return decoder.runWithException(input); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new OcpValidationError( + `batch.${operation}.${entityType}`, + `Converter output does not match the generated DAML ${operation} variant: ${message}`, + { + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue: input, + } + ); + } +} + +/** Build and validate one generated DAML create variant from a correlated entity-kind/data pair. */ +export function buildOcfCreateData( + ...args: Arguments +): OcfCreateDataFor; +export function buildOcfCreateData(...args: OcfCreateArguments): OcfCreateData { + const [type] = args; + if (!isOcfCreatableEntityType(type)) { + throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { + code: OcpErrorCodes.INVALID_TYPE, + }); + } + + const tag = ENTITY_TAG_MAP[type].create; + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, + { tag, value: convertToDaml(...args) }, + 'create', + type + ); +} + +function buildOcfCreateDataFromOperation(operation: OcfCreateOperation): OcfCreateData { + const { type } = operation; + if (!isOcfCreatableEntityType(type)) { + throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { + code: OcpErrorCodes.INVALID_TYPE, + }); + } + + const tag = ENTITY_TAG_MAP[type].create; + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, + { tag, value: convertOperationToDaml(operation) }, + 'create', + type + ); +} + +/** Build and validate one generated DAML edit variant from a correlated entity-kind/data pair. */ +export function buildOcfEditData( + ...args: Arguments +): OcfEditDataFor; +export function buildOcfEditData(...args: OcfEditArguments): OcfEditData { + const [type] = args; + if (!isOcfEditableEntityType(type)) { + throw new OcpValidationError('type', `Edit operation not supported for entity type: ${String(type)}`, { + code: OcpErrorCodes.INVALID_TYPE, + }); + } + + const tag = ENTITY_TAG_MAP[type].edit; + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, + { tag, value: convertToDaml(...args) }, + 'edit', + type + ); +} + +function buildOcfEditDataFromOperation(operation: OcfEditOperation): OcfEditData { + const { type } = operation; + if (!isOcfEditableEntityType(type)) { + throw new OcpValidationError('type', `Edit operation not supported for entity type: ${String(type)}`, { + code: OcpErrorCodes.INVALID_TYPE, + }); + } + + const tag = ENTITY_TAG_MAP[type].edit; + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, + { tag, value: convertOperationToDaml(operation) }, + 'edit', + type + ); +} + +/** Build and validate one generated DAML delete variant. */ +export function buildOcfDeleteData( + type: EntityType, + id: string +): OcfDeleteDataFor; +export function buildOcfDeleteData(type: OcfDeletableEntityType, id: string): OcfDeleteData { + if (!isOcfDeletableEntityType(type)) { + throw new OcpValidationError('type', `Delete operation not supported for entity type: ${String(type)}`, { + code: OcpErrorCodes.INVALID_TYPE, + }); + } + + const tag = ENTITY_TAG_MAP[type].delete; + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfDeleteData.decoder, + { tag, value: id }, + 'delete', + type + ); +} + /** * Fluent batch builder for cap table updates. * @@ -111,24 +233,18 @@ export class CapTableBatch { */ create(...args: OcfCreateArguments): this { const [type, data] = args; - if (!isOcfCreatableEntityType(type)) { - throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - - const tag = ENTITY_TAG_MAP[type].create; - if (!tag) { - throw new OcpValidationError('type', `Create operation not supported for entity type: ${type}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - const damlData = convertToDaml(...args); - this.creates.push({ tag, value: damlData } as unknown as OcfCreateData); + this.creates.push(buildOcfCreateData(...args)); this.createMetas.push(extractBatchItemMeta(type, data)); return this; } + /** Add a pre-correlated create operation object to the batch. */ + createOperation(operation: OcfCreateOperation): this { + this.creates.push(buildOcfCreateDataFromOperation(operation)); + this.createMetas.push(extractBatchItemMeta(operation.type, operation.data)); + return this; + } + /** * Add an edit operation to the batch. * @@ -138,24 +254,18 @@ export class CapTableBatch { */ edit(...args: OcfEditArguments): this { const [type, data] = args; - if (!isOcfEditableEntityType(type)) { - throw new OcpValidationError('type', `Edit operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - - const tag = ENTITY_TAG_MAP[type].edit; - if (!tag) { - throw new OcpValidationError('type', `Edit operation not supported for entity type: ${type}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - const damlData = convertToDaml(...args); - this.edits.push({ tag, value: damlData } as unknown as OcfEditData); + this.edits.push(buildOcfEditData(...args)); this.editMetas.push(extractBatchItemMeta(type, data)); return this; } + /** Add a pre-correlated edit operation object to the batch. */ + editOperation(operation: OcfEditOperation): this { + this.edits.push(buildOcfEditDataFromOperation(operation)); + this.editMetas.push(extractBatchItemMeta(operation.type, operation.data)); + return this; + } + /** * Add a delete operation to the batch. * @@ -171,17 +281,16 @@ export class CapTableBatch { }); } - const tag = ENTITY_TAG_MAP[type].delete; - if (!tag) { - throw new OcpValidationError('type', `Delete operation not supported for entity type: ${type}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - this.deletes.push({ tag, value: id } as unknown as OcfDeleteData); + this.deletes.push(buildOcfDeleteData(type, id)); this.deleteMetas.push({ entityType: type, id }); return this; } + /** Add a pre-correlated delete operation object to the batch. */ + deleteOperation(operation: OcfDeleteOperation): this { + return this.delete(operation.type, operation.id); + } + /** Get the number of operations in the batch. */ get size(): number { return this.creates.length + this.edits.length + this.deletes.length; @@ -516,22 +625,14 @@ export function buildUpdateCapTableCommand( const batch = new CapTableBatch({ ...params, actAs: [] }); for (const op of operations.creates ?? []) { - batch.create(...createOperationArguments(op)); + batch.createOperation(op); } for (const op of operations.edits ?? []) { - batch.edit(...editOperationArguments(op)); + batch.editOperation(op); } for (const op of operations.deletes ?? []) { - batch.delete(op.type, op.id); + batch.deleteOperation(op); } return batch.build(); } - -function createOperationArguments(operation: OcfCreateOperation): OcfCreateArguments { - return [operation.type, operation.data] as OcfCreateArguments; -} - -function editOperationArguments(operation: OcfEditOperation): OcfEditArguments { - return [operation.type, operation.data] as OcfEditArguments; -} diff --git a/src/functions/OpenCapTable/capTable/index.ts b/src/functions/OpenCapTable/capTable/index.ts index 2b45d7f8..6e8e8892 100644 --- a/src/functions/OpenCapTable/capTable/index.ts +++ b/src/functions/OpenCapTable/capTable/index.ts @@ -20,6 +20,9 @@ export { export * from './batchTypes'; export { CapTableBatch, + buildOcfCreateData, + buildOcfDeleteData, + buildOcfEditData, buildUpdateCapTableCommand, type BatchItemDetails, type BatchItemMeta, diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 3111941b..872c742e 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -378,8 +378,8 @@ function quantitySourceToDamlEnum( case 'INSTRUMENT_MIN': return 'OcfQuantityInstrumentMin'; default: { - const _exhaustiveCheck: never = qs; - throw new OcpParseError(`Unknown quantity_source: ${String(qs)}`, { + const exhaustiveCheck: never = qs; + throw new OcpParseError(`Unknown quantity_source: ${String(exhaustiveCheck)}`, { source: 'warrantIssuance.quantity_source', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); diff --git a/test/batch/generatedOperationConstruction.test.ts b/test/batch/generatedOperationConstruction.test.ts new file mode 100644 index 00000000..11cbe92f --- /dev/null +++ b/test/batch/generatedOperationConstruction.test.ts @@ -0,0 +1,152 @@ +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { + buildOcfCreateData, + buildOcfDeleteData, + buildOcfEditData, + buildUpdateCapTableCommand, + ENTITY_REGISTRY, + ENTITY_TAG_MAP, + isOcfCreatableEntityType, + isOcfDeletableEntityType, + isOcfEditableEntityType, + parseOcfEntityInput, + parseOcfObject, + type OcfCreateArguments, + type OcfDataTypeFor, + type OcfEditArguments, + type OcfEntityType, +} from '../../src'; +import { loadFixture, stripSourceMetadata } from '../utils/productionFixtures'; + +function loadEntityFixture( + entityType: T, + relativePath: string +): readonly [type: T, data: OcfDataTypeFor] { + const fixture = stripSourceMetadata(loadFixture>(relativePath)); + const canonicalFixture = parseOcfObject(fixture); + return [entityType, parseOcfEntityInput(entityType, canonicalFixture)]; +} + +const editableFixtures: readonly OcfEditArguments[] = [ + loadEntityFixture('convertibleAcceptance', 'synthetic/convertibleAcceptance.json'), + loadEntityFixture('convertibleCancellation', 'production/convertibleCancellation.json'), + loadEntityFixture('convertibleConversion', 'production/convertibleConversion.json'), + loadEntityFixture('convertibleIssuance', 'production/convertibleIssuance/safe-post-money.json'), + loadEntityFixture('convertibleRetraction', 'synthetic/convertibleRetraction.json'), + loadEntityFixture('convertibleTransfer', 'production/convertibleTransfer.json'), + loadEntityFixture('document', 'production/document/basic.json'), + loadEntityFixture('equityCompensationAcceptance', 'synthetic/equityCompensationAcceptance.json'), + loadEntityFixture('equityCompensationCancellation', 'production/equityCompensationCancellation.json'), + loadEntityFixture('equityCompensationExercise', 'production/equityCompensationExercise.json'), + loadEntityFixture('equityCompensationIssuance', 'production/equityCompensationIssuance/option-iso.json'), + loadEntityFixture('equityCompensationRelease', 'synthetic/equityCompensationRelease.json'), + loadEntityFixture('equityCompensationRepricing', 'synthetic/equityCompensationRepricing.json'), + loadEntityFixture('equityCompensationRetraction', 'synthetic/equityCompensationRetraction.json'), + loadEntityFixture('equityCompensationTransfer', 'synthetic/equityCompensationTransfer.json'), + loadEntityFixture('issuer', 'production/issuer/basic.json'), + loadEntityFixture('issuerAuthorizedSharesAdjustment', 'production/issuerAuthorizedSharesAdjustment.json'), + loadEntityFixture('stakeholder', 'production/stakeholder/individual.json'), + loadEntityFixture('stakeholderRelationshipChangeEvent', 'synthetic/stakeholderRelationshipChangeEvent.json'), + loadEntityFixture('stakeholderStatusChangeEvent', 'synthetic/stakeholderStatusChangeEvent.json'), + loadEntityFixture('stockAcceptance', 'synthetic/stockAcceptance.json'), + loadEntityFixture('stockCancellation', 'production/stockCancellation.json'), + loadEntityFixture('stockClass', 'production/stockClass/common.json'), + loadEntityFixture('stockClassAuthorizedSharesAdjustment', 'production/stockClassAuthorizedSharesAdjustment.json'), + loadEntityFixture('stockClassConversionRatioAdjustment', 'synthetic/stockClassConversionRatioAdjustment.json'), + loadEntityFixture('stockClassSplit', 'production/stockClassSplit.json'), + loadEntityFixture('stockConsolidation', 'synthetic/stockConsolidation.json'), + loadEntityFixture('stockConversion', 'synthetic/stockConversion.json'), + loadEntityFixture('stockIssuance', 'production/stockIssuance/founders-stock.json'), + loadEntityFixture('stockLegendTemplate', 'production/stockLegendTemplate/rule-144.json'), + loadEntityFixture('stockPlan', 'production/stockPlan/basic.json'), + loadEntityFixture('stockPlanPoolAdjustment', 'production/stockPlanPoolAdjustment.json'), + loadEntityFixture('stockPlanReturnToPool', 'synthetic/stockPlanReturnToPool.json'), + loadEntityFixture('stockReissuance', 'synthetic/stockReissuance.json'), + loadEntityFixture('stockRepurchase', 'production/stockRepurchase.json'), + loadEntityFixture('stockRetraction', 'synthetic/stockRetraction.json'), + loadEntityFixture('stockTransfer', 'production/stockTransfer.json'), + loadEntityFixture('valuation', 'production/valuation/409a.json'), + loadEntityFixture('vestingAcceleration', 'synthetic/vestingAcceleration.json'), + loadEntityFixture('vestingEvent', 'synthetic/vestingEvent.json'), + loadEntityFixture('vestingStart', 'production/vestingStart.json'), + loadEntityFixture('vestingTerms', 'production/vestingTerms/time-based-cliff.json'), + loadEntityFixture('warrantAcceptance', 'synthetic/warrantAcceptance.json'), + loadEntityFixture('warrantCancellation', 'synthetic/warrantCancellation.json'), + loadEntityFixture('warrantExercise', 'synthetic/warrantExercise.json'), + loadEntityFixture('warrantIssuance', 'production/warrantIssuance.json'), + loadEntityFixture('warrantRetraction', 'synthetic/warrantRetraction.json'), + loadEntityFixture('warrantTransfer', 'synthetic/warrantTransfer.json'), +]; + +const creatableFixtures = editableFixtures.filter((args): args is OcfCreateArguments => + isOcfCreatableEntityType(args[0]) +); +const deletableEntityTypes = Object.keys(ENTITY_REGISTRY).filter(isOcfDeletableEntityType); + +const createCases = creatableFixtures.map((args) => ({ entityType: args[0], args })); +const editCases = editableFixtures.map((args) => ({ entityType: args[0], args })); +const deleteCases = deletableEntityTypes.map((entityType) => ({ entityType })); + +describe('generated DAML batch operation construction', () => { + test('fixture matrix covers every generated operation capability exactly once', () => { + const expectedCreatableTypes = Object.keys(ENTITY_REGISTRY).filter(isOcfCreatableEntityType).sort(); + const expectedEditableTypes = Object.keys(ENTITY_REGISTRY).filter(isOcfEditableEntityType).sort(); + const expectedDeletableTypes = Object.keys(ENTITY_REGISTRY).filter(isOcfDeletableEntityType).sort(); + + expect(createCases.map(({ entityType }) => entityType).sort()).toEqual(expectedCreatableTypes); + expect(editCases.map(({ entityType }) => entityType).sort()).toEqual(expectedEditableTypes); + expect(deleteCases.map(({ entityType }) => entityType).sort()).toEqual(expectedDeletableTypes); + expect(new Set(createCases.map(({ entityType }) => entityType)).size).toBe(createCases.length); + expect(new Set(editCases.map(({ entityType }) => entityType)).size).toBe(editCases.length); + }); + + test.each(createCases)('constructs and decodes the $entityType create variant', ({ entityType, args }) => { + const create = buildOcfCreateData(...args); + + expect(create.tag).toBe(ENTITY_TAG_MAP[entityType].create); + expect(Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder.runWithException(create)).toEqual(create); + }); + + test.each(editCases)('constructs and decodes the $entityType edit variant', ({ entityType, args }) => { + const edit = buildOcfEditData(...args); + + expect(edit.tag).toBe(ENTITY_TAG_MAP[entityType].edit); + expect(Fairmint.OpenCapTable.CapTable.OcfEditData.decoder.runWithException(edit)).toEqual(edit); + }); + + test.each(deleteCases)('constructs and decodes the $entityType delete variant', ({ entityType }) => { + const id = `${entityType}-generated-delete-id`; + const deletion = buildOcfDeleteData(entityType, id); + + expect(deletion).toEqual({ tag: ENTITY_TAG_MAP[entityType].delete, value: id }); + expect(Fairmint.OpenCapTable.CapTable.OcfDeleteData.decoder.runWithException(deletion)).toEqual(deletion); + }); + + test('constructs correlated operation objects without rebuilding asserted tuples', () => { + const stakeholder = parseOcfEntityInput( + 'stakeholder', + parseOcfObject( + stripSourceMetadata(loadFixture>('production/stakeholder/individual.json')) + ) + ); + const { command } = buildUpdateCapTableCommand( + { capTableContractId: 'cap-table-generated-operation-1' }, + { + creates: [{ type: 'stakeholder', data: stakeholder }], + edits: [{ type: 'stakeholder', data: stakeholder }], + deletes: [{ type: 'stakeholder', id: stakeholder.id }], + } + ); + + if (!('ExerciseCommand' in command)) { + throw new Error('Expected an ExerciseCommand'); + } + + const operations = Fairmint.OpenCapTable.CapTable.UpdateCapTable.decoder.runWithException( + command.ExerciseCommand.choiceArgument + ); + expect(operations.creates[0]?.tag).toBe('OcfCreateStakeholder'); + expect(operations.edits[0]?.tag).toBe('OcfEditStakeholder'); + expect(operations.deletes[0]).toEqual({ tag: 'OcfDeleteStakeholder', value: stakeholder.id }); + }); +}); diff --git a/test/declarations/generatedOperationConstruction.types.ts b/test/declarations/generatedOperationConstruction.types.ts new file mode 100644 index 00000000..3e89a806 --- /dev/null +++ b/test/declarations/generatedOperationConstruction.types.ts @@ -0,0 +1,34 @@ +/** Built-declaration contracts for exact generated DAML batch operation variants. */ + +import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { + buildOcfCreateData, + buildOcfDeleteData, + buildOcfEditData, + type OcfCreateDataFor, + type OcfDeleteDataFor, + type OcfEditDataFor, + type OcfIssuer, + type OcfStakeholder, +} from '../../dist'; + +declare const stakeholder: OcfStakeholder; +declare const issuer: OcfIssuer; + +const create: OcfCreateDataFor<'stakeholder'> = buildOcfCreateData('stakeholder', stakeholder); +const edit: OcfEditDataFor<'issuer'> = buildOcfEditData('issuer', issuer); +const deletion: OcfDeleteDataFor<'stakeholder'> = buildOcfDeleteData('stakeholder', stakeholder.id); + +const createTag: 'OcfCreateStakeholder' = create.tag; +const editTag: 'OcfEditIssuer' = edit.tag; +const deleteTag: 'OcfDeleteStakeholder' = deletion.tag; +const createValue: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData = create.value; +const editValue: Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData = edit.value; +const deleteValue: string = deletion.value; + +void createTag; +void editTag; +void deleteTag; +void createValue; +void editValue; +void deleteValue; diff --git a/test/integration/setup/integrationTestHarness.ts b/test/integration/setup/integrationTestHarness.ts index b0fdf51f..dc9b3a9e 100644 --- a/test/integration/setup/integrationTestHarness.ts +++ b/test/integration/setup/integrationTestHarness.ts @@ -209,45 +209,6 @@ async function initializeHarness(): Promise { } } -/** - * Discover parties to use for tests. - * - * @deprecated Currently unused - party discovery is done inline in initializeHarness. Kept for potential future use - * with multi-party test scenarios. - */ -async function _discoverParties(ocp: OcpClient): Promise<{ issuerParty: string; systemOperatorParty: string }> { - // Try to find parties from LocalNet - const response = await ocp.ledger.listParties({}); - const partyDetails = (response as { partyDetails?: typeof response.partyDetails }).partyDetails ?? []; - - if (partyDetails.length === 0) { - throw new Error('No parties found in LocalNet. Make sure cn-quickstart is running and parties are allocated.'); - } - - // Helper to find a party by name hints - const findParty = (hints: string[]): string | null => { - for (const hint of hints) { - const party = partyDetails.find((p) => p.party.toLowerCase().includes(hint.toLowerCase())); - if (party) return party.party; - } - return null; - }; - - // System operator (usually "intellect" or "admin" or first party) - const systemOperatorParty = findParty(['intellect', 'admin', 'operator', 'sv']) ?? partyDetails[0].party; - - // Issuer party (usually "alice" or different from system operator) - let issuerParty = findParty(['alice', 'issuer', 'company', 'provider']); - - // If no specific issuer found, use first party that's not the system operator - if (!issuerParty) { - const otherParty = partyDetails.find((p) => p.party !== systemOperatorParty); - issuerParty = otherParty?.party ?? systemOperatorParty; - } - - return { issuerParty, systemOperatorParty }; -} - /** * Get the current test context. * diff --git a/test/mocks/fairmint-canton-node-sdk.ts b/test/mocks/fairmint-canton-node-sdk.ts index 2bd8767e..69495f33 100644 --- a/test/mocks/fairmint-canton-node-sdk.ts +++ b/test/mocks/fairmint-canton-node-sdk.ts @@ -89,7 +89,7 @@ export class LedgerJsonApiClient { } export class AuthenticationManager { - constructor(private readonly config?: ClientConfig) {} + constructor(_config?: ClientConfig) {} async getAuthToken(): Promise { // Mock implementation - returns undefined return Promise.resolve(undefined); @@ -100,7 +100,7 @@ export class BaseClient { protected readonly authManager: AuthenticationManager; constructor( - private readonly name: string, + _name: string, protected readonly config?: ClientConfig ) { this.authManager = new AuthenticationManager(config); @@ -137,7 +137,7 @@ export class ValidatorApiClient extends BaseClient { (ValidatorApiClient.__instances as any).push(this); } - public async getAuthToken(): Promise { + public override async getAuthToken(): Promise { return super.getAuthToken(); } } diff --git a/test/types/generatedOperationConstruction.types.ts b/test/types/generatedOperationConstruction.types.ts new file mode 100644 index 00000000..2b22d458 --- /dev/null +++ b/test/types/generatedOperationConstruction.types.ts @@ -0,0 +1,56 @@ +/** Compile-time contracts for cast-free generated DAML operation builders. */ + +import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { + buildOcfCreateData, + buildOcfDeleteData, + buildOcfEditData, + type OcfCreateData, + type OcfCreateDataFor, + type OcfDeleteData, + type OcfDeleteDataFor, + type OcfEditData, + type OcfEditDataFor, + type OcfIssuer, + type OcfStakeholder, + type OcfStockClass, +} from '../../src'; + +function verifyGeneratedOperationBuilders( + stakeholder: OcfStakeholder, + stockClass: OcfStockClass, + issuer: OcfIssuer +): void { + const create: OcfCreateData = buildOcfCreateData('stakeholder', stakeholder); + const edit: OcfEditData = buildOcfEditData('issuer', issuer); + const deletion: OcfDeleteData = buildOcfDeleteData('stakeholder', stakeholder.id); + const exactCreate: OcfCreateDataFor<'stakeholder'> = buildOcfCreateData('stakeholder', stakeholder); + const exactEdit: OcfEditDataFor<'issuer'> = buildOcfEditData('issuer', issuer); + const exactDelete: OcfDeleteDataFor<'stakeholder'> = buildOcfDeleteData('stakeholder', stakeholder.id); + const exactCreateTag: 'OcfCreateStakeholder' = exactCreate.tag; + const exactEditTag: 'OcfEditIssuer' = exactEdit.tag; + const exactDeleteTag: 'OcfDeleteStakeholder' = exactDelete.tag; + const exactCreateValue: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData = exactCreate.value; + const exactEditValue: Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData = exactEdit.value; + const exactDeleteValue: string = exactDelete.value; + void create; + void edit; + void deletion; + void exactCreateTag; + void exactEditTag; + void exactDeleteTag; + void exactCreateValue; + void exactEditValue; + void exactDeleteValue; + + // @ts-expect-error create builders preserve entity-kind/payload correlation + buildOcfCreateData('stockClass', stakeholder); + + // @ts-expect-error edit builders preserve entity-kind/payload correlation + buildOcfEditData('stakeholder', stockClass); + + // @ts-expect-error issuer is edit-only and has no generated delete variant + buildOcfDeleteData('issuer', issuer.id); +} + +void verifyGeneratedOperationBuilders; diff --git a/tsconfig.json b/tsconfig.json index fe3ea1e7..6f119664 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,10 @@ "outDir": "./dist", "rootDir": "./src", "strict": true, + "noImplicitReturns": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, From 5ea346886aeec7b99f9952f2055778becdff2841 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 21:44:57 -0400 Subject: [PATCH 04/47] Route client reads through validated dispatch --- src/OcpClient.ts | 323 +++--------------- .../OpenCapTable/capTable/CapTableBatch.ts | 15 - test/client/OcpClient.test.ts | 55 +++ test/declarations/publicApi.types.ts | 2 + 4 files changed, 98 insertions(+), 297 deletions(-) diff --git a/src/OcpClient.ts b/src/OcpClient.ts index 904e7112..38a8f0a6 100644 --- a/src/OcpClient.ts +++ b/src/OcpClient.ts @@ -71,48 +71,7 @@ import { OcpErrorCodes, OcpValidationError } from './errors'; import { authorizeIssuer, buildCreateIssuerCommand, - getConvertibleAcceptanceAsOcf, - getConvertibleCancellationAsOcf, - getConvertibleConversionAsOcf, - getConvertibleIssuanceAsOcf, - getConvertibleTransferAsOcf, - getDocumentAsOcf, getEntityAsOcf, - getEquityCompensationAcceptanceAsOcf, - getEquityCompensationCancellationAsOcf, - getEquityCompensationExerciseAsOcf, - getEquityCompensationIssuanceAsOcf, - getEquityCompensationTransferAsOcf, - getIssuerAsOcf, - getIssuerAuthorizedSharesAdjustmentAsOcf, - getStakeholderAsOcf, - getStakeholderRelationshipChangeEventAsOcf, - getStakeholderStatusChangeEventAsOcf, - getStockAcceptanceAsOcf, - getStockCancellationAsOcf, - getStockClassAsOcf, - getStockClassAuthorizedSharesAdjustmentAsOcf, - getStockClassConversionRatioAdjustmentAsOcf, - getStockClassSplitAsOcf, - getStockConsolidationAsOcf, - getStockConversionAsOcf, - getStockIssuanceAsOcf, - getStockLegendTemplateAsOcf, - getStockPlanAsOcf, - getStockPlanPoolAdjustmentAsOcf, - getStockReissuanceAsOcf, - getStockRepurchaseAsOcf, - getStockTransferAsOcf, - getValuationAsOcf, - getVestingAccelerationAsOcf, - getVestingEventAsOcf, - getVestingStartAsOcf, - getVestingTermsAsOcf, - getWarrantAcceptanceAsOcf, - getWarrantCancellationAsOcf, - getWarrantExerciseAsOcf, - getWarrantIssuanceAsOcf, - getWarrantTransferAsOcf, withdrawAuthorization, type AuthorizeIssuerParams, type AuthorizeIssuerResult, @@ -591,129 +550,34 @@ export class OcpClient { const methods = { // ===== Objects ===== issuer: { - get: async (params) => getIssuerAsOcf(client, params), + ...genericEntity('issuer'), buildCreate: (params) => buildCreateIssuerCommand(params), }, - stakeholder: { - get: async (params) => { - const r = await getStakeholderAsOcf(client, params); - return toContractResult(r.stakeholder, r.contractId); - }, - }, - stockClass: { - get: async (params) => { - const r = await getStockClassAsOcf(client, params); - return toContractResult(r.stockClass, r.contractId); - }, - }, - stockLegendTemplate: { - get: async (params) => { - const r = await getStockLegendTemplateAsOcf(client, params); - return toContractResult(r.stockLegendTemplate, r.contractId); - }, - }, - stockPlan: { - get: async (params) => { - const r = await getStockPlanAsOcf(client, params); - return toContractResult(r.stockPlan, r.contractId); - }, - }, - vestingTerms: { - get: async (params) => { - const r = await getVestingTermsAsOcf(client, params); - return toContractResult(r.vestingTerms, r.contractId); - }, - }, - valuation: { - get: async (params) => { - const r = await getValuationAsOcf(client, params); - return toContractResult(r.valuation, r.contractId); - }, - }, - document: { - get: async (params) => { - const r = await getDocumentAsOcf(client, params); - return toContractResult(r.document, r.contractId); - }, - }, + stakeholder: genericEntity('stakeholder'), + stockClass: genericEntity('stockClass'), + stockLegendTemplate: genericEntity('stockLegendTemplate'), + stockPlan: genericEntity('stockPlan'), + vestingTerms: genericEntity('vestingTerms'), + valuation: genericEntity('valuation'), + document: genericEntity('document'), // ===== Issuances ===== - stockIssuance: { - get: async (params) => { - const r = await getStockIssuanceAsOcf(client, params); - return toContractResult(r.stockIssuance, r.contractId); - }, - }, - equityCompensationIssuance: { - get: async (params) => { - const r = await getEquityCompensationIssuanceAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - warrantIssuance: { - get: async (params) => { - const r = await getWarrantIssuanceAsOcf(client, params); - return toContractResult(r.warrantIssuance, r.contractId); - }, - }, - convertibleIssuance: { - get: async (params) => { - const r = await getConvertibleIssuanceAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, + stockIssuance: genericEntity('stockIssuance'), + equityCompensationIssuance: genericEntity('equityCompensationIssuance'), + warrantIssuance: genericEntity('warrantIssuance'), + convertibleIssuance: genericEntity('convertibleIssuance'), // ===== Transfers ===== - stockTransfer: { - get: async (params) => { - const r = await getStockTransferAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - warrantTransfer: { - get: async (params) => { - const r = await getWarrantTransferAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - convertibleTransfer: { - get: async (params) => { - const r = await getConvertibleTransferAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - equityCompensationTransfer: { - get: async (params) => { - const r = await getEquityCompensationTransferAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, + stockTransfer: genericEntity('stockTransfer'), + warrantTransfer: genericEntity('warrantTransfer'), + convertibleTransfer: genericEntity('convertibleTransfer'), + equityCompensationTransfer: genericEntity('equityCompensationTransfer'), // ===== Cancellations ===== - stockCancellation: { - get: async (params) => { - const r = await getStockCancellationAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - warrantCancellation: { - get: async (params) => { - const r = await getWarrantCancellationAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - convertibleCancellation: { - get: async (params) => { - const r = await getConvertibleCancellationAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - equityCompensationCancellation: { - get: async (params) => { - const r = await getEquityCompensationCancellationAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, + stockCancellation: genericEntity('stockCancellation'), + warrantCancellation: genericEntity('warrantCancellation'), + convertibleCancellation: genericEntity('convertibleCancellation'), + equityCompensationCancellation: genericEntity('equityCompensationCancellation'), // ===== Retractions ===== stockRetraction: genericEntity('stockRetraction'), @@ -722,147 +586,42 @@ export class OcpClient { equityCompensationRetraction: genericEntity('equityCompensationRetraction'), // ===== Exercises ===== - equityCompensationExercise: { - get: async (params) => { - const r = await getEquityCompensationExerciseAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - warrantExercise: { - get: async (params) => { - const r = await getWarrantExerciseAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, + equityCompensationExercise: genericEntity('equityCompensationExercise'), + warrantExercise: genericEntity('warrantExercise'), // ===== Conversions ===== - stockConversion: { - get: async (params) => { - const r = await getStockConversionAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - convertibleConversion: { - get: async (params) => { - const r = await getConvertibleConversionAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, + stockConversion: genericEntity('stockConversion'), + convertibleConversion: genericEntity('convertibleConversion'), // ===== Acceptances ===== - stockAcceptance: { - get: async (params) => { - const r = await getStockAcceptanceAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - warrantAcceptance: { - get: async (params) => { - const r = await getWarrantAcceptanceAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - convertibleAcceptance: { - get: async (params) => { - const r = await getConvertibleAcceptanceAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - equityCompensationAcceptance: { - get: async (params) => { - const r = await getEquityCompensationAcceptanceAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, + stockAcceptance: genericEntity('stockAcceptance'), + warrantAcceptance: genericEntity('warrantAcceptance'), + convertibleAcceptance: genericEntity('convertibleAcceptance'), + equityCompensationAcceptance: genericEntity('equityCompensationAcceptance'), // ===== Adjustments ===== - issuerAuthorizedSharesAdjustment: { - get: async (params) => { - const r = await getIssuerAuthorizedSharesAdjustmentAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - stockClassAuthorizedSharesAdjustment: { - get: async (params) => { - const r = await getStockClassAuthorizedSharesAdjustmentAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - stockClassConversionRatioAdjustment: { - get: async (params) => { - const r = await getStockClassConversionRatioAdjustmentAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - stockClassSplit: { - get: async (params) => { - const r = await getStockClassSplitAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - stockPlanPoolAdjustment: { - get: async (params) => { - const r = await getStockPlanPoolAdjustmentAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, + issuerAuthorizedSharesAdjustment: genericEntity('issuerAuthorizedSharesAdjustment'), + stockClassAuthorizedSharesAdjustment: genericEntity('stockClassAuthorizedSharesAdjustment'), + stockClassConversionRatioAdjustment: genericEntity('stockClassConversionRatioAdjustment'), + stockClassSplit: genericEntity('stockClassSplit'), + stockPlanPoolAdjustment: genericEntity('stockPlanPoolAdjustment'), stockPlanReturnToPool: genericEntity('stockPlanReturnToPool'), // ===== Other Transactions ===== - stockRepurchase: { - get: async (params) => { - const r = await getStockRepurchaseAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - stockConsolidation: { - get: async (params) => { - const r = await getStockConsolidationAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - stockReissuance: { - get: async (params) => { - const r = await getStockReissuanceAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, + stockRepurchase: genericEntity('stockRepurchase'), + stockConsolidation: genericEntity('stockConsolidation'), + stockReissuance: genericEntity('stockReissuance'), equityCompensationRelease: genericEntity('equityCompensationRelease'), equityCompensationRepricing: genericEntity('equityCompensationRepricing'), // ===== Vesting ===== - vestingStart: { - get: async (params) => { - const r = await getVestingStartAsOcf(client, params); - return toContractResult(r.vestingStart, r.contractId); - }, - }, - vestingEvent: { - get: async (params) => { - const r = await getVestingEventAsOcf(client, params); - return toContractResult(r.vestingEvent, r.contractId); - }, - }, - vestingAcceleration: { - get: async (params) => { - const r = await getVestingAccelerationAsOcf(client, params); - return toContractResult(r.vestingAcceleration, r.contractId); - }, - }, + vestingStart: genericEntity('vestingStart'), + vestingEvent: genericEntity('vestingEvent'), + vestingAcceleration: genericEntity('vestingAcceleration'), // ===== Stakeholder Events ===== - stakeholderRelationshipChangeEvent: { - get: async (params) => { - const r = await getStakeholderRelationshipChangeEventAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - stakeholderStatusChangeEvent: { - get: async (params) => { - const r = await getStakeholderStatusChangeEventAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, + stakeholderRelationshipChangeEvent: genericEntity('stakeholderRelationshipChangeEvent'), + stakeholderStatusChangeEvent: genericEntity('stakeholderStatusChangeEvent'), // ===== Authorization ===== issuerAuthorization: { diff --git a/src/functions/OpenCapTable/capTable/CapTableBatch.ts b/src/functions/OpenCapTable/capTable/CapTableBatch.ts index 9d9109c9..ad68c416 100644 --- a/src/functions/OpenCapTable/capTable/CapTableBatch.ts +++ b/src/functions/OpenCapTable/capTable/CapTableBatch.ts @@ -118,11 +118,6 @@ export class CapTableBatch { } const tag = ENTITY_TAG_MAP[type].create; - if (!tag) { - throw new OcpValidationError('type', `Create operation not supported for entity type: ${type}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } const damlData = convertToDaml(...args); this.creates.push({ tag, value: damlData } as unknown as OcfCreateData); this.createMetas.push(extractBatchItemMeta(type, data)); @@ -145,11 +140,6 @@ export class CapTableBatch { } const tag = ENTITY_TAG_MAP[type].edit; - if (!tag) { - throw new OcpValidationError('type', `Edit operation not supported for entity type: ${type}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } const damlData = convertToDaml(...args); this.edits.push({ tag, value: damlData } as unknown as OcfEditData); this.editMetas.push(extractBatchItemMeta(type, data)); @@ -172,11 +162,6 @@ export class CapTableBatch { } const tag = ENTITY_TAG_MAP[type].delete; - if (!tag) { - throw new OcpValidationError('type', `Delete operation not supported for entity type: ${type}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } this.deletes.push({ tag, value: id } as unknown as OcfDeleteData); this.deleteMetas.push({ entityType: type, id }); return this; diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index 52f1633a..eb764097 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -6,6 +6,7 @@ import { ENTITY_REGISTRY, OCF_OBJECT_TYPE_TO_ENTITY_TYPE, mapOcfObjectTypeToEntityType, + type OcfEntityType, type OcfReadableObjectType, } from '../../src/functions/OpenCapTable/capTable'; import { @@ -379,6 +380,9 @@ describe('OcpClient OpenCapTable entity facade', () => { const objectTypeReaderEntries = Object.entries(OCF_OBJECT_TYPE_TO_ENTITY_TYPE) as Array< [OcfReadableObjectType, ObjectReaderEntityType] >; + const canonicalEntityEntries = Object.entries(ENTITY_REGISTRY) as Array< + [OcfEntityType, (typeof ENTITY_REGISTRY)[OcfEntityType]] + >; it('exports one canonical OCF object_type mapping for each readable registry entry', () => { const expected = Object.fromEntries( @@ -417,6 +421,57 @@ describe('OcpClient OpenCapTable entity facade', () => { }); }); + it.each(canonicalEntityEntries)( + '%s namespace validates template identity before decoding and forwards readAs', + async (entityType, entry) => { + const wrongTemplateId = + entityType === 'stakeholder' ? ENTITY_REGISTRY.stockClass.templateId : ENTITY_REGISTRY.stakeholder.templateId; + const getEventsByContractId = jest.fn().mockResolvedValue({ + created: { + createdEvent: { + templateId: wrongTemplateId, + createArgument: { [entry.dataField]: {} }, + }, + }, + }); + const ocp = new OcpClient({ ledger: { getEventsByContractId } as never }); + const readAs = [`reader::${entityType}`]; + + await expect( + ocp.OpenCapTable[entityType].get({ contractId: `wrong-template-${entityType}`, readAs }) + ).rejects.toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'module_entity_mismatch', + }); + expect(getEventsByContractId).toHaveBeenCalledWith({ + contractId: `wrong-template-${entityType}`, + readAs, + }); + } + ); + + it.each(canonicalEntityEntries)( + '%s namespace rejects malformed generated DAML payloads', + async (entityType, entry) => { + const getEventsByContractId = jest.fn().mockResolvedValue({ + created: { + createdEvent: { + templateId: entry.templateId, + createArgument: { [entry.dataField]: {} }, + }, + }, + }); + const ocp = new OcpClient({ ledger: { getEventsByContractId } as never }); + + await expect( + ocp.OpenCapTable[entityType].get({ contractId: `malformed-payload-${entityType}` }) + ).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + ); + it('rejects unsupported runtime object types', async () => { const ledger = createLedgerJsonApiClient({ network: 'devnet' }); const ocp = new OcpClient({ ledger }); diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index c51dbf76..e2a3363d 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -20,6 +20,8 @@ import { type Assert = T; type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +// This file is linted before `dist` exists in a clean checkout, so its declaration-only imports appear as error types. +// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents type IntendedCanonicalOcfObject = OcfEntityDataMap[OcfEntityType] | OcfFinancing; type LegacyPlanSecurityObjectType = | 'TX_PLAN_SECURITY_ACCEPTANCE' From 9b0bdd4ae92d6179cd3f5eaf63338f148bffd43b Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 21:44:57 -0400 Subject: [PATCH 05/47] Keep stock issuance readback schema valid --- .../stockIssuance/getStockIssuanceAsOcf.ts | 14 ++++++++------ .../stockIssuanceReadConversions.test.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index de951fcb..f9a7c28d 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -43,6 +43,13 @@ export function damlStockIssuanceDataToNative( receivedValue: id, }); } + const vestings = Array.isArray((anyD as { vestings?: unknown }).vestings) + ? (anyD as { vestings: Array<{ date: string; amount: string }> }).vestings.map((vesting) => ({ + date: damlTimeToDateString(vesting.date), + amount: normalizeNumericString(vesting.amount), + })) + : []; + return { object_type: 'TX_STOCK_ISSUANCE', id, @@ -72,12 +79,7 @@ export function damlStockIssuanceDataToNative( share_price: damlMonetaryToNative(d.share_price), quantity: normalizeNumericString(d.quantity), ...(d.vesting_terms_id && { vesting_terms_id: d.vesting_terms_id }), - vestings: Array.isArray((anyD as { vestings?: unknown }).vestings) - ? (anyD as { vestings: Array<{ date: string; amount: string }> }).vestings.map((v) => ({ - date: damlTimeToDateString(v.date), - amount: normalizeNumericString(v.amount), - })) - : [], + ...(vestings.length > 0 ? { vestings } : {}), ...(d.cost_basis && { cost_basis: damlMonetaryToNative(d.cost_basis) }), stock_legend_ids: Array.isArray((d as unknown as { stock_legend_ids?: unknown }).stock_legend_ids) ? (d as unknown as { stock_legend_ids: string[] }).stock_legend_ids diff --git a/test/createOcf/stockIssuanceReadConversions.test.ts b/test/createOcf/stockIssuanceReadConversions.test.ts index 9948f4f2..bb474faa 100644 --- a/test/createOcf/stockIssuanceReadConversions.test.ts +++ b/test/createOcf/stockIssuanceReadConversions.test.ts @@ -1,6 +1,7 @@ /** Unit tests for stock issuance DAML→OCF read conversions. */ import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; +import { parseOcfEntityInput } from '../../src/utils/ocfZodSchemas'; function makeMinimalDamlStockIssuance(overrides: Record = {}): Record { return { @@ -115,6 +116,24 @@ describe('damlStockIssuanceDataToNative', () => { }); describe('array field handling', () => { + test('omits empty vestings so the output remains OCF-schema valid', () => { + const daml = makeMinimalDamlStockIssuance({ vestings: [] }); + const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + + expect(result).not.toHaveProperty('vestings'); + expect(() => parseOcfEntityInput('stockIssuance', result)).not.toThrow(); + }); + + test('includes non-empty vestings', () => { + const daml = makeMinimalDamlStockIssuance({ + vestings: [{ date: '2025-06-01T00:00:00Z', amount: '10.00' }], + }); + const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + + expect(result.vestings).toEqual([{ date: '2025-06-01', amount: '10' }]); + expect(() => parseOcfEntityInput('stockIssuance', result)).not.toThrow(); + }); + test('handles security_law_exemptions array', () => { const daml = makeMinimalDamlStockIssuance({ security_law_exemptions: [{ description: 'SEC Rule 701', jurisdiction: 'US' }], From 1c01ea1a88833671cef77c193da842f21fb39f6b Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 21:51:22 -0400 Subject: [PATCH 06/47] Keep declaration smoke tests lintable --- test/declarations/publicApi.types.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index e2a3363d..8308ee2e 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -1,3 +1,4 @@ +/* eslint @typescript-eslint/no-redundant-type-constituents: off */ /** Compile-time smoke tests for declarations exported by the built SDK. */ import { @@ -20,8 +21,6 @@ import { type Assert = T; type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; -// This file is linted before `dist` exists in a clean checkout, so its declaration-only imports appear as error types. -// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents type IntendedCanonicalOcfObject = OcfEntityDataMap[OcfEntityType] | OcfFinancing; type LegacyPlanSecurityObjectType = | 'TX_PLAN_SECURITY_ACCEPTANCE' From 51f7778c57452bfef08f4f76e47823dbe9fdbeaf Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 22:17:46 -0400 Subject: [PATCH 07/47] Validate OCF date boundaries --- .../getConvertibleConversionAsOcf.ts | 4 +- .../createConvertibleIssuance.ts | 9 +- .../getConvertibleIssuanceAsOcf.ts | 38 ++++-- .../getConvertibleTransferAsOcf.ts | 4 +- .../getEquityCompensationCancellationAsOcf.ts | 4 +- .../getEquityCompensationExerciseAsOcf.ts | 4 +- .../getEquityCompensationIssuanceAsOcf.ts | 30 ++++- .../getEquityCompensationTransferAsOcf.ts | 4 +- ...etIssuerAuthorizedSharesAdjustmentAsOcf.ts | 20 +++- ...StakeholderRelationshipChangeEventAsOcf.ts | 4 +- .../getStakeholderStatusChangeEventAsOcf.ts | 4 +- .../getStockCancellationAsOcf.ts | 4 +- ...mlToStockClassConversionRatioAdjustment.ts | 4 +- ...tockClassConversionRatioAdjustmentAsOcf.ts | 4 +- .../stockClassSplit/damlToStockClassSplit.ts | 4 +- .../getStockClassSplitAsOcf.ts | 4 +- .../damlToStockConsolidation.ts | 3 +- .../getStockConsolidationAsOcf.ts | 3 +- .../getStockConversionAsOcf.ts | 4 +- .../getStockPlanPoolAdjustmentAsOcf.ts | 20 +++- .../stockReissuance/damlToStockReissuance.ts | 3 +- .../getStockReissuanceAsOcf.ts | 3 +- .../getStockRepurchaseAsOcf.ts | 4 +- .../stockTransfer/getStockTransferAsOcf.ts | 4 +- .../getWarrantCancellationAsOcf.ts | 4 +- .../OpenCapTable/warrantExercise/damlToOcf.ts | 2 +- .../getWarrantIssuanceAsOcf.ts | 35 ++++-- .../getWarrantTransferAsOcf.ts | 4 +- src/utils/ocfComparison.ts | 38 +----- src/utils/typeConversions.ts | 74 ++++++++++-- .../converters/dateBoundaryValidation.test.ts | 89 ++++++++++++++ test/integration/utils/setupTestData.ts | 3 +- .../property/typeConversions.property.test.ts | 94 +++++++++++++-- test/utils/ocfComparison.test.ts | 20 ++++ test/validation/typeCoercion.test.ts | 110 ++++++++++++++++-- 35 files changed, 523 insertions(+), 139 deletions(-) create mode 100644 test/converters/dateBoundaryValidation.test.ts diff --git a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts index 7cb31a51..b4665462 100644 --- a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpContractError, OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfConvertibleConversion } from '../../../types/native'; -import { isRecord } from '../../../utils/typeConversions'; +import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; import type { DamlConvertibleConversionData } from './damlToOcf'; @@ -110,7 +110,7 @@ export async function getConvertibleConversionAsOcf( const event: OcfConvertibleConversionEvent = { object_type: 'TX_CONVERTIBLE_CONVERSION', id: d.id, - date: d.date.split('T')[0], + date: damlTimeToDateString(d.date, 'convertibleConversion.date'), reason_text: d.reason_text, security_id: d.security_id, trigger_id: d.trigger_id, diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 172a9a3b..e3e3e190 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -188,9 +188,14 @@ function mechanismInputToDamlEnum( ? arr.map((ir) => ({ rate: ir?.rate != null ? normalizeNumericString(String(ir.rate)) : null, accrual_start_date: ir?.accrual_start_date - ? dateStringToDAMLTime(ir.accrual_start_date as string) + ? dateStringToDAMLTime( + ir.accrual_start_date, + 'convertibleIssuance.interest_rates[].accrual_start_date' + ) + : null, + accrual_end_date: ir?.accrual_end_date + ? dateStringToDAMLTime(ir.accrual_end_date, 'convertibleIssuance.interest_rates[].accrual_end_date') : null, - accrual_end_date: ir?.accrual_end_date ? dateStringToDAMLTime(ir.accrual_end_date as string) : null, })) : []; const accrualToDaml = (v: unknown): string => { diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 826d3438..134b6886 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -8,6 +8,7 @@ import type { } from '../../../types/native'; import { damlMonetaryToNativeWithValidation, + damlTimeToDateString, mapDamlTriggerTypeToOcf, normalizeNumericString, safeString, @@ -367,9 +368,17 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers } return { rate: normalizeNumericString(typeof irObj.rate === 'number' ? irObj.rate.toString() : irObj.rate), - accrual_start_date: irObj.accrual_start_date.split('T')[0], + accrual_start_date: damlTimeToDateString( + irObj.accrual_start_date, + 'convertibleIssuance.interest_rates[].accrual_start_date' + ), ...(irObj.accrual_end_date - ? { accrual_end_date: (irObj.accrual_end_date as string).split('T')[0] } + ? { + accrual_end_date: damlTimeToDateString( + irObj.accrual_end_date, + 'convertibleIssuance.interest_rates[].accrual_end_date' + ), + } : {}), }; }) @@ -513,13 +522,19 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers const trigger_description: string | undefined = typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; const trigger_date: string | undefined = - typeof r.trigger_date === 'string' && r.trigger_date.length ? r.trigger_date.split('T')[0] : undefined; + typeof r.trigger_date === 'string' && r.trigger_date.length + ? damlTimeToDateString(r.trigger_date, 'convertibleIssuance.conversion_triggers[].trigger_date') + : undefined; const trigger_condition: string | undefined = typeof r.trigger_condition === 'string' && r.trigger_condition.length ? r.trigger_condition : undefined; const start_date: string | undefined = - typeof r.start_date === 'string' && r.start_date.length ? r.start_date.split('T')[0] : undefined; + typeof r.start_date === 'string' && r.start_date.length + ? damlTimeToDateString(r.start_date, 'convertibleIssuance.conversion_triggers[].start_date') + : undefined; const end_date: string | undefined = - typeof r.end_date === 'string' && r.end_date.length ? r.end_date.split('T')[0] : undefined; + typeof r.end_date === 'string' && r.end_date.length + ? damlTimeToDateString(r.end_date, 'convertibleIssuance.conversion_triggers[].end_date') + : undefined; // Parse conversion_right if present and convertible variant is used let conversion_right: ConvertibleConversionRight | undefined; @@ -636,15 +651,22 @@ export function damlConvertibleIssuanceDataToNative(d: Record): const issuance = { object_type: 'TX_CONVERTIBLE_ISSUANCE', id: d.id, - date: d.date.split('T')[0], + date: damlTimeToDateString(d.date, 'convertibleIssuance.date'), security_id: d.security_id, custom_id: d.custom_id as string, stakeholder_id: d.stakeholder_id as string, ...(typeof d.board_approval_date === 'string' && d.board_approval_date.length - ? { board_approval_date: d.board_approval_date.split('T')[0] } + ? { + board_approval_date: damlTimeToDateString(d.board_approval_date, 'convertibleIssuance.board_approval_date'), + } : {}), ...(typeof d.stockholder_approval_date === 'string' && d.stockholder_approval_date.length - ? { stockholder_approval_date: d.stockholder_approval_date.split('T')[0] } + ? { + stockholder_approval_date: damlTimeToDateString( + d.stockholder_approval_date, + 'convertibleIssuance.stockholder_approval_date' + ), + } : {}), investment_amount: { amount: normalizeNumericString(investmentAmountStr), diff --git a/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts b/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts index 2bbd7b3d..53cee57e 100644 --- a/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts @@ -2,7 +2,7 @@ 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 { OcfConvertibleTransfer } from '../../../types/native'; -import { normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; /** @@ -42,7 +42,7 @@ export async function getConvertibleTransferAsOcf( const event: OcfConvertibleTransferEvent = { object_type: 'TX_CONVERTIBLE_TRANSFER', id: data.id, - date: data.date.split('T')[0], + date: damlTimeToDateString(data.date, 'convertibleTransfer.date'), security_id: data.security_id, amount: { amount: normalizeNumericString(amountStr), diff --git a/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts b/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts index 9dabdaf0..d07cedb8 100644 --- a/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts @@ -1,7 +1,7 @@ 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 { normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; /** @@ -54,7 +54,7 @@ export async function getEquityCompensationCancellationAsOcf( const event: OcfEquityCompensationCancellationEvent = { object_type: 'TX_EQUITY_COMPENSATION_CANCELLATION', id: data.id, - date: data.date.split('T')[0], + date: damlTimeToDateString(data.date, 'equityCompensationCancellation.date'), security_id: data.security_id, quantity: normalizeNumericString(quantityStr), ...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}), diff --git a/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts b/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts index e48a4e91..c7b09d8f 100644 --- a/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpContractError, OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfEquityCompensationExercise } from '../../../types/native'; -import { normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; /** @@ -50,7 +50,7 @@ export function damlEquityCompensationExerciseDataToNative(d: Record): OcfWarr return { object_type: 'TX_WARRANT_EXERCISE', id: d.id as string, - date: damlTimeToDateString(d.date as string), + date: damlTimeToDateString(d.date, 'warrantExercise.date'), security_id: d.security_id as string, trigger_id: d.trigger_id, ...(quantity !== undefined ? { quantity: normalizeNumericString(quantity) } : {}), diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 156bb937..2c938cde 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -15,6 +15,7 @@ import type { import { damlMonetaryToNative, damlMonetaryToNativeWithValidation, + damlTimeToDateString, mapDamlTriggerTypeToOcf, normalizeNumericString, } from '../../../utils/typeConversions'; @@ -347,13 +348,19 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf const trigger_description: string | undefined = typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; const trigger_date: string | undefined = - typeof r.trigger_date === 'string' && r.trigger_date.length ? r.trigger_date.split('T')[0] : undefined; + typeof r.trigger_date === 'string' && r.trigger_date.length + ? damlTimeToDateString(r.trigger_date, 'warrantIssuance.exercise_triggers[].trigger_date') + : undefined; const trigger_condition: string | undefined = typeof r.trigger_condition === 'string' && r.trigger_condition.length ? r.trigger_condition : undefined; const start_date: string | undefined = - typeof r.start_date === 'string' && r.start_date.length ? r.start_date.split('T')[0] : undefined; + typeof r.start_date === 'string' && r.start_date.length + ? damlTimeToDateString(r.start_date, 'warrantIssuance.exercise_triggers[].start_date') + : undefined; const end_date: string | undefined = - typeof r.end_date === 'string' && r.end_date.length ? r.end_date.split('T')[0] : undefined; + typeof r.end_date === 'string' && r.end_date.length + ? damlTimeToDateString(r.end_date, 'warrantIssuance.exercise_triggers[].end_date') + : undefined; const conversion_right: WarrantTriggerConversionRight = mapAnyConversionRightFromDaml(r.conversion_right); @@ -414,7 +421,7 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf }); } return { - date: v.date.split('T')[0], + date: damlTimeToDateString(v.date, 'warrantIssuance.vestings[].date'), amount: normalizeNumericString(amountStr), }; }) @@ -455,7 +462,7 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf return { object_type: 'TX_WARRANT_ISSUANCE', id: d.id, - date: d.date.split('T')[0], + date: damlTimeToDateString(d.date, 'warrantIssuance.date'), security_id: d.security_id, custom_id: d.custom_id, stakeholder_id: d.stakeholder_id, @@ -488,14 +495,26 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf return {}; })(), ...(d.warrant_expiration_date - ? { warrant_expiration_date: (d.warrant_expiration_date as string).split('T')[0] } + ? { + warrant_expiration_date: damlTimeToDateString( + d.warrant_expiration_date, + 'warrantIssuance.warrant_expiration_date' + ), + } : {}), ...(d.vesting_terms_id && typeof d.vesting_terms_id === 'string' ? { vesting_terms_id: d.vesting_terms_id } : {}), ...(d.board_approval_date && typeof d.board_approval_date === 'string' - ? { board_approval_date: d.board_approval_date.split('T')[0] } + ? { + board_approval_date: damlTimeToDateString(d.board_approval_date, 'warrantIssuance.board_approval_date'), + } : {}), ...(d.stockholder_approval_date && typeof d.stockholder_approval_date === 'string' - ? { stockholder_approval_date: d.stockholder_approval_date.split('T')[0] } + ? { + stockholder_approval_date: damlTimeToDateString( + d.stockholder_approval_date, + 'warrantIssuance.stockholder_approval_date' + ), + } : {}), ...(typeof d.consideration_text === 'string' && d.consideration_text.length > 0 ? { consideration_text: d.consideration_text } diff --git a/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.ts b/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.ts index 088c6073..b5ae6e7c 100644 --- a/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.ts +++ b/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.ts @@ -2,7 +2,7 @@ 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 { OcfWarrantTransfer } from '../../../types/native'; -import { normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; /** @@ -42,7 +42,7 @@ export async function getWarrantTransferAsOcf( const event: OcfWarrantTransferEvent = { object_type: 'TX_WARRANT_TRANSFER', id: data.id, - date: data.date.split('T')[0], + date: damlTimeToDateString(data.date, 'warrantTransfer.date'), security_id: data.security_id, quantity: normalizeNumericString(quantityStr), resulting_security_ids: data.resulting_security_ids, diff --git a/src/utils/ocfComparison.ts b/src/utils/ocfComparison.ts index 81965428..01cbcb89 100644 --- a/src/utils/ocfComparison.ts +++ b/src/utils/ocfComparison.ts @@ -7,6 +7,8 @@ * @module ocfComparison */ +import { tryIsoDateToDateString } from './typeConversions'; + /** * Default internal fields added by the database/SDK that should be ignored during comparison. * These are not part of the OCF specification. @@ -68,40 +70,6 @@ export interface OcfComparisonResult { differences: string[]; } -/** - * Regex for ISO 8601 / RFC 3339 date strings: YYYY-MM-DD with optional time. - * - * Matches: - * - "2024-01-15" (date only) - * - "2024-01-15T00:00:00.000Z" (date + time + Z) - * - "2024-01-15T12:30:00Z" (date + time, no millis) - * - "2024-01-15T12:30:00+05:00" (date + time + offset) - * - * Does NOT match: - * - "2024-01-15TICKET" (no T separator or invalid suffix) - * - "2024-01-15T12:30TICKET" (no seconds, trailing junk) - * - * Time portion requires HH:MM:SS with optional fractional seconds - * (up to 9 digits) and optional timezone (Z or +/-HH:MM). - * - * The date portion (YYYY-MM-DD) is captured in group 1. - */ -const ISO_DATE_REGEX = /^(\d{4}-\d{2}-\d{2})(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})?)?$/; - -/** - * If the string looks like an ISO date (or date-time), return the date-only - * portion (YYYY-MM-DD). Otherwise return null. - * - * This allows `"2024-01-15"` and `"2024-01-15T00:00:00.000Z"` to compare as - * equal during replication diff, which is the correct semantic for OCF date - * fields. - */ -function tryNormalizeDateString(value: string): string | null { - const match = ISO_DATE_REGEX.exec(value); - if (!match) return null; - return match[1]; // YYYY-MM-DD (strips time portion if present) -} - /** * Normalize a value for OCF comparison. * @@ -139,7 +107,7 @@ function normalizeOcfValue(value: unknown): unknown { // so both fall through to the return. Without this normalization, // "2024-01-15" !== "2024-01-15T00:00:00.000Z" causes phantom edits // during replication. - const normalizedDate = tryNormalizeDateString(trimmed); + const normalizedDate = tryIsoDateToDateString(trimmed); if (normalizedDate !== null) { return normalizedDate; } diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 5e8d0a3e..997aba42 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -24,17 +24,65 @@ export function isRecord(value: unknown): value is Record { // ===== Date and Time Conversion Helpers ===== +const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const RFC3339_DATE_TIME_PATTERN = + /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/; + +const DATE_EXPECTED_TYPE = 'YYYY-MM-DD or RFC 3339 date-time string with Z or numeric offset'; + +function isValidCalendarDate(value: string): boolean { + const year = Number(value.slice(0, 4)); + const month = Number(value.slice(5, 7)); + const day = Number(value.slice(8, 10)); + + if (month < 1 || month > 12 || day < 1) return false; + + const isLeapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = month === 2 ? (isLeapYear ? 29 : 28) : [4, 6, 9, 11].includes(month) ? 30 : 31; + return day <= daysInMonth; +} + /** - * Convert a date string (YYYY-MM-DD) to DAML Time format (ISO string with 0 timestamp) DAML Time expects a string in - * the format YYYY-MM-DDTHH:MM:SS.000Z Since we only care about the date, we use 00:00:00.000Z for the time portion If - * the date already has a time portion, return it as-is + * Return the lexical date prefix for a valid OCF date or RFC 3339 date-time. + * + * This deliberately does not convert through `Date`: an offset timestamp such as + * `2024-01-15T23:30:00-05:00` represents the OCF date `2024-01-15`, even though + * its UTC representation falls on the following day. */ -export function dateStringToDAMLTime(dateString: string): string { - // If already has time portion, return as-is - if (dateString.includes('T')) { - return dateString; +export function tryIsoDateToDateString(value: unknown): string | null { + if (typeof value !== 'string') return null; + if (!DATE_ONLY_PATTERN.test(value) && !RFC3339_DATE_TIME_PATTERN.test(value)) return null; + if (!isValidCalendarDate(value)) return null; + return value.slice(0, 10); +} + +function requireIsoDate(value: unknown, fieldPath: string): { source: string; date: string } { + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, 'Expected a date string', { + expectedType: DATE_EXPECTED_TYPE, + receivedValue: value, + code: OcpErrorCodes.INVALID_TYPE, + }); } - return `${dateString}T00:00:00.000Z`; + + const date = tryIsoDateToDateString(value); + if (date !== null) return { source: value, date }; + + throw new OcpValidationError(fieldPath, `Expected a valid ${DATE_EXPECTED_TYPE}`, { + expectedType: DATE_EXPECTED_TYPE, + receivedValue: value, + code: OcpErrorCodes.INVALID_FORMAT, + }); +} + +/** + * Convert a valid OCF date to DAML Time format. RFC 3339 date-times are validated and preserved exactly; date-only + * values receive a UTC midnight suffix. + */ +export function dateStringToDAMLTime(value: string, fieldPath?: string): string; +export function dateStringToDAMLTime(value: unknown, fieldPath = 'date'): string { + const { source, date } = requireIsoDate(value, fieldPath); + return source === date ? `${date}T00:00:00.000Z` : source; } /** @@ -45,10 +93,12 @@ export function relTimeToDAML(microseconds: string): { microseconds: string } { return { microseconds }; } -/** Convert a DAML Time string back to a date string (YYYY-MM-DD) Extract only the date portion and return as string */ -export function damlTimeToDateString(timeString: string): string { - // Extract just the date portion (YYYY-MM-DD) - return timeString.split('T')[0]; +/** + * Convert a DAML Time value to its OCF date prefix after validating the runtime value. Date-only values are accepted so + * already-normalized data can safely cross the same boundary. + */ +export function damlTimeToDateString(value: unknown, fieldPath = 'date'): string { + return requireIsoDate(value, fieldPath).date; } /** diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts new file mode 100644 index 00000000..015bee56 --- /dev/null +++ b/test/converters/dateBoundaryValidation.test.ts @@ -0,0 +1,89 @@ +import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { damlIssuerAuthorizedSharesAdjustmentDataToNative } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf'; +import { damlStockClassSplitToNative } from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; + +function expectInvalidDate( + action: () => unknown, + fieldPath: string, + receivedValue: unknown, + code: OcpErrorCode = OcpErrorCodes.INVALID_FORMAT +): void { + try { + action(); + throw new Error('Expected converter date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath, + receivedValue, + }); + } +} + +describe('DAML read converter date boundaries', () => { + test('preserves the lexical date when an offset crosses the UTC day', () => { + const result = damlStockClassSplitToNative({ + id: 'split-1', + date: '2024-01-15T23:30:00-05:00', + stock_class_id: 'class-1', + split_ratio: { numerator: '2', denominator: '1' }, + comments: [], + }); + + expect(result.date).toBe('2024-01-15'); + }); + + test.each(['2024-02-30T00:00:00Z', '2024-01-15T00:00:00', '2024-01-15T25:00:00Z'])( + 'rejects malformed required converter date %s', + (date) => { + expectInvalidDate( + () => + damlStockClassSplitToNative({ + id: 'split-1', + date, + stock_class_id: 'class-1', + split_ratio: { numerator: '2', denominator: '1' }, + comments: [], + }), + 'stockClassSplit.date', + date + ); + } + ); + + test('identifies the exact invalid optional approval field', () => { + const boardApprovalDate = '2023-02-29T00:00:00Z'; + + expectInvalidDate( + () => + damlIssuerAuthorizedSharesAdjustmentDataToNative({ + id: 'adjustment-1', + issuer_id: 'issuer-1', + date: '2024-01-15T00:00:00Z', + new_shares_authorized: '1000', + board_approval_date: boardApprovalDate, + }), + 'issuerAuthorizedSharesAdjustment.board_approval_date', + boardApprovalDate + ); + }); + + test('rejects a non-string optional date at the runtime ledger boundary', () => { + const stockholderApprovalDate = { seconds: 1 }; + + expectInvalidDate( + () => + damlIssuerAuthorizedSharesAdjustmentDataToNative({ + id: 'adjustment-1', + issuer_id: 'issuer-1', + date: '2024-01-15T00:00:00Z', + new_shares_authorized: '1000', + stockholder_approval_date: stockholderApprovalDate, + }), + 'issuerAuthorizedSharesAdjustment.stockholder_approval_date', + stockholderApprovalDate, + OcpErrorCodes.INVALID_TYPE + ); + }); +}); diff --git a/test/integration/utils/setupTestData.ts b/test/integration/utils/setupTestData.ts index 9ffe1873..875b72c9 100644 --- a/test/integration/utils/setupTestData.ts +++ b/test/integration/utils/setupTestData.ts @@ -43,6 +43,7 @@ import type { OcfWarrantRetraction, OcfWarrantTransfer, } from '../../../src/types/native'; +import { damlTimeToDateString } from '../../../src/utils/typeConversions'; import { createValidatorApiClient } from '../../utils/cantonNodeSdkCompat'; import { authorizeIssuerWithFactory } from '../setup/contractDeployment'; import { requireCreatedEventBlob } from './transactionHelpers'; @@ -110,7 +111,7 @@ export async function getCapTableDetails( export function generateDateString(daysFromNow = 0): string { const date = new Date(); date.setDate(date.getDate() + daysFromNow); - return date.toISOString().split('T')[0]; + return damlTimeToDateString(date.toISOString(), 'integrationTest.generatedDate'); } /** Create test issuer data with optional overrides. */ diff --git a/test/property/typeConversions.property.test.ts b/test/property/typeConversions.property.test.ts index 97442cee..7baec64a 100644 --- a/test/property/typeConversions.property.test.ts +++ b/test/property/typeConversions.property.test.ts @@ -15,6 +15,7 @@ import { normalizeNumericString, optionalNumberToString, optionalString, + tryIsoDateToDateString, } from '../../src/utils/typeConversions'; describe('Property-based tests: Type Conversions', () => { @@ -177,18 +178,21 @@ describe('Property-based tests: Type Conversions', () => { */ test('date round-trip preserves date portion', () => { fc.assert( - fc.property(fc.date({ min: new Date('1900-01-01'), max: new Date('2100-12-31') }), (date) => { - // Format as YYYY-MM-DD - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const dateStr = `${year}-${month}-${day}`; + fc.property( + fc.date({ min: new Date('1900-01-01'), max: new Date('2100-12-31'), noInvalidDate: true }), + (date) => { + // Format as YYYY-MM-DD + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const dateStr = `${year}-${month}-${day}`; - const damlTime = dateStringToDAMLTime(dateStr); - const backToDate = damlTimeToDateString(damlTime); + const damlTime = dateStringToDAMLTime(dateStr); + const backToDate = damlTimeToDateString(damlTime); - expect(backToDate).toBe(dateStr); - }), + expect(backToDate).toBe(dateStr); + } + ), { numRuns: 500 } ); }); @@ -250,6 +254,76 @@ describe('Property-based tests: Type Conversions', () => { { numRuns: 200 } ); }); + + test('valid RFC 3339 date-times preserve both their source and lexical date prefix', () => { + const dateArbitrary = fc + .date({ + min: new Date('1900-01-01T00:00:00.000Z'), + max: new Date('2100-12-31T00:00:00.000Z'), + noInvalidDate: true, + }) + .map((date) => date.toISOString().slice(0, 10)); + const timeArbitrary = fc + .tuple(fc.integer({ min: 0, max: 23 }), fc.integer({ min: 0, max: 59 }), fc.integer({ min: 0, max: 59 })) + .map(([hour, minute, second]) => [hour, minute, second].map((part) => String(part).padStart(2, '0')).join(':')); + const fractionArbitrary = fc.oneof( + fc.constant(''), + fc.integer({ min: 0, max: 999_999_999 }).map((fraction) => `.${String(fraction).padStart(9, '0')}`) + ); + const offsetArbitrary = fc.oneof( + fc.constant('Z'), + fc + .tuple(fc.constantFrom('+', '-'), fc.integer({ min: 0, max: 23 }), fc.integer({ min: 0, max: 59 })) + .map(([sign, hour, minute]) => `${sign}${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`) + ); + + fc.assert( + fc.property( + dateArbitrary, + timeArbitrary, + fractionArbitrary, + offsetArbitrary, + (date, time, fraction, offset) => { + const value = `${date}T${time}${fraction}${offset}`; + expect(dateStringToDAMLTime(value)).toBe(value); + expect(damlTimeToDateString(value)).toBe(date); + expect(tryIsoDateToDateString(value)).toBe(date); + } + ), + { numRuns: 500 } + ); + }); + + test('calendar dates invalid for their month are always rejected', () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 9999 }), fc.constantFrom(4, 6, 9, 11), (year, month) => { + const value = `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-31`; + expect(tryIsoDateToDateString(value)).toBeNull(); + expect(() => damlTimeToDateString(value)).toThrow(); + }), + { numRuns: 200 } + ); + }); + + test('non-string runtime values are never accepted as dates', () => { + fc.assert( + fc.property( + fc.oneof( + fc.integer(), + fc.boolean(), + fc.constant(null), + fc.constant(undefined), + fc.array(fc.integer(), { maxLength: 4 }), + fc.dictionary(fc.string({ maxLength: 8 }), fc.integer(), { maxKeys: 4 }) + ), + (value) => { + expect(tryIsoDateToDateString(value)).toBeNull(); + expect(() => Reflect.apply(dateStringToDAMLTime, undefined, [value])).toThrow(); + } + ), + { numRuns: 300 } + ); + }); }); describe('monetary conversion properties', () => { diff --git a/test/utils/ocfComparison.test.ts b/test/utils/ocfComparison.test.ts index 25b1590c..c49e9b06 100644 --- a/test/utils/ocfComparison.test.ts +++ b/test/utils/ocfComparison.test.ts @@ -164,6 +164,26 @@ describe('date normalization', () => { test('handles date with timezone offset', () => { expect(ocfDeepEqual({ date: '2024-01-15' }, { date: '2024-01-15T12:00:00+05:00' })).toBe(true); }); + + test('keeps lexical date-prefix semantics when an offset crosses a UTC day', () => { + expect(ocfDeepEqual({ date: '2024-01-15' }, { date: '2024-01-15T23:30:00-05:00' })).toBe(true); + expect(ocfDeepEqual({ date: '2024-01-15' }, { date: '2024-01-15T00:30:00+14:00' })).toBe(true); + }); + + test.each([ + '2024-02-30T00:00:00Z', + '2024-01-15T12:30:00', + '2024-01-15T24:00:00Z', + '2024-01-15T12:30:00+24:00', + '2024-01-15T12:30:00Zjunk', + ])('does not normalize invalid date-time %s', (invalidDateTime) => { + expect(ocfDeepEqual({ date: '2024-01-15' }, { date: invalidDateTime })).toBe(false); + }); + + test('does not normalize impossible date-only values', () => { + expect(ocfDeepEqual({ date: '2024-02-30' }, { date: '2024-02-30T00:00:00Z' })).toBe(false); + expect(ocfDeepEqual({ date: '2023-02-29' }, { date: '2023-02-29T00:00:00Z' })).toBe(false); + }); }); describe('ocfCompare', () => { diff --git a/test/validation/typeCoercion.test.ts b/test/validation/typeCoercion.test.ts index d80b1700..0f008178 100644 --- a/test/validation/typeCoercion.test.ts +++ b/test/validation/typeCoercion.test.ts @@ -5,7 +5,7 @@ * invalid inputs. */ -import { OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { damlTimeToDateString, dateStringToDAMLTime, @@ -13,6 +13,7 @@ import { optionalNumberToString, optionalString, safeString, + tryIsoDateToDateString, } from '../../src/utils/typeConversions'; describe('Type Coercion Utilities', () => { @@ -83,21 +84,28 @@ describe('Type Coercion Utilities', () => { }); describe('dateStringToDAMLTime', () => { - test('converts date string to DAML time format', () => { - expect(dateStringToDAMLTime('2024-01-15')).toBe('2024-01-15T00:00:00.000Z'); - expect(dateStringToDAMLTime('2000-12-31')).toBe('2000-12-31T00:00:00.000Z'); + test.each(['2024-01-15', '2000-02-29', '1900-12-31'])('converts the valid date %s to UTC midnight', (date) => { + expect(dateStringToDAMLTime(date)).toBe(`${date}T00:00:00.000Z`); }); - test('returns datetime as-is if already has time portion', () => { - const isoDate = '2024-01-15T14:30:00.000Z'; - expect(dateStringToDAMLTime(isoDate)).toBe(isoDate); + test.each([ + '2024-01-15T14:30:00Z', + '2024-01-15T14:30:00.000000Z', + '2024-01-15T23:59:59+14:00', + '2024-01-15T00:00:00-05:30', + ])('preserves the valid RFC 3339 date-time %s exactly', (dateTime) => { + expect(dateStringToDAMLTime(dateTime)).toBe(dateTime); }); }); describe('damlTimeToDateString', () => { - test('extracts date portion from DAML time', () => { - expect(damlTimeToDateString('2024-01-15T00:00:00.000Z')).toBe('2024-01-15'); - expect(damlTimeToDateString('2024-01-15T14:30:00.000Z')).toBe('2024-01-15'); + test.each([ + '2024-01-15T00:00:00.000Z', + '2024-01-15T14:30:00.000Z', + '2024-01-15T23:30:00-05:00', + '2024-01-15T00:30:00+14:00', + ])('extracts the lexical date prefix from %s', (dateTime) => { + expect(damlTimeToDateString(dateTime)).toBe('2024-01-15'); }); test('returns date-only string as-is', () => { @@ -105,6 +113,88 @@ describe('Type Coercion Utilities', () => { }); }); + describe('strict date validation', () => { + const invalidValues: unknown[] = [ + '2023-02-29', + '1900-02-29', + '2024-02-30', + '2024-04-31', + '2024-00-15', + '2024-13-15', + '2024-01-00', + '2024-01-32', + '2024-1-15', + '2024-01-15T12:30:00', + '2024-01-15T24:00:00Z', + '2024-01-15T23:60:00Z', + '2024-01-15T23:59:60Z', + '2024-01-15T12:30Z', + '2024-01-15T12:30:00+24:00', + '2024-01-15T12:30:00+05:60', + '2024-01-15T12:30:00z', + '2024-01-15TICKET', + '2024-01-15T12:30:00Zjunk', + ' 2024-01-15', + '2024-01-15 ', + '', + null, + undefined, + 20240115, + {}, + [], + ]; + + test.each(invalidValues)('non-throwing parser rejects invalid input %#', (value) => { + expect(tryIsoDateToDateString(value)).toBeNull(); + }); + + test.each(invalidValues)('both conversion boundaries reject invalid input %#', (value) => { + expect(() => Reflect.apply(dateStringToDAMLTime, undefined, [value])).toThrow(OcpValidationError); + expect(() => damlTimeToDateString(value)).toThrow(OcpValidationError); + }); + + test('accepts Gregorian leap days only when the year is valid', () => { + expect(tryIsoDateToDateString('2000-02-29')).toBe('2000-02-29'); + expect(tryIsoDateToDateString('2024-02-29T23:59:59.123456789Z')).toBe('2024-02-29'); + expect(tryIsoDateToDateString('2100-02-29')).toBeNull(); + }); + + test('reports the caller field path and invalid value', () => { + const value = '2024-02-30T00:00:00Z'; + + try { + damlTimeToDateString(value, 'warrantIssuance.warrant_expiration_date'); + throw new Error('Expected date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + fieldPath: 'warrantIssuance.warrant_expiration_date', + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'YYYY-MM-DD or RFC 3339 date-time string with Z or numeric offset', + receivedValue: value, + }); + } + }); + + test('distinguishes wrong runtime types from malformed date strings', () => { + for (const action of [ + () => damlTimeToDateString({ seconds: 1 }, 'transaction.date'), + () => Reflect.apply(dateStringToDAMLTime, undefined, [42, 'transaction.date']), + ]) { + try { + action(); + throw new Error('Expected date type validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + fieldPath: 'transaction.date', + code: OcpErrorCodes.INVALID_TYPE, + }); + } + } + }); + }); + describe('safeString', () => { test('returns empty string for null', () => { expect(safeString(null)).toBe(''); From b54502c68f9006e1b6a07e170f8372a80dd40838 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 22:19:21 -0400 Subject: [PATCH 08/47] Reject year zero in OCF dates --- src/utils/typeConversions.ts | 2 +- test/validation/typeCoercion.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 997aba42..59a3517c 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -35,7 +35,7 @@ function isValidCalendarDate(value: string): boolean { const month = Number(value.slice(5, 7)); const day = Number(value.slice(8, 10)); - if (month < 1 || month > 12 || day < 1) return false; + if (year < 1 || month < 1 || month > 12 || day < 1) return false; const isLeapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); const daysInMonth = month === 2 ? (isLeapYear ? 29 : 28) : [4, 6, 9, 11].includes(month) ? 30 : 31; diff --git a/test/validation/typeCoercion.test.ts b/test/validation/typeCoercion.test.ts index 0f008178..320b3717 100644 --- a/test/validation/typeCoercion.test.ts +++ b/test/validation/typeCoercion.test.ts @@ -123,6 +123,7 @@ describe('Type Coercion Utilities', () => { '2024-13-15', '2024-01-00', '2024-01-32', + '0000-01-01', '2024-1-15', '2024-01-15T12:30:00', '2024-01-15T24:00:00Z', From fec540996bac334007aa35493b80e3166f680563 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 22:28:31 -0400 Subject: [PATCH 09/47] Curate strict public SDK declarations --- package.json | 8 + scripts/check-declarations.ts | 26 +- src/OcpClient.ts | 59 ++-- .../OpenCapTable/capTable/CapTableBatch.ts | 143 +------- .../OpenCapTable/capTable/archiveCapTable.ts | 2 +- .../capTable/archiveFullCapTable.ts | 2 +- .../OpenCapTable/capTable/batchTypes.ts | 330 ++---------------- .../capTable/buildCapTableCommand.ts | 2 +- .../OpenCapTable/capTable/entityTypes.ts | 271 ++++++++++++++ .../capTable/generatedBatchOperations.ts | 141 ++++++++ .../OpenCapTable/capTable/getCapTableState.ts | 4 +- src/functions/OpenCapTable/capTable/index.ts | 7 +- src/functions/OpenCapTable/issuer/api.ts | 12 + .../OpenCapTable/issuer/createIssuer.ts | 34 +- src/functions/OpenCapTable/issuer/index.ts | 3 +- src/functions/OpenCapTable/issuer/types.ts | 18 + .../issuerAuthorization/authorizeIssuer.ts | 18 +- .../OpenCapTable/issuerAuthorization/index.ts | 1 + .../OpenCapTable/issuerAuthorization/types.ts | 30 ++ .../withdrawAuthorization.ts | 14 +- .../stockIssuance/createStockIssuance.ts | 8 +- src/index.ts | 47 ++- src/types/common.ts | 6 + src/types/index.ts | 12 - test/batch/batchTypes.test.ts | 2 +- .../generatedOperationConstruction.test.ts | 14 +- test/client/OcpClient.test.ts | 10 +- .../coreObjectReadValidation.test.ts | 4 +- test/declarations/damlReadDispatch.types.ts | 10 +- .../generatedOperationConstruction.types.ts | 13 +- test/declarations/normalization.types.ts | 4 +- test/declarations/publicApi.types.ts | 64 +++- test/integration/utils/setupTestData.ts | 2 +- test/publicApi/rootExports.test.ts | 58 +++ test/types/capTableBatch.types.ts | 28 +- test/types/damlReadDispatch.types.ts | 4 +- .../generatedOperationConstruction.types.ts | 20 +- test/types/normalization.types.ts | 4 +- 38 files changed, 801 insertions(+), 634 deletions(-) create mode 100644 src/functions/OpenCapTable/capTable/entityTypes.ts create mode 100644 src/functions/OpenCapTable/capTable/generatedBatchOperations.ts create mode 100644 src/functions/OpenCapTable/issuer/api.ts create mode 100644 src/functions/OpenCapTable/issuer/types.ts create mode 100644 src/functions/OpenCapTable/issuerAuthorization/types.ts create mode 100644 test/publicApi/rootExports.test.ts diff --git a/package.json b/package.json index f8fea457..6c266659 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,14 @@ "url": "https://github.com/Fairmint/ocp-canton-sdk.git" }, "license": "GPL-3.0-only", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.js", + "default": "./dist/index.js" + } + }, "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ diff --git a/scripts/check-declarations.ts b/scripts/check-declarations.ts index 0788895f..65596abb 100644 --- a/scripts/check-declarations.ts +++ b/scripts/check-declarations.ts @@ -5,7 +5,9 @@ import ts from 'typescript'; const projectRoot = process.cwd(); const configPath = path.join(projectRoot, 'tsconfig.json'); const declarationEntryPoint = path.join(projectRoot, 'dist', 'index.d.ts'); +const strictConsumerEntryPoint = path.join(projectRoot, 'test', 'declarations', 'publicApi.types.ts'); const declarationRoot = `${path.dirname(declarationEntryPoint)}${path.sep}`; +const generatedDamlPackage = '@fairmint/open-captable-protocol-daml-js'; const diagnosticHost: ts.FormatDiagnosticsHost = { getCanonicalFileName: (fileName) => fileName, getCurrentDirectory: () => projectRoot, @@ -30,16 +32,26 @@ if (parsedConfig.errors.length > 0) { } const program = ts.createProgram({ - rootNames: [declarationEntryPoint], - options: parsedConfig.options, + rootNames: [declarationEntryPoint, strictConsumerEntryPoint], + options: { ...parsedConfig.options, rootDir: projectRoot }, }); -const sdkDiagnostics = ts - .getPreEmitDiagnostics(program) - .filter((diagnostic) => diagnostic.file?.fileName.startsWith(declarationRoot)); +const diagnostics = ts.getPreEmitDiagnostics(program); -if (sdkDiagnostics.length > 0) { +if (diagnostics.length > 0) { throw new Error( - `SDK declaration validation failed:\n${ts.formatDiagnosticsWithColorAndContext(sdkDiagnostics, diagnosticHost)}` + `Strict consumer declaration validation failed:\n${ts.formatDiagnosticsWithColorAndContext(diagnostics, diagnosticHost)}` + ); +} + +const generatedDamlLeaks = program + .getSourceFiles() + .filter((sourceFile) => sourceFile.fileName.startsWith(declarationRoot)) + .filter((sourceFile) => sourceFile.text.includes(generatedDamlPackage)) + .map((sourceFile) => path.relative(projectRoot, sourceFile.fileName)); + +if (generatedDamlLeaks.length > 0) { + throw new Error( + `Public declaration graph references ${generatedDamlPackage}:\n${generatedDamlLeaks.map((file) => `- ${file}`).join('\n')}` ); } diff --git a/src/OcpClient.ts b/src/OcpClient.ts index 38a8f0a6..6df3b344 100644 --- a/src/OcpClient.ts +++ b/src/OcpClient.ts @@ -68,35 +68,38 @@ import { type OcpEnvironment, } from './environment'; import { OcpErrorCodes, OcpValidationError } from './errors'; -import { - authorizeIssuer, - buildCreateIssuerCommand, - getEntityAsOcf, - withdrawAuthorization, - type AuthorizeIssuerParams, - type AuthorizeIssuerResult, - type CreateIssuerParams, - type WithdrawAuthorizationParams, - type WithdrawAuthorizationResult, -} from './functions'; import { archiveCapTable, - CapTableBatch, - classifyIssuerCapTables, - getCapTableState, - mapOcfObjectTypeToEntityType, type ArchiveCapTableParams, type ArchiveCapTableResult, - type CapTableState, - type IssuerCapTableClassification, +} from './functions/OpenCapTable/capTable/archiveCapTable'; +import { CapTableBatch, type CapTableBatchParams } from './functions/OpenCapTable/capTable/CapTableBatch'; +import { getEntityAsOcf } from './functions/OpenCapTable/capTable/damlToOcf'; +import { + mapOcfObjectTypeToEntityType, type OcfDataTypeFor, type OcfEntityType, type OcfReadableDataForObjectType, type OcfReadableObjectType, -} from './functions/OpenCapTable/capTable'; +} from './functions/OpenCapTable/capTable/entityTypes'; +import { + classifyIssuerCapTables, + getCapTableState, + type CapTableState, + type IssuerCapTableClassification, +} from './functions/OpenCapTable/capTable/getCapTableState'; +import { buildCreateIssuerCommand } from './functions/OpenCapTable/issuer/api'; +import type { CreateIssuerParams } from './functions/OpenCapTable/issuer/types'; +import { authorizeIssuer } from './functions/OpenCapTable/issuerAuthorization/authorizeIssuer'; +import type { + AuthorizeIssuerParams, + AuthorizeIssuerResult, + WithdrawAuthorizationParams, + WithdrawAuthorizationResult, +} from './functions/OpenCapTable/issuerAuthorization/types'; +import { withdrawAuthorization } from './functions/OpenCapTable/issuerAuthorization/withdrawAuthorization'; import { mergeCommandContext, type CommandObservabilityOptions, type OcpObservabilityOptions } from './observability'; -import type { CommandWithDisclosedContracts } from './types'; -import type { ContractResult, GetByContractIdParams } from './types/common'; +import type { CommandWithDisclosedContracts, ContractResult, GetByContractIdParams } from './types/common'; import type { OcfConvertibleAcceptanceOutput, OcfConvertibleCancellationOutput, @@ -824,21 +827,7 @@ export interface GetByObjectTypeParams< } /** Parameters for creating a batch cap table update */ -interface CapTableUpdateParams extends CommandObservabilityOptions { - /** The contract ID of the CapTable to update */ - capTableContractId: string; - /** Optional contract details for the CapTable (used to get correct templateId from ledger) */ - capTableContractDetails?: { templateId: string }; - /** - * Optional deterministic command ID for idempotent retry handling. - * Takes precedence over `defaultContext.commandId` and `context.commandId`. - */ - commandId?: string; - /** Party IDs to act as (signatories) */ - actAs: string[]; - /** Optional additional party IDs for read access */ - readAs?: string[]; -} +type CapTableUpdateParams = CapTableBatchParams; /** Entity namespace with a single `get()` method */ interface EntityReader { diff --git a/src/functions/OpenCapTable/capTable/CapTableBatch.ts b/src/functions/OpenCapTable/capTable/CapTableBatch.ts index eb0942fa..9d9f43ab 100644 --- a/src/functions/OpenCapTable/capTable/CapTableBatch.ts +++ b/src/functions/OpenCapTable/capTable/CapTableBatch.ts @@ -7,38 +7,35 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api'; import type { Command } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/commands'; -import { Fairmint, OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; +import { OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; import { OcpContractError, OcpErrorCodes, OcpValidationError } from '../../../errors'; import { mergeCommandContext, submitObservedTransactionTree, type CommandObservabilityOptions, } from '../../../observability'; -import type { CommandWithDisclosedContracts } from '../../../types'; +import type { CommandWithDisclosedContracts } from '../../../types/common'; +import { type OcfCreateData, type OcfDeleteData, type OcfEditData, type UpdateCapTableResult } from './batchTypes'; import { - ENTITY_TAG_MAP, - isOcfCreatableEntityType, isOcfDeletableEntityType, - isOcfEditableEntityType, type CapTableBatchExecuteResult, type CapTableBatchOperations, type OcfCreateArguments, - type OcfCreateData, - type OcfCreateDataFor, type OcfCreateOperation, type OcfDataTypeFor, type OcfDeletableEntityType, - type OcfDeleteData, - type OcfDeleteDataFor, type OcfDeleteOperation, type OcfEditArguments, - type OcfEditData, - type OcfEditDataFor, type OcfEditOperation, type OcfEntityType, - type UpdateCapTableResult, -} from './batchTypes'; -import { convertOperationToDaml, convertToDaml } from './ocfToDaml'; +} from './entityTypes'; +import { + buildOcfCreateData, + buildOcfCreateDataFromOperation, + buildOcfDeleteData, + buildOcfEditData, + buildOcfEditDataFromOperation, +} from './generatedBatchOperations'; /** Parameters for initializing a batch update. */ export interface CapTableBatchParams extends CommandObservabilityOptions { @@ -71,124 +68,6 @@ export interface BatchItemMeta { securityId?: string; } -function decodeGeneratedOperation( - decoder: { runWithException: (input: unknown) => T }, - input: unknown, - operation: 'create' | 'edit' | 'delete', - entityType: OcfEntityType -): T { - try { - return decoder.runWithException(input); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new OcpValidationError( - `batch.${operation}.${entityType}`, - `Converter output does not match the generated DAML ${operation} variant: ${message}`, - { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: input, - } - ); - } -} - -/** Build and validate one generated DAML create variant from a correlated entity-kind/data pair. */ -export function buildOcfCreateData( - ...args: Arguments -): OcfCreateDataFor; -export function buildOcfCreateData(...args: OcfCreateArguments): OcfCreateData { - const [type] = args; - if (!isOcfCreatableEntityType(type)) { - throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - - const tag = ENTITY_TAG_MAP[type].create; - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, - { tag, value: convertToDaml(...args) }, - 'create', - type - ); -} - -function buildOcfCreateDataFromOperation(operation: OcfCreateOperation): OcfCreateData { - const { type } = operation; - if (!isOcfCreatableEntityType(type)) { - throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - - const tag = ENTITY_TAG_MAP[type].create; - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, - { tag, value: convertOperationToDaml(operation) }, - 'create', - type - ); -} - -/** Build and validate one generated DAML edit variant from a correlated entity-kind/data pair. */ -export function buildOcfEditData( - ...args: Arguments -): OcfEditDataFor; -export function buildOcfEditData(...args: OcfEditArguments): OcfEditData { - const [type] = args; - if (!isOcfEditableEntityType(type)) { - throw new OcpValidationError('type', `Edit operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - - const tag = ENTITY_TAG_MAP[type].edit; - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, - { tag, value: convertToDaml(...args) }, - 'edit', - type - ); -} - -function buildOcfEditDataFromOperation(operation: OcfEditOperation): OcfEditData { - const { type } = operation; - if (!isOcfEditableEntityType(type)) { - throw new OcpValidationError('type', `Edit operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - - const tag = ENTITY_TAG_MAP[type].edit; - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, - { tag, value: convertOperationToDaml(operation) }, - 'edit', - type - ); -} - -/** Build and validate one generated DAML delete variant. */ -export function buildOcfDeleteData( - type: EntityType, - id: string -): OcfDeleteDataFor; -export function buildOcfDeleteData(type: OcfDeletableEntityType, id: string): OcfDeleteData { - if (!isOcfDeletableEntityType(type)) { - throw new OcpValidationError('type', `Delete operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - - const tag = ENTITY_TAG_MAP[type].delete; - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfDeleteData.decoder, - { tag, value: id }, - 'delete', - type - ); -} - /** * Fluent batch builder for cap table updates. * diff --git a/src/functions/OpenCapTable/capTable/archiveCapTable.ts b/src/functions/OpenCapTable/capTable/archiveCapTable.ts index 07d031cd..5996b618 100644 --- a/src/functions/OpenCapTable/capTable/archiveCapTable.ts +++ b/src/functions/OpenCapTable/capTable/archiveCapTable.ts @@ -11,7 +11,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import { submitObservedTransactionTree, type CommandObservabilityOptions } from '../../../observability'; -import type { CommandWithDisclosedContracts } from '../../../types'; +import type { CommandWithDisclosedContracts } from '../../../types/common'; import { buildCapTableCommand } from './buildCapTableCommand'; /** Parameters for archiving a CapTable contract. */ diff --git a/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts b/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts index 14d80d7b..51a21c66 100644 --- a/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts +++ b/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts @@ -17,8 +17,8 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk/build/src/cl import { OcpErrorCodes } from '../../../errors/codes'; import { OcpContractError } from '../../../errors/OcpContractError'; import { archiveCapTable } from './archiveCapTable'; -import type { OcfEntityType } from './batchTypes'; import { CapTableBatch } from './CapTableBatch'; +import type { OcfEntityType } from './entityTypes'; import { classifyIssuerCapTables, type CapTableWithArchiveContext } from './getCapTableState'; /** Minimal contract state needed for the archive operation. */ diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index 909c2a10..d1dcb053 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -6,57 +6,41 @@ */ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import type { OcfObjectType } from '../../../types/native'; import type { - OcfConvertibleAcceptance, - OcfConvertibleCancellation, - OcfConvertibleConversion, - OcfConvertibleIssuance, - OcfConvertibleRetraction, - OcfConvertibleTransfer, - OcfDocument, - OcfEquityCompensationAcceptance, - OcfEquityCompensationCancellation, - OcfEquityCompensationExercise, - OcfEquityCompensationIssuance, - OcfEquityCompensationRelease, - OcfEquityCompensationRepricing, - OcfEquityCompensationRetraction, - OcfEquityCompensationTransfer, - OcfIssuer, - OcfIssuerAuthorizedSharesAdjustment, - OcfObjectType, - OcfStakeholder, - OcfStakeholderRelationshipChangeEvent, - OcfStakeholderStatusChangeEvent, - OcfStockAcceptance, - OcfStockCancellation, - OcfStockClass, - OcfStockClassAuthorizedSharesAdjustment, - OcfStockClassConversionRatioAdjustment, - OcfStockClassSplit, - OcfStockConsolidation, - OcfStockConversion, - OcfStockIssuance, - OcfStockLegendTemplate, - OcfStockPlan, - OcfStockPlanPoolAdjustment, - OcfStockPlanReturnToPool, - OcfStockReissuance, - OcfStockRepurchase, - OcfStockRetraction, - OcfStockTransfer, - OcfValuation, - OcfVestingAcceleration, - OcfVestingEvent, - OcfVestingStart, - OcfVestingTerms, - OcfWarrantAcceptance, - OcfWarrantCancellation, - OcfWarrantExercise, - OcfWarrantIssuance, - OcfWarrantRetraction, - OcfWarrantTransfer, -} from '../../../types'; + OcfCreatableEntityType, + OcfDataTypeFor, + OcfDeletableEntityType, + OcfEditableEntityType, + OcfEntityDataMap, + OcfEntityType, +} from './entityTypes'; + +export { + isOcfCreatableEntityType, + isOcfDeletableEntityType, + isOcfEditableEntityType, + isOcfEntityType, + isOcfReadableObjectType, + mapOcfObjectTypeToEntityType, + OCF_OBJECT_TYPE_TO_ENTITY_TYPE, + type CapTableBatchExecuteResult, + type CapTableBatchOperations, + type OcfCreatableEntityType, + type OcfCreateArguments, + type OcfCreateOperation, + type OcfDataTypeFor, + type OcfDeletableEntityType, + type OcfDeleteOperation, + type OcfEditableEntityType, + type OcfEditArguments, + type OcfEditOperation, + type OcfEntityDataMap, + type OcfEntityType, + type OcfEntityTypeForObjectType, + type OcfReadableDataForObjectType, + type OcfReadableObjectType, +} from './entityTypes'; // Re-export DAML types for convenience export type OcfCreateData = Fairmint.OpenCapTable.CapTable.OcfCreateData; @@ -64,86 +48,6 @@ export type OcfEditData = Fairmint.OpenCapTable.CapTable.OcfEditData; export type OcfDeleteData = Fairmint.OpenCapTable.CapTable.OcfDeleteData; export type UpdateCapTableResult = Fairmint.OpenCapTable.CapTable.UpdateCapTableResult; -/** - * Result of executing a CapTableBatch. - * - * Extends the DAML choice result with transaction metadata from the ledger. - * This includes all properties from UpdateCapTableResult plus the updateId. - */ -export type CapTableBatchExecuteResult = UpdateCapTableResult & { - /** The update ID (transaction ID) from the Canton ledger */ - updateId: string; -}; - -/** - * Type mapping from entity type string to native OCF data type. - */ -// A closed alias prevents module augmentation from widening OcfEntityType beyond ENTITY_REGISTRY. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type OcfEntityDataMap = { - convertibleAcceptance: OcfConvertibleAcceptance; - convertibleCancellation: OcfConvertibleCancellation; - convertibleConversion: OcfConvertibleConversion; - convertibleIssuance: OcfConvertibleIssuance; - convertibleRetraction: OcfConvertibleRetraction; - convertibleTransfer: OcfConvertibleTransfer; - document: OcfDocument; - equityCompensationAcceptance: OcfEquityCompensationAcceptance; - equityCompensationCancellation: OcfEquityCompensationCancellation; - equityCompensationExercise: OcfEquityCompensationExercise; - equityCompensationIssuance: OcfEquityCompensationIssuance; - equityCompensationRelease: OcfEquityCompensationRelease; - equityCompensationRepricing: OcfEquityCompensationRepricing; - equityCompensationRetraction: OcfEquityCompensationRetraction; - equityCompensationTransfer: OcfEquityCompensationTransfer; - /** Issuer is edit-only (no create/delete) - created with CapTable via IssuerAuthorization */ - issuer: OcfIssuer; - issuerAuthorizedSharesAdjustment: OcfIssuerAuthorizedSharesAdjustment; - stakeholder: OcfStakeholder; - stakeholderRelationshipChangeEvent: OcfStakeholderRelationshipChangeEvent; - stakeholderStatusChangeEvent: OcfStakeholderStatusChangeEvent; - stockAcceptance: OcfStockAcceptance; - stockCancellation: OcfStockCancellation; - stockClass: OcfStockClass; - stockClassAuthorizedSharesAdjustment: OcfStockClassAuthorizedSharesAdjustment; - stockClassConversionRatioAdjustment: OcfStockClassConversionRatioAdjustment; - stockClassSplit: OcfStockClassSplit; - stockConsolidation: OcfStockConsolidation; - stockConversion: OcfStockConversion; - stockIssuance: OcfStockIssuance; - stockLegendTemplate: OcfStockLegendTemplate; - stockPlan: OcfStockPlan; - stockPlanPoolAdjustment: OcfStockPlanPoolAdjustment; - stockPlanReturnToPool: OcfStockPlanReturnToPool; - stockReissuance: OcfStockReissuance; - stockRepurchase: OcfStockRepurchase; - stockRetraction: OcfStockRetraction; - stockTransfer: OcfStockTransfer; - valuation: OcfValuation; - vestingAcceleration: OcfVestingAcceleration; - vestingEvent: OcfVestingEvent; - vestingStart: OcfVestingStart; - vestingTerms: OcfVestingTerms; - warrantAcceptance: OcfWarrantAcceptance; - warrantCancellation: OcfWarrantCancellation; - warrantExercise: OcfWarrantExercise; - warrantIssuance: OcfWarrantIssuance; - warrantRetraction: OcfWarrantRetraction; - warrantTransfer: OcfWarrantTransfer; -}; - -/** - * All supported OCF entity types for batch operations. - * - * Derived from {@link OcfEntityDataMap} so an entity kind cannot be added to the - * data model without also becoming part of the SDK's canonical entity-kind union. - * Operation-specific subsets are derived from {@link ENTITY_REGISTRY} below. - */ -export type OcfEntityType = keyof OcfEntityDataMap; - -/** Helper type to get the native OCF data type for a given entity type. */ -export type OcfDataTypeFor = OcfEntityDataMap[T]; - type OcfCreateTag = OcfCreateData['tag']; type OcfEditTag = OcfEditData['tag']; type OcfDeleteTag = OcfDeleteData['tag']; @@ -567,21 +471,6 @@ export const ENTITY_REGISTRY = { }, } as const satisfies OcfEntityRegistry; -type EntityTypeWithCapability = { - [EntityType in OcfEntityType]: (typeof ENTITY_REGISTRY)[EntityType]['operations'] extends Record - ? EntityType - : never; -}[OcfEntityType]; - -/** Entity kinds that can be created through UpdateCapTable. */ -export type OcfCreatableEntityType = EntityTypeWithCapability<'create'>; - -/** Entity kinds that can be edited through UpdateCapTable. */ -export type OcfEditableEntityType = EntityTypeWithCapability<'edit'>; - -/** Entity kinds that can be deleted through UpdateCapTable. */ -export type OcfDeletableEntityType = EntityTypeWithCapability<'delete'>; - type OcfOperationTagFor = (typeof ENTITY_REGISTRY)[EntityType]['operations'] extends Readonly> ? Tag @@ -621,157 +510,6 @@ export type OcfEntityArguments = { [EntityType in OcfEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; }[OcfEntityType]; -/** Correlated argument tuples accepted by {@link CapTableBatch.create}. */ -export type OcfCreateArguments = { - [EntityType in OcfCreatableEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; -}[OcfCreatableEntityType]; - -/** Correlated argument tuples accepted by {@link CapTableBatch.edit}. */ -export type OcfEditArguments = { - [EntityType in OcfEditableEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; -}[OcfEditableEntityType]; - -/** A create operation whose entity kind and payload type are correlated. */ -export type OcfCreateOperation = - EntityType extends OcfCreatableEntityType - ? Readonly<{ - type: EntityType; - data: OcfDataTypeFor; - }> - : never; - -/** An edit operation whose entity kind and payload type are correlated. */ -export type OcfEditOperation = - EntityType extends OcfEditableEntityType - ? Readonly<{ - type: EntityType; - data: OcfDataTypeFor; - }> - : never; - -/** A delete operation limited to entity kinds that support deletion. */ -export type OcfDeleteOperation = - EntityType extends OcfDeletableEntityType - ? Readonly<{ - type: EntityType; - id: string; - }> - : never; - -/** Operations accepted by {@link buildUpdateCapTableCommand}. */ -export interface CapTableBatchOperations { - readonly creates?: readonly OcfCreateOperation[]; - readonly edits?: readonly OcfEditOperation[]; - readonly deletes?: readonly OcfDeleteOperation[]; -} - -/** Runtime guard for SDK entity kinds. */ -export function isOcfEntityType(entityType: string): entityType is OcfEntityType { - return Object.prototype.hasOwnProperty.call(ENTITY_REGISTRY, entityType); -} - -/** Runtime guard for entity kinds that support create operations. */ -export function isOcfCreatableEntityType(entityType: string): entityType is OcfCreatableEntityType { - if (!isOcfEntityType(entityType)) return false; - const entry: OcfEntityRegistryEntry = ENTITY_REGISTRY[entityType]; - return entry.operations.create !== undefined; -} - -/** Runtime guard for entity kinds that support edit operations. */ -export function isOcfEditableEntityType(entityType: string): entityType is OcfEditableEntityType { - if (!isOcfEntityType(entityType)) return false; - const entry: OcfEntityRegistryEntry = ENTITY_REGISTRY[entityType]; - return entry.operations.edit !== undefined; -} - -/** Runtime guard for entity kinds that support delete operations. */ -export function isOcfDeletableEntityType(entityType: string): entityType is OcfDeletableEntityType { - if (!isOcfEntityType(entityType)) return false; - const entry: OcfEntityRegistryEntry = ENTITY_REGISTRY[entityType]; - return entry.operations.delete !== undefined; -} - -/** - * Canonical OCF `object_type` to SDK entity reader mapping. - * - * Legacy PlanSecurity aliases normalize to EquityCompensation before reaching this map, - * so it exposes only canonical ledger object types with first-class read facades. - */ -export const OCF_OBJECT_TYPE_TO_ENTITY_TYPE = { - CE_STAKEHOLDER_RELATIONSHIP: 'stakeholderRelationshipChangeEvent', - CE_STAKEHOLDER_STATUS: 'stakeholderStatusChangeEvent', - DOCUMENT: 'document', - ISSUER: 'issuer', - STAKEHOLDER: 'stakeholder', - STOCK_CLASS: 'stockClass', - STOCK_LEGEND_TEMPLATE: 'stockLegendTemplate', - STOCK_PLAN: 'stockPlan', - TX_CONVERTIBLE_ACCEPTANCE: 'convertibleAcceptance', - TX_CONVERTIBLE_CANCELLATION: 'convertibleCancellation', - TX_CONVERTIBLE_CONVERSION: 'convertibleConversion', - TX_CONVERTIBLE_ISSUANCE: 'convertibleIssuance', - TX_CONVERTIBLE_RETRACTION: 'convertibleRetraction', - TX_CONVERTIBLE_TRANSFER: 'convertibleTransfer', - TX_EQUITY_COMPENSATION_ACCEPTANCE: 'equityCompensationAcceptance', - TX_EQUITY_COMPENSATION_CANCELLATION: 'equityCompensationCancellation', - TX_EQUITY_COMPENSATION_EXERCISE: 'equityCompensationExercise', - TX_EQUITY_COMPENSATION_ISSUANCE: 'equityCompensationIssuance', - TX_EQUITY_COMPENSATION_RELEASE: 'equityCompensationRelease', - TX_EQUITY_COMPENSATION_REPRICING: 'equityCompensationRepricing', - TX_EQUITY_COMPENSATION_RETRACTION: 'equityCompensationRetraction', - TX_EQUITY_COMPENSATION_TRANSFER: 'equityCompensationTransfer', - TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT: 'issuerAuthorizedSharesAdjustment', - TX_STOCK_ACCEPTANCE: 'stockAcceptance', - TX_STOCK_CANCELLATION: 'stockCancellation', - TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT: 'stockClassAuthorizedSharesAdjustment', - TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT: 'stockClassConversionRatioAdjustment', - TX_STOCK_CLASS_SPLIT: 'stockClassSplit', - TX_STOCK_CONSOLIDATION: 'stockConsolidation', - TX_STOCK_CONVERSION: 'stockConversion', - TX_STOCK_ISSUANCE: 'stockIssuance', - TX_STOCK_PLAN_POOL_ADJUSTMENT: 'stockPlanPoolAdjustment', - TX_STOCK_PLAN_RETURN_TO_POOL: 'stockPlanReturnToPool', - TX_STOCK_REISSUANCE: 'stockReissuance', - TX_STOCK_REPURCHASE: 'stockRepurchase', - TX_STOCK_RETRACTION: 'stockRetraction', - TX_STOCK_TRANSFER: 'stockTransfer', - TX_VESTING_ACCELERATION: 'vestingAcceleration', - TX_VESTING_EVENT: 'vestingEvent', - TX_VESTING_START: 'vestingStart', - TX_WARRANT_ACCEPTANCE: 'warrantAcceptance', - TX_WARRANT_CANCELLATION: 'warrantCancellation', - TX_WARRANT_EXERCISE: 'warrantExercise', - TX_WARRANT_ISSUANCE: 'warrantIssuance', - TX_WARRANT_RETRACTION: 'warrantRetraction', - TX_WARRANT_TRANSFER: 'warrantTransfer', - VALUATION: 'valuation', - VESTING_TERMS: 'vestingTerms', -} as const satisfies Record; - -/** OCF object types that can be read through OpenCapTable entity readers. */ -export type OcfReadableObjectType = keyof typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE; - -/** SDK entity reader name for a given OCF object type. */ -export type OcfEntityTypeForObjectType = (typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE)[T]; - -/** Canonical entity data returned for a readable OCF object type. */ -export type OcfReadableDataForObjectType = OcfDataTypeFor< - OcfEntityTypeForObjectType ->; - -export function mapOcfObjectTypeToEntityType( - objectType: T -): OcfEntityTypeForObjectType; -export function mapOcfObjectTypeToEntityType(objectType: string): OcfEntityType | null; -export function mapOcfObjectTypeToEntityType(objectType: string): OcfEntityType | null { - return isOcfReadableObjectType(objectType) ? OCF_OBJECT_TYPE_TO_ENTITY_TYPE[objectType] : null; -} - -/** Runtime guard for OCF object types supported by OpenCapTable readers. */ -export function isOcfReadableObjectType(objectType: string): objectType is OcfReadableObjectType { - return Object.prototype.hasOwnProperty.call(OCF_OBJECT_TYPE_TO_ENTITY_TYPE, objectType); -} - function entityRegistryEntries(): Array<[OcfEntityType, OcfEntityRegistryEntry]> { return Object.entries(ENTITY_REGISTRY) as Array<[OcfEntityType, OcfEntityRegistryEntry]>; } diff --git a/src/functions/OpenCapTable/capTable/buildCapTableCommand.ts b/src/functions/OpenCapTable/capTable/buildCapTableCommand.ts index 8ccaa25e..95e61686 100644 --- a/src/functions/OpenCapTable/capTable/buildCapTableCommand.ts +++ b/src/functions/OpenCapTable/capTable/buildCapTableCommand.ts @@ -1,6 +1,6 @@ import type { Command } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/commands'; import { OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; -import type { CommandWithDisclosedContracts } from '../../../types'; +import type { CommandWithDisclosedContracts } from '../../../types/common'; /** * Interface for JSON API choice arguments - matches what the SDK's Zod schema accepts. diff --git a/src/functions/OpenCapTable/capTable/entityTypes.ts b/src/functions/OpenCapTable/capTable/entityTypes.ts new file mode 100644 index 00000000..253b0e82 --- /dev/null +++ b/src/functions/OpenCapTable/capTable/entityTypes.ts @@ -0,0 +1,271 @@ +/** + * Public, protocol-native entity and batch types. + * + * This module deliberately depends only on the SDK's canonical OCF model. The + * generated DAML codecs are an implementation detail and must not become part + * of the package's root declaration graph. + */ + +import type { + OcfConvertibleAcceptance, + OcfConvertibleCancellation, + OcfConvertibleConversion, + OcfConvertibleIssuance, + OcfConvertibleRetraction, + OcfConvertibleTransfer, + OcfDocument, + OcfEquityCompensationAcceptance, + OcfEquityCompensationCancellation, + OcfEquityCompensationExercise, + OcfEquityCompensationIssuance, + OcfEquityCompensationRelease, + OcfEquityCompensationRepricing, + OcfEquityCompensationRetraction, + OcfEquityCompensationTransfer, + OcfIssuer, + OcfIssuerAuthorizedSharesAdjustment, + OcfStakeholder, + OcfStakeholderRelationshipChangeEvent, + OcfStakeholderStatusChangeEvent, + OcfStockAcceptance, + OcfStockCancellation, + OcfStockClass, + OcfStockClassAuthorizedSharesAdjustment, + OcfStockClassConversionRatioAdjustment, + OcfStockClassSplit, + OcfStockConsolidation, + OcfStockConversion, + OcfStockIssuance, + OcfStockLegendTemplate, + OcfStockPlan, + OcfStockPlanPoolAdjustment, + OcfStockPlanReturnToPool, + OcfStockReissuance, + OcfStockRepurchase, + OcfStockRetraction, + OcfStockTransfer, + OcfValuation, + OcfVestingAcceleration, + OcfVestingEvent, + OcfVestingStart, + OcfVestingTerms, + OcfWarrantAcceptance, + OcfWarrantCancellation, + OcfWarrantExercise, + OcfWarrantIssuance, + OcfWarrantRetraction, + OcfWarrantTransfer, +} from '../../../types/native'; + +/** Canonical native OCF data for every entity handled by the SDK. */ +// A closed alias prevents module augmentation from widening the supported kinds. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type OcfEntityDataMap = { + convertibleAcceptance: OcfConvertibleAcceptance; + convertibleCancellation: OcfConvertibleCancellation; + convertibleConversion: OcfConvertibleConversion; + convertibleIssuance: OcfConvertibleIssuance; + convertibleRetraction: OcfConvertibleRetraction; + convertibleTransfer: OcfConvertibleTransfer; + document: OcfDocument; + equityCompensationAcceptance: OcfEquityCompensationAcceptance; + equityCompensationCancellation: OcfEquityCompensationCancellation; + equityCompensationExercise: OcfEquityCompensationExercise; + equityCompensationIssuance: OcfEquityCompensationIssuance; + equityCompensationRelease: OcfEquityCompensationRelease; + equityCompensationRepricing: OcfEquityCompensationRepricing; + equityCompensationRetraction: OcfEquityCompensationRetraction; + equityCompensationTransfer: OcfEquityCompensationTransfer; + /** Issuer is edit-only; it is created with the CapTable. */ + issuer: OcfIssuer; + issuerAuthorizedSharesAdjustment: OcfIssuerAuthorizedSharesAdjustment; + stakeholder: OcfStakeholder; + stakeholderRelationshipChangeEvent: OcfStakeholderRelationshipChangeEvent; + stakeholderStatusChangeEvent: OcfStakeholderStatusChangeEvent; + stockAcceptance: OcfStockAcceptance; + stockCancellation: OcfStockCancellation; + stockClass: OcfStockClass; + stockClassAuthorizedSharesAdjustment: OcfStockClassAuthorizedSharesAdjustment; + stockClassConversionRatioAdjustment: OcfStockClassConversionRatioAdjustment; + stockClassSplit: OcfStockClassSplit; + stockConsolidation: OcfStockConsolidation; + stockConversion: OcfStockConversion; + stockIssuance: OcfStockIssuance; + stockLegendTemplate: OcfStockLegendTemplate; + stockPlan: OcfStockPlan; + stockPlanPoolAdjustment: OcfStockPlanPoolAdjustment; + stockPlanReturnToPool: OcfStockPlanReturnToPool; + stockReissuance: OcfStockReissuance; + stockRepurchase: OcfStockRepurchase; + stockRetraction: OcfStockRetraction; + stockTransfer: OcfStockTransfer; + valuation: OcfValuation; + vestingAcceleration: OcfVestingAcceleration; + vestingEvent: OcfVestingEvent; + vestingStart: OcfVestingStart; + vestingTerms: OcfVestingTerms; + warrantAcceptance: OcfWarrantAcceptance; + warrantCancellation: OcfWarrantCancellation; + warrantExercise: OcfWarrantExercise; + warrantIssuance: OcfWarrantIssuance; + warrantRetraction: OcfWarrantRetraction; + warrantTransfer: OcfWarrantTransfer; +}; + +/** All canonical entity kinds supported by batch operations and readers. */ +export type OcfEntityType = keyof OcfEntityDataMap; + +/** Canonical OCF data for one entity kind. */ +export type OcfDataTypeFor = OcfEntityDataMap[T]; + +/** Entity kinds that can be created through UpdateCapTable. */ +export type OcfCreatableEntityType = Exclude; + +/** Every canonical entity can be edited through UpdateCapTable. */ +export type OcfEditableEntityType = OcfEntityType; + +/** Entity kinds that can be deleted through UpdateCapTable. */ +export type OcfDeletableEntityType = Exclude; + +/** Correlated argument tuples accepted by {@link import('./CapTableBatch').CapTableBatch.create}. */ +export type OcfCreateArguments = { + [EntityType in OcfCreatableEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; +}[OcfCreatableEntityType]; + +/** Correlated argument tuples accepted by {@link import('./CapTableBatch').CapTableBatch.edit}. */ +export type OcfEditArguments = { + [EntityType in OcfEditableEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; +}[OcfEditableEntityType]; + +/** A create operation whose kind and payload remain correlated. */ +export type OcfCreateOperation = + EntityType extends OcfCreatableEntityType ? Readonly<{ type: EntityType; data: OcfDataTypeFor }> : never; + +/** An edit operation whose kind and payload remain correlated. */ +export type OcfEditOperation = + EntityType extends OcfEditableEntityType ? Readonly<{ type: EntityType; data: OcfDataTypeFor }> : never; + +/** A delete operation limited to deletable entity kinds. */ +export type OcfDeleteOperation = + EntityType extends OcfDeletableEntityType ? Readonly<{ type: EntityType; id: string }> : never; + +/** Native operations accepted by {@link import('./CapTableBatch').buildUpdateCapTableCommand}. */ +export interface CapTableBatchOperations { + readonly creates?: readonly OcfCreateOperation[]; + readonly edits?: readonly OcfEditOperation[]; + readonly deletes?: readonly OcfDeleteOperation[]; +} + +/** Contract ID returned for one OCF entity created or edited by a batch. */ +export type OcfContractId = { + [EntityType in OcfEntityType]: Readonly<{ + tag: `Cid${Capitalize}`; + value: string; + }>; +}[OcfEntityType]; + +/** Result of executing a fluent cap-table batch. */ +export interface CapTableBatchExecuteResult { + readonly updatedCapTableCid: string; + readonly createdCids: readonly OcfContractId[]; + readonly editedCids: readonly OcfContractId[]; + readonly updateId: string; +} + +/** Canonical OCF `object_type` to typed reader namespace. */ +export const OCF_OBJECT_TYPE_TO_ENTITY_TYPE = { + CE_STAKEHOLDER_RELATIONSHIP: 'stakeholderRelationshipChangeEvent', + CE_STAKEHOLDER_STATUS: 'stakeholderStatusChangeEvent', + DOCUMENT: 'document', + ISSUER: 'issuer', + STAKEHOLDER: 'stakeholder', + STOCK_CLASS: 'stockClass', + STOCK_LEGEND_TEMPLATE: 'stockLegendTemplate', + STOCK_PLAN: 'stockPlan', + TX_CONVERTIBLE_ACCEPTANCE: 'convertibleAcceptance', + TX_CONVERTIBLE_CANCELLATION: 'convertibleCancellation', + TX_CONVERTIBLE_CONVERSION: 'convertibleConversion', + TX_CONVERTIBLE_ISSUANCE: 'convertibleIssuance', + TX_CONVERTIBLE_RETRACTION: 'convertibleRetraction', + TX_CONVERTIBLE_TRANSFER: 'convertibleTransfer', + TX_EQUITY_COMPENSATION_ACCEPTANCE: 'equityCompensationAcceptance', + TX_EQUITY_COMPENSATION_CANCELLATION: 'equityCompensationCancellation', + TX_EQUITY_COMPENSATION_EXERCISE: 'equityCompensationExercise', + TX_EQUITY_COMPENSATION_ISSUANCE: 'equityCompensationIssuance', + TX_EQUITY_COMPENSATION_RELEASE: 'equityCompensationRelease', + TX_EQUITY_COMPENSATION_REPRICING: 'equityCompensationRepricing', + TX_EQUITY_COMPENSATION_RETRACTION: 'equityCompensationRetraction', + TX_EQUITY_COMPENSATION_TRANSFER: 'equityCompensationTransfer', + TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT: 'issuerAuthorizedSharesAdjustment', + TX_STOCK_ACCEPTANCE: 'stockAcceptance', + TX_STOCK_CANCELLATION: 'stockCancellation', + TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT: 'stockClassAuthorizedSharesAdjustment', + TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT: 'stockClassConversionRatioAdjustment', + TX_STOCK_CLASS_SPLIT: 'stockClassSplit', + TX_STOCK_CONSOLIDATION: 'stockConsolidation', + TX_STOCK_CONVERSION: 'stockConversion', + TX_STOCK_ISSUANCE: 'stockIssuance', + TX_STOCK_PLAN_POOL_ADJUSTMENT: 'stockPlanPoolAdjustment', + TX_STOCK_PLAN_RETURN_TO_POOL: 'stockPlanReturnToPool', + TX_STOCK_REISSUANCE: 'stockReissuance', + TX_STOCK_REPURCHASE: 'stockRepurchase', + TX_STOCK_RETRACTION: 'stockRetraction', + TX_STOCK_TRANSFER: 'stockTransfer', + TX_VESTING_ACCELERATION: 'vestingAcceleration', + TX_VESTING_EVENT: 'vestingEvent', + TX_VESTING_START: 'vestingStart', + TX_WARRANT_ACCEPTANCE: 'warrantAcceptance', + TX_WARRANT_CANCELLATION: 'warrantCancellation', + TX_WARRANT_EXERCISE: 'warrantExercise', + TX_WARRANT_ISSUANCE: 'warrantIssuance', + TX_WARRANT_RETRACTION: 'warrantRetraction', + TX_WARRANT_TRANSFER: 'warrantTransfer', + VALUATION: 'valuation', + VESTING_TERMS: 'vestingTerms', +} as const satisfies Record; + +/** OCF object types supported by the high-level entity readers. */ +export type OcfReadableObjectType = keyof typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE; + +/** Reader namespace for one canonical OCF object type. */ +export type OcfEntityTypeForObjectType = (typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE)[T]; + +/** Canonical data returned by one object-type reader. */ +export type OcfReadableDataForObjectType = OcfDataTypeFor< + OcfEntityTypeForObjectType +>; + +const OCF_ENTITY_TYPES: ReadonlySet = new Set(Object.values(OCF_OBJECT_TYPE_TO_ENTITY_TYPE)); + +/** Runtime guard for canonical SDK entity kinds. */ +export function isOcfEntityType(entityType: string): entityType is OcfEntityType { + return OCF_ENTITY_TYPES.has(entityType); +} + +/** Runtime guard for entity kinds that support creation. */ +export function isOcfCreatableEntityType(entityType: string): entityType is OcfCreatableEntityType { + return entityType !== 'issuer' && isOcfEntityType(entityType); +} + +/** Runtime guard for entity kinds that support edits. */ +export function isOcfEditableEntityType(entityType: string): entityType is OcfEditableEntityType { + return isOcfEntityType(entityType); +} + +/** Runtime guard for entity kinds that support deletion. */ +export function isOcfDeletableEntityType(entityType: string): entityType is OcfDeletableEntityType { + return entityType !== 'issuer' && isOcfEntityType(entityType); +} + +export function mapOcfObjectTypeToEntityType( + objectType: T +): OcfEntityTypeForObjectType; +export function mapOcfObjectTypeToEntityType(objectType: string): OcfEntityType | null; +export function mapOcfObjectTypeToEntityType(objectType: string): OcfEntityType | null { + return isOcfReadableObjectType(objectType) ? OCF_OBJECT_TYPE_TO_ENTITY_TYPE[objectType] : null; +} + +/** Runtime guard for OCF object types supported by high-level readers. */ +export function isOcfReadableObjectType(objectType: string): objectType is OcfReadableObjectType { + return Object.prototype.hasOwnProperty.call(OCF_OBJECT_TYPE_TO_ENTITY_TYPE, objectType); +} diff --git a/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts b/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts new file mode 100644 index 00000000..c70c9be0 --- /dev/null +++ b/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts @@ -0,0 +1,141 @@ +/** @internal Generated DAML operation construction for CapTableBatch. */ + +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { + ENTITY_TAG_MAP, + type OcfCreateData, + type OcfCreateDataFor, + type OcfDeleteData, + type OcfDeleteDataFor, + type OcfEditData, + type OcfEditDataFor, +} from './batchTypes'; +import type { + OcfCreateArguments, + OcfCreateOperation, + OcfDeletableEntityType, + OcfDeleteOperation, + OcfEditArguments, + OcfEditOperation, + OcfEntityType, +} from './entityTypes'; +import { isOcfCreatableEntityType, isOcfDeletableEntityType, isOcfEditableEntityType } from './entityTypes'; +import { convertOperationToDaml, convertToDaml } from './ocfToDaml'; + +function decodeGeneratedOperation( + decoder: { runWithException: (input: unknown) => T }, + input: unknown, + operation: 'create' | 'edit' | 'delete', + entityType: OcfEntityType +): T { + try { + return decoder.runWithException(input); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new OcpValidationError( + `batch.${operation}.${entityType}`, + `Converter output does not match the generated DAML ${operation} variant: ${message}`, + { + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue: input, + } + ); + } +} + +/** @internal Build and validate one generated DAML create variant. */ +export function buildOcfCreateData( + ...args: Arguments +): OcfCreateDataFor; +export function buildOcfCreateData(...args: OcfCreateArguments): OcfCreateData { + const [type] = args; + if (!isOcfCreatableEntityType(type)) { + throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { + code: OcpErrorCodes.INVALID_TYPE, + }); + } + + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, + { tag: ENTITY_TAG_MAP[type].create, value: convertToDaml(...args) }, + 'create', + type + ); +} + +export function buildOcfCreateDataFromOperation(operation: OcfCreateOperation): OcfCreateData { + const { type } = operation; + if (!isOcfCreatableEntityType(type)) { + throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { + code: OcpErrorCodes.INVALID_TYPE, + }); + } + + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, + { tag: ENTITY_TAG_MAP[type].create, value: convertOperationToDaml(operation) }, + 'create', + type + ); +} + +/** @internal Build and validate one generated DAML edit variant. */ +export function buildOcfEditData( + ...args: Arguments +): OcfEditDataFor; +export function buildOcfEditData(...args: OcfEditArguments): OcfEditData { + const [type] = args; + if (!isOcfEditableEntityType(type)) { + throw new OcpValidationError('type', `Edit operation not supported for entity type: ${String(type)}`, { + code: OcpErrorCodes.INVALID_TYPE, + }); + } + + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, + { tag: ENTITY_TAG_MAP[type].edit, value: convertToDaml(...args) }, + 'edit', + type + ); +} + +export function buildOcfEditDataFromOperation(operation: OcfEditOperation): OcfEditData { + const { type } = operation; + if (!isOcfEditableEntityType(type)) { + throw new OcpValidationError('type', `Edit operation not supported for entity type: ${String(type)}`, { + code: OcpErrorCodes.INVALID_TYPE, + }); + } + + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, + { tag: ENTITY_TAG_MAP[type].edit, value: convertOperationToDaml(operation) }, + 'edit', + type + ); +} + +/** @internal Build and validate one generated DAML delete variant. */ +export function buildOcfDeleteData( + type: EntityType, + id: string +): OcfDeleteDataFor; +export function buildOcfDeleteData(type: OcfDeletableEntityType, id: string): OcfDeleteData { + if (!isOcfDeletableEntityType(type)) { + throw new OcpValidationError('type', `Delete operation not supported for entity type: ${String(type)}`, { + code: OcpErrorCodes.INVALID_TYPE, + }); + } + + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfDeleteData.decoder, + { tag: ENTITY_TAG_MAP[type].delete, value: id }, + 'delete', + type + ); +} + +export function buildOcfDeleteDataFromOperation(operation: OcfDeleteOperation): OcfDeleteData { + return buildOcfDeleteData(operation.type, operation.id); +} diff --git a/src/functions/OpenCapTable/capTable/getCapTableState.ts b/src/functions/OpenCapTable/capTable/getCapTableState.ts index aa13c41f..889478f6 100644 --- a/src/functions/OpenCapTable/capTable/getCapTableState.ts +++ b/src/functions/OpenCapTable/capTable/getCapTableState.ts @@ -35,8 +35,8 @@ import { } from '../../../utils/contractReadDiagnostics'; import { ledgerReadScope } from '../../../utils/readScope'; import { parseDamlMap } from '../../../utils/typeConversions'; -import { FIELD_TO_ENTITY_TYPE, SECURITY_ID_FIELD_TO_ENTITY_TYPE, type OcfEntityType } from './batchTypes'; -export { FIELD_TO_ENTITY_TYPE, SECURITY_ID_FIELD_TO_ENTITY_TYPE } from './batchTypes'; +import { FIELD_TO_ENTITY_TYPE, SECURITY_ID_FIELD_TO_ENTITY_TYPE } from './batchTypes'; +import type { OcfEntityType } from './entityTypes'; /** CapTable template ID this SDK treats as current (package-name symbolic form; used for ledger queries). */ const CURRENT_CAP_TABLE_TEMPLATE_ID = OCP_TEMPLATES.capTable; diff --git a/src/functions/OpenCapTable/capTable/index.ts b/src/functions/OpenCapTable/capTable/index.ts index 6e8e8892..13d6f1e4 100644 --- a/src/functions/OpenCapTable/capTable/index.ts +++ b/src/functions/OpenCapTable/capTable/index.ts @@ -20,14 +20,12 @@ export { export * from './batchTypes'; export { CapTableBatch, - buildOcfCreateData, - buildOcfDeleteData, - buildOcfEditData, buildUpdateCapTableCommand, type BatchItemDetails, type BatchItemMeta, type CapTableBatchParams, } from './CapTableBatch'; +export { buildOcfCreateData, buildOcfDeleteData, buildOcfEditData } from './generatedBatchOperations'; export { convertToDaml } from './ocfToDaml'; // DAML to OCF conversion (read operations) @@ -43,9 +41,8 @@ export { } from './damlToOcf'; // CapTable state reader (for replication) +export { FIELD_TO_ENTITY_TYPE, SECURITY_ID_FIELD_TO_ENTITY_TYPE } from './batchTypes'; export { - FIELD_TO_ENTITY_TYPE, - SECURITY_ID_FIELD_TO_ENTITY_TYPE, classifyIssuerCapTables, getCapTableState, type CapTableState, diff --git a/src/functions/OpenCapTable/issuer/api.ts b/src/functions/OpenCapTable/issuer/api.ts new file mode 100644 index 00000000..e1f75716 --- /dev/null +++ b/src/functions/OpenCapTable/issuer/api.ts @@ -0,0 +1,12 @@ +/** Public CreateIssuer API, isolated from generated DAML converter declarations. */ + +import type { CommandWithDisclosedContracts } from '../../../types/common'; +import { buildCreateIssuerCommand as buildGeneratedCreateIssuerCommand } from './createIssuer'; +import type { CreateIssuerParams } from './types'; + +export type { CreateIssuerParams, IssuerDataInput } from './types'; + +/** Build the CreateCapTable command that creates an issuer and its CapTable. */ +export function buildCreateIssuerCommand(params: CreateIssuerParams): CommandWithDisclosedContracts { + return buildGeneratedCreateIssuerCommand(params); +} diff --git a/src/functions/OpenCapTable/issuer/createIssuer.ts b/src/functions/OpenCapTable/issuer/createIssuer.ts index 956351cb..77c914a4 100644 --- a/src/functions/OpenCapTable/issuer/createIssuer.ts +++ b/src/functions/OpenCapTable/issuer/createIssuer.ts @@ -3,7 +3,8 @@ import type { DisclosedContract, } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/commands'; import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import type { CommandWithDisclosedContracts, OcfIssuer } from '../../../types'; +import type { CommandWithDisclosedContracts } from '../../../types/common'; +import type { OcfIssuer } from '../../../types/native'; import { validateIssuerData } from '../../../utils/entityValidators'; import { emailTypeToDaml, phoneTypeToDaml } from '../../../utils/enumConversions'; import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; @@ -15,6 +16,7 @@ import { initialSharesAuthorizedToDaml, optionalString, } from '../../../utils/typeConversions'; +import type { CreateIssuerParams, IssuerDataInput } from './types'; function emailToDaml(email: OcfIssuer['email']): Fairmint.OpenCapTable.Types.Contact.OcfEmail | null { if (!email) return null; @@ -36,10 +38,7 @@ function phoneToDaml(phone: OcfIssuer['phone']): Fairmint.OpenCapTable.Types.Con * Input type for issuer data that may have missing array fields. * The SDK normalizes these to empty arrays automatically. */ -export type IssuerDataInput = Omit & { - /** Tax IDs - normalized to empty array if null/undefined */ - tax_ids?: OcfIssuer['tax_ids'] | null; -}; +export type { CreateIssuerParams, IssuerDataInput } from './types'; /** * Normalize issuer data by ensuring array fields are arrays (not null/undefined). @@ -122,31 +121,6 @@ function issuerDataToDamlInternal( }; } -export interface CreateIssuerParams { - /** Details of the IssuerAuthorization contract for disclosed contracts */ - issuerAuthorizationContractDetails: DisclosedContract; - issuerParty: string; - /** - * Issuer data to create - * - * Schema: https://schema.opencaptablecoalition.com/v/1.2.0/objects/Issuer.schema.json - * - * - Legal_name: Legal name of the issuer - * - Formation_date: Date of formation (YYYY-MM-DD) - * - Country_of_formation: Country of formation (ISO 3166-1 alpha-2) - * - Dba (optional): Doing Business As name - * - Country_subdivision_of_formation (optional): Subdivision code of formation (ISO 3166-2) - * - Country_subdivision_name_of_formation (optional): Text name of subdivision of formation - * - Tax_ids (optional): Issuer tax IDs (normalized to empty array if null/undefined) - * - Email (optional): Work email - * - Phone (optional): Phone number in ITU E.123 format - * - Address (optional): Headquarters address - * - Initial_shares_authorized (optional): Initial authorized shares (enum or numeric) - * - Comments (optional): Additional comments - */ - issuerData: IssuerDataInput; -} - /** * Build the ledger command to exercise **CreateCapTable** on an IssuerAuthorization contract. * diff --git a/src/functions/OpenCapTable/issuer/index.ts b/src/functions/OpenCapTable/issuer/index.ts index 2f332fc6..61ea4947 100644 --- a/src/functions/OpenCapTable/issuer/index.ts +++ b/src/functions/OpenCapTable/issuer/index.ts @@ -1,2 +1,3 @@ -export * from './createIssuer'; +export * from './api'; +export { issuerDataToDaml, normalizeIssuerData } from './createIssuer'; export * from './getIssuerAsOcf'; diff --git a/src/functions/OpenCapTable/issuer/types.ts b/src/functions/OpenCapTable/issuer/types.ts new file mode 100644 index 00000000..d970c16b --- /dev/null +++ b/src/functions/OpenCapTable/issuer/types.ts @@ -0,0 +1,18 @@ +import type { DisclosedContract } from '../../../types/common'; +import type { OcfIssuer } from '../../../types/native'; + +/** Issuer input accepted by the high-level CreateCapTable command builder. */ +export type IssuerDataInput = Omit & { + /** Tax IDs are normalized to an empty array when omitted or null. */ + tax_ids?: OcfIssuer['tax_ids'] | null; +}; + +/** Parameters for creating the issuer and its CapTable. */ +export interface CreateIssuerParams { + /** IssuerAuthorization contract disclosed to the CreateCapTable choice. */ + issuerAuthorizationContractDetails: DisclosedContract; + /** Issuer party authorizing creation. */ + issuerParty: string; + /** Canonical OCF issuer payload. */ + issuerData: IssuerDataInput; +} diff --git a/src/functions/OpenCapTable/issuerAuthorization/authorizeIssuer.ts b/src/functions/OpenCapTable/issuerAuthorization/authorizeIssuer.ts index db65d91f..57bedc9e 100644 --- a/src/functions/OpenCapTable/issuerAuthorization/authorizeIssuer.ts +++ b/src/functions/OpenCapTable/issuerAuthorization/authorizeIssuer.ts @@ -1,24 +1,12 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; -import type { DisclosedContract } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas'; import { findCreatedEventByTemplateId } from '@fairmint/canton-node-sdk/build/src/utils/contracts/findCreatedEvent'; import { OCP_TEMPLATES, type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import factoryContractIdData from '@fairmint/open-captable-protocol-daml-js/ocp-factory-contract-id.json'; import { OcpContractError, OcpErrorCodes, OcpValidationError } from '../../../errors'; -import { submitObservedTransactionTree, type CommandObservabilityOptions } from '../../../observability'; +import { submitObservedTransactionTree } from '../../../observability'; +import type { AuthorizeIssuerParams, AuthorizeIssuerResult } from './types'; -export interface AuthorizeIssuerParams extends CommandObservabilityOptions { - issuer: string; // Party ID of the issuer to authorize - /** Override: factory contract ID (e.g. for staging). Requires factoryTemplateId. */ - factoryContractId?: string; - /** Override: factory template ID (e.g. for staging). Required when factoryContractId is set. */ - factoryTemplateId?: string; -} - -export interface AuthorizeIssuerResult extends DisclosedContract { - updateId: string; - response: SubmitAndWaitForTransactionTreeResponse; -} +export type { AuthorizeIssuerParams, AuthorizeIssuerResult } from './types'; /** * Authorize an issuer using the OCP Factory contract diff --git a/src/functions/OpenCapTable/issuerAuthorization/index.ts b/src/functions/OpenCapTable/issuerAuthorization/index.ts index 60f79215..e6223834 100644 --- a/src/functions/OpenCapTable/issuerAuthorization/index.ts +++ b/src/functions/OpenCapTable/issuerAuthorization/index.ts @@ -1,2 +1,3 @@ export * from './authorizeIssuer'; +export * from './types'; export * from './withdrawAuthorization'; diff --git a/src/functions/OpenCapTable/issuerAuthorization/types.ts b/src/functions/OpenCapTable/issuerAuthorization/types.ts new file mode 100644 index 00000000..8a99a6c6 --- /dev/null +++ b/src/functions/OpenCapTable/issuerAuthorization/types.ts @@ -0,0 +1,30 @@ +import type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; +import type { CommandObservabilityOptions } from '../../../observability'; +import type { DisclosedContract } from '../../../types/common'; + +/** Parameters for authorizing an issuer through the OCP Factory. */ +export interface AuthorizeIssuerParams extends CommandObservabilityOptions { + issuer: string; + /** Factory contract override. Must be paired with `factoryTemplateId`. */ + factoryContractId?: string; + /** Factory template override. Must be paired with `factoryContractId`. */ + factoryTemplateId?: string; +} + +/** Result of authorizing an issuer. */ +export interface AuthorizeIssuerResult extends DisclosedContract { + updateId: string; + response: SubmitAndWaitForTransactionTreeResponse; +} + +/** Parameters for withdrawing an issuer authorization. */ +export interface WithdrawAuthorizationParams extends CommandObservabilityOptions { + issuerAuthorizationContractId: string; + systemOperatorParty: string; +} + +/** Result of withdrawing an issuer authorization. */ +export interface WithdrawAuthorizationResult { + updateId: string; + response: SubmitAndWaitForTransactionTreeResponse; +} diff --git a/src/functions/OpenCapTable/issuerAuthorization/withdrawAuthorization.ts b/src/functions/OpenCapTable/issuerAuthorization/withdrawAuthorization.ts index 11cf56f1..3d8346ad 100644 --- a/src/functions/OpenCapTable/issuerAuthorization/withdrawAuthorization.ts +++ b/src/functions/OpenCapTable/issuerAuthorization/withdrawAuthorization.ts @@ -1,17 +1,9 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; import { OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; -import { submitObservedTransactionTree, type CommandObservabilityOptions } from '../../../observability'; +import { submitObservedTransactionTree } from '../../../observability'; +import type { WithdrawAuthorizationParams, WithdrawAuthorizationResult } from './types'; -export interface WithdrawAuthorizationParams extends CommandObservabilityOptions { - issuerAuthorizationContractId: string; - systemOperatorParty: string; -} - -export interface WithdrawAuthorizationResult { - updateId: string; - response: SubmitAndWaitForTransactionTreeResponse; -} +export type { WithdrawAuthorizationParams, WithdrawAuthorizationResult } from './types'; export async function withdrawAuthorization( client: LedgerJsonApiClient, diff --git a/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts b/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts index 876d8d3d..3d7c7930 100644 --- a/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts +++ b/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts @@ -1,10 +1,6 @@ import { OcpErrorCodes, OcpParseError } from '../../../errors'; -import type { - OcfStockIssuance, - PkgStockIssuanceOcfData, - PkgStockIssuanceType, - StockIssuanceType, -} from '../../../types'; +import type { PkgStockIssuanceOcfData, PkgStockIssuanceType } from '../../../types/daml'; +import type { OcfStockIssuance, StockIssuanceType } from '../../../types/native'; import { validateStockIssuanceData } from '../../../utils/entityValidators'; import { cleanComments, diff --git a/src/index.ts b/src/index.ts index f26ec0e8..09ad1a62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,48 @@ +/** + * Curated public API for the OCP Canton SDK. + * + * Generated DAML codecs and low-level conversion/read helpers intentionally stay + * behind this boundary. Public declarations are expressed only in canonical OCF + * and Canton client types so strict consumers do not inherit code-generator + * implementation details. + */ + export * from './environment'; export * from './errors'; -export * from './functions'; export * from './observability'; export * from './OcpClient'; -export * from './types'; -export * from './utils'; + +export * from './types/branded'; +export * from './types/common'; +export * from './types/native'; +export * from './types/output'; + +export type { ArchiveCapTableParams, ArchiveCapTableResult } from './functions/OpenCapTable/capTable/archiveCapTable'; +export { + CapTableBatch, + buildUpdateCapTableCommand, + type BatchItemDetails, + type BatchItemMeta, + type CapTableBatchParams, +} from './functions/OpenCapTable/capTable/CapTableBatch'; +export * from './functions/OpenCapTable/capTable/entityTypes'; +export type { + CapTableState, + CapTableWithArchiveContext, + IssuerCapTableClassification, + IssuerCapTableStatus, +} from './functions/OpenCapTable/capTable/getCapTableState'; + +export { + buildCreateIssuerCommand, + type CreateIssuerParams, + type IssuerDataInput, +} from './functions/OpenCapTable/issuer/api'; +export { authorizeIssuer } from './functions/OpenCapTable/issuerAuthorization/authorizeIssuer'; +export type { + AuthorizeIssuerParams, + AuthorizeIssuerResult, + WithdrawAuthorizationParams, + WithdrawAuthorizationResult, +} from './functions/OpenCapTable/issuerAuthorization/types'; +export { withdrawAuthorization } from './functions/OpenCapTable/issuerAuthorization/withdrawAuthorization'; diff --git a/src/types/common.ts b/src/types/common.ts index f199aebb..087f8c9b 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -22,6 +22,12 @@ export type { ClientConfig, LedgerJsonApiClient, ValidatorApiClient } from '@fai export type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; +/** A ledger command together with the contracts disclosed for its execution. */ +export interface CommandWithDisclosedContracts { + command: Command; + disclosedContracts: DisclosedContract[]; +} + // ===== Common Params Types ===== /** diff --git a/src/types/index.ts b/src/types/index.ts index 795f9c18..31b49103 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,16 +1,4 @@ export * from './branded'; export * from './common'; -export * from './daml'; export * from './native'; export * from './output'; - -import type { Command, DisclosedContract } from './common'; - -/** - * Return type for all buildCreate*Command functions. Contains a command and its associated disclosed contracts for - * Canton cross-domain interactions. - */ -export interface CommandWithDisclosedContracts { - command: Command; - disclosedContracts: DisclosedContract[]; -} diff --git a/test/batch/batchTypes.test.ts b/test/batch/batchTypes.test.ts index 6cf51ce0..76cbf065 100644 --- a/test/batch/batchTypes.test.ts +++ b/test/batch/batchTypes.test.ts @@ -1,11 +1,11 @@ import { - ENTITY_REGISTRY, isOcfCreatableEntityType, isOcfDeletableEntityType, isOcfEditableEntityType, isOcfEntityType, type OcfEntityType, } from '../../src'; +import { ENTITY_REGISTRY } from '../../src/functions/OpenCapTable/capTable/batchTypes'; const entityTypes = Object.keys(ENTITY_REGISTRY) as OcfEntityType[]; diff --git a/test/batch/generatedOperationConstruction.test.ts b/test/batch/generatedOperationConstruction.test.ts index 11cbe92f..739615c4 100644 --- a/test/batch/generatedOperationConstruction.test.ts +++ b/test/batch/generatedOperationConstruction.test.ts @@ -1,21 +1,21 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { - buildOcfCreateData, - buildOcfDeleteData, - buildOcfEditData, buildUpdateCapTableCommand, - ENTITY_REGISTRY, - ENTITY_TAG_MAP, isOcfCreatableEntityType, isOcfDeletableEntityType, isOcfEditableEntityType, - parseOcfEntityInput, - parseOcfObject, type OcfCreateArguments, type OcfDataTypeFor, type OcfEditArguments, type OcfEntityType, } from '../../src'; +import { ENTITY_REGISTRY, ENTITY_TAG_MAP } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { + buildOcfCreateData, + buildOcfDeleteData, + buildOcfEditData, +} from '../../src/functions/OpenCapTable/capTable/generatedBatchOperations'; +import { parseOcfEntityInput, parseOcfObject } from '../../src/utils/ocfZodSchemas'; import { loadFixture, stripSourceMetadata } from '../utils/productionFixtures'; function loadEntityFixture( diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index eb764097..521a014a 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -1,7 +1,6 @@ import { Canton, type ClientConfig } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes } from '../../src/errors'; -import * as openCapTableCapTable from '../../src/functions/OpenCapTable/capTable'; import { ENTITY_REGISTRY, OCF_OBJECT_TYPE_TO_ENTITY_TYPE, @@ -9,6 +8,7 @@ import { type OcfEntityType, type OcfReadableObjectType, } from '../../src/functions/OpenCapTable/capTable'; +import * as capTableState from '../../src/functions/OpenCapTable/capTable/getCapTableState'; import { authorizeIssuer, type AuthorizeIssuerResult, @@ -338,15 +338,15 @@ describe('OcpClient OpenCapTable.issuerAuthorization.authorize', () => { describe('OcpClient OpenCapTable.capTable facade', () => { const config: ClientConfig = { network: 'devnet' }; - let classifySpy: jest.SpiedFunction; - let getStateSpy: jest.SpiedFunction; + let classifySpy: jest.SpiedFunction; + let getStateSpy: jest.SpiedFunction; beforeEach(() => { - classifySpy = jest.spyOn(openCapTableCapTable, 'classifyIssuerCapTables').mockResolvedValue({ + classifySpy = jest.spyOn(capTableState, 'classifyIssuerCapTables').mockResolvedValue({ status: 'none', current: null, }); - getStateSpy = jest.spyOn(openCapTableCapTable, 'getCapTableState').mockResolvedValue(null); + getStateSpy = jest.spyOn(capTableState, 'getCapTableState').mockResolvedValue(null); }); afterEach(() => { diff --git a/test/converters/coreObjectReadValidation.test.ts b/test/converters/coreObjectReadValidation.test.ts index cc2675c3..de562a53 100644 --- a/test/converters/coreObjectReadValidation.test.ts +++ b/test/converters/coreObjectReadValidation.test.ts @@ -1,4 +1,6 @@ -import { damlDocumentDataToNative, damlStakeholderDataToNative, OcpValidationError } from '../../src'; +import { OcpValidationError } from '../../src'; +import { damlDocumentDataToNative } from '../../src/functions/OpenCapTable/document/getDocumentAsOcf'; +import { damlStakeholderDataToNative } from '../../src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf'; const minimalStakeholder = { id: 'stakeholder-read-1', diff --git a/test/declarations/damlReadDispatch.types.ts b/test/declarations/damlReadDispatch.types.ts index 2b92afef..05f6114c 100644 --- a/test/declarations/damlReadDispatch.types.ts +++ b/test/declarations/damlReadDispatch.types.ts @@ -1,13 +1,9 @@ /** Built-declaration contracts for generated DAML read dispatch. */ import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { - convertToOcf, - decodeDamlEntityData, - ENTITY_TEMPLATE_ID_MAP, - type DamlDataTypeFor, - type OcfStakeholder, -} from '../../dist'; +import { ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor } from '../../dist/functions/OpenCapTable/capTable/batchTypes'; +import { convertToOcf, decodeDamlEntityData } from '../../dist/functions/OpenCapTable/capTable/damlToOcf'; +import type { OcfStakeholder } from '../../dist/types/native'; declare const stakeholderDamlData: DamlDataTypeFor<'stakeholder'>; declare const stockClassDamlData: DamlDataTypeFor<'stockClass'>; diff --git a/test/declarations/generatedOperationConstruction.types.ts b/test/declarations/generatedOperationConstruction.types.ts index 3e89a806..433dfd74 100644 --- a/test/declarations/generatedOperationConstruction.types.ts +++ b/test/declarations/generatedOperationConstruction.types.ts @@ -1,16 +1,17 @@ /** Built-declaration contracts for exact generated DAML batch operation variants. */ import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import type { + OcfCreateDataFor, + OcfDeleteDataFor, + OcfEditDataFor, +} from '../../dist/functions/OpenCapTable/capTable/batchTypes'; import { buildOcfCreateData, buildOcfDeleteData, buildOcfEditData, - type OcfCreateDataFor, - type OcfDeleteDataFor, - type OcfEditDataFor, - type OcfIssuer, - type OcfStakeholder, -} from '../../dist'; +} from '../../dist/functions/OpenCapTable/capTable/generatedBatchOperations'; +import type { OcfIssuer, OcfStakeholder } from '../../dist/types/native'; declare const stakeholder: OcfStakeholder; declare const issuer: OcfIssuer; diff --git a/test/declarations/normalization.types.ts b/test/declarations/normalization.types.ts index 7186140a..20679b1c 100644 --- a/test/declarations/normalization.types.ts +++ b/test/declarations/normalization.types.ts @@ -1,12 +1,12 @@ /** Compile-time contracts for canonicalization helpers in the built SDK declarations. */ +import type { OcfPlanSecurityIssuance } from '../../dist/types/native'; import { deepNormalizeNumericStrings, normalizeEntityType, normalizeObjectType, normalizeOcfData, - type OcfPlanSecurityIssuance, -} from '../../dist'; +} from '../../dist/utils/planSecurityAliases'; const normalizedNumericString: string = deepNormalizeNumericStrings('1.00' as const); void normalizedNumericString; diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index e2a3363d..7381bb95 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -1,9 +1,16 @@ /** Compile-time smoke tests for declarations exported by the built SDK. */ import { - convertToDaml, - type CapTableBatch, + authorizeIssuer, + buildCreateIssuerCommand, + CapTableBatch, + OcpClient, + OcpValidationError, + withdrawAuthorization, + type CapTableBatchExecuteResult, type CapTableBatchOperations, + type CreateIssuerParams, + type OcfContractId, type OcfCreateOperation, type OcfEntityDataMap, type OcfEntityType, @@ -13,15 +20,23 @@ import { type OcfStakeholder, type OcfStockAcceptance, type OcfStockClass, - type OcfVestingEvent, type OcfVestingStart, - type OcfWarrantAcceptance, } from '../../dist'; type Assert = T; type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type RemovedRootValue = Extract< + keyof typeof import('../../dist'), + | 'convertToDaml' + | 'convertToOcf' + | 'decodeDamlEntityData' + | 'ENTITY_REGISTRY' + | 'ENTITY_TAG_MAP' + | 'getIssuerAsOcf' + | 'getStakeholderAsOcf' +>; // This file is linted before `dist` exists in a clean checkout, so its declaration-only imports appear as error types. -// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + type IntendedCanonicalOcfObject = OcfEntityDataMap[OcfEntityType] | OcfFinancing; type LegacyPlanSecurityObjectType = | 'TX_PLAN_SECURITY_ACCEPTANCE' @@ -36,9 +51,35 @@ const publishedOcfObjectIsExact: Assert, never> > = true; +const generatedAndLegacyValuesAreNotRootExports: Assert> = true; void publishedOcfObjectIsExact; void publishedOcfObjectExcludesLegacyPlanSecurity; +void generatedAndLegacyValuesAreNotRootExports; +void authorizeIssuer; +void buildCreateIssuerCommand; +void CapTableBatch; +void OcpClient; +void OcpValidationError; +void withdrawAuthorization; + +declare const createIssuerParams: CreateIssuerParams; +buildCreateIssuerCommand(createIssuerParams); + +// @ts-expect-error generated DAML wire unions are intentionally not root exports +type RemovedGeneratedWireType = import('../../dist').OcfCreateData; +declare const removedGeneratedWireType: RemovedGeneratedWireType; +void removedGeneratedWireType; + +declare const executeResult: CapTableBatchExecuteResult; +const returnedContractIds: readonly OcfContractId[] = executeResult.editedCids; +const issuerContractId: OcfContractId = { tag: 'CidIssuer', value: 'issuer-cid' }; +void returnedContractIds; +void issuerContractId; + +// @ts-expect-error built declarations exclude legacy PlanSecurity result tags +const legacyContractId: OcfContractId = { tag: 'CidPlanSecurityIssuance', value: 'legacy-cid' }; +void legacyContractId; function verifyPublishedBatchApi( batch: CapTableBatch, @@ -46,9 +87,7 @@ function verifyPublishedBatchApi( stockClass: OcfStockClass, issuer: OcfIssuer, stockAcceptance: OcfStockAcceptance, - warrantAcceptance: OcfWarrantAcceptance, - vestingStart: OcfVestingStart, - vestingEvent: OcfVestingEvent + vestingStart: OcfVestingStart ): void { batch.create('stakeholder', stakeholder); batch.create('stockClass', stockClass); @@ -72,21 +111,12 @@ function verifyPublishedBatchApi( // @ts-expect-error a union-valued kind cannot bypass edit payload correlation batch.edit(widenedKind, stakeholder); - // @ts-expect-error a union-valued kind cannot bypass converter payload correlation - convertToDaml(widenedKind, stakeholder); - // @ts-expect-error published types preserve stock vs warrant identity even with identical fields batch.create('warrantAcceptance', stockAcceptance); // @ts-expect-error published types preserve vesting start vs vesting event identity batch.create('vestingEvent', vestingStart); - // @ts-expect-error converter declarations cannot reinterpret a warrant acceptance as stock - convertToDaml('stockAcceptance', warrantAcceptance); - - // @ts-expect-error converter declarations cannot reinterpret a vesting event as vesting start - convertToDaml('vestingStart', vestingEvent); - // @ts-expect-error published entity declarations require object_type const missingObjectType: OcfStockAcceptance = { id: 'acceptance-1', diff --git a/test/integration/utils/setupTestData.ts b/test/integration/utils/setupTestData.ts index 9ffe1873..aa1e4529 100644 --- a/test/integration/utils/setupTestData.ts +++ b/test/integration/utils/setupTestData.ts @@ -1007,7 +1007,7 @@ export function createTestConvertibleIssuanceData( } /** Helper to extract a contract ID from a createdCids array by type tag. */ -function extractCreatedCid(createdCids: Array>, tagPrefix: string): string { +function extractCreatedCid(createdCids: ReadonlyArray>, tagPrefix: string): string { for (const cid of createdCids) { const { tag, value: taggedValue } = cid; if (typeof tag === 'string' && tag.startsWith(tagPrefix) && typeof taggedValue === 'string') { diff --git a/test/publicApi/rootExports.test.ts b/test/publicApi/rootExports.test.ts new file mode 100644 index 00000000..8c6b76a9 --- /dev/null +++ b/test/publicApi/rootExports.test.ts @@ -0,0 +1,58 @@ +import * as sdk from '../../src'; + +describe('package root exports', () => { + it('exposes only the curated high-level runtime surface', () => { + expect(Object.keys(sdk).sort()).toEqual([ + 'CUSTOM_PRESET', + 'CapTableBatch', + 'DEVNET_PRESET', + 'ENVIRONMENT_PRESETS', + 'LOCALNET_PRESET', + 'MAINNET_PRESET', + 'OCF_OBJECT_TYPE_TO_ENTITY_TYPE', + 'OcpClient', + 'OcpContextManager', + 'OcpContractError', + 'OcpError', + 'OcpErrorCodes', + 'OcpNetworkError', + 'OcpParseError', + 'OcpValidationError', + 'SCRATCHNET_PRESET', + 'TESTNET_PRESET', + 'applyCommandContext', + 'authorizeIssuer', + 'buildCreateIssuerCommand', + 'buildUpdateCapTableCommand', + 'createSharedSecretTokenGenerator', + 'detectEnvironment', + 'isContractId', + 'isOcfCreatableEntityType', + 'isOcfDeletableEntityType', + 'isOcfEditableEntityType', + 'isOcfEntityType', + 'isOcfId', + 'isOcfReadableObjectType', + 'isPartyId', + 'isSecurityId', + 'loadEnvironmentConfigFromEnv', + 'mapOcfObjectTypeToEntityType', + 'mergeCommandContext', + 'resolveEnvironmentConfig', + 'submitObservedTransactionTree', + 'toCantonConfig', + 'toCantonNetwork', + 'toContractId', + 'toOcfId', + 'toPartyId', + 'toResolvedCantonConfig', + 'toSecurityId', + 'unsafeToContractId', + 'unsafeToOcfId', + 'unsafeToPartyId', + 'unsafeToSecurityId', + 'validateConfig', + 'withdrawAuthorization', + ]); + }); +}); diff --git a/test/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index ed172fea..95b266d1 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -8,8 +8,9 @@ import { type CapTableBatch, + type CapTableBatchExecuteResult, type CapTableBatchOperations, - convertToDaml, + type OcfContractId, type OcfCreateOperation, type OcfEntityDataMap, type OcfEntityType, @@ -19,9 +20,7 @@ import { type OcfStakeholder, type OcfStockAcceptance, type OcfStockClass, - type OcfVestingEvent, type OcfVestingStart, - type OcfWarrantAcceptance, } from '../../src'; type Assert = T; @@ -44,15 +43,23 @@ const publicOcfObjectExcludesLegacyPlanSecurity: Assert< void publicOcfObjectIsExact; void publicOcfObjectExcludesLegacyPlanSecurity; +declare const executeResult: CapTableBatchExecuteResult; +const returnedContractIds: readonly OcfContractId[] = executeResult.createdCids; +const stakeholderContractId: OcfContractId = { tag: 'CidStakeholder', value: 'stakeholder-cid' }; +void returnedContractIds; +void stakeholderContractId; + +// @ts-expect-error batch results expose only canonical entity contract-id tags +const legacyContractId: OcfContractId = { tag: 'CidPlanSecurityIssuance', value: 'legacy-cid' }; +void legacyContractId; + function verifyCapTableBatchContract( batch: CapTableBatch, stakeholder: OcfStakeholder, stockClass: OcfStockClass, issuer: OcfIssuer, stockAcceptance: OcfStockAcceptance, - warrantAcceptance: OcfWarrantAcceptance, - vestingStart: OcfVestingStart, - vestingEvent: OcfVestingEvent + vestingStart: OcfVestingStart ): void { batch.create('stakeholder', stakeholder); batch.create('stockClass', stockClass); @@ -79,21 +86,12 @@ function verifyCapTableBatchContract( // @ts-expect-error explicit union type arguments cannot bypass kind/payload correlation batch.edit(widenedKind, stakeholder); - // @ts-expect-error the converter uses the same kind/payload correlation as the batch API - convertToDaml(widenedKind, stakeholder); - // @ts-expect-error identical payload fields cannot erase stock vs warrant identity batch.create('warrantAcceptance', stockAcceptance); // @ts-expect-error the discriminator also separates vesting start from vesting event batch.create('vestingEvent', vestingStart); - // @ts-expect-error converter dispatch cannot reinterpret a warrant acceptance as stock - convertToDaml('stockAcceptance', warrantAcceptance); - - // @ts-expect-error converter dispatch cannot reinterpret a vesting event as vesting start - convertToDaml('vestingStart', vestingEvent); - // @ts-expect-error every top-level OCF object requires its canonical discriminator const missingObjectType: OcfStockAcceptance = { id: 'acceptance-1', diff --git a/test/types/damlReadDispatch.types.ts b/test/types/damlReadDispatch.types.ts index 2b24a664..99391305 100644 --- a/test/types/damlReadDispatch.types.ts +++ b/test/types/damlReadDispatch.types.ts @@ -6,8 +6,8 @@ import { decodeDamlEntityData, ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor, - type OcfStakeholder, -} from '../../src'; +} from '../../src/functions/OpenCapTable/capTable'; +import type { OcfStakeholder } from '../../src/types/native'; declare const stakeholderDamlData: DamlDataTypeFor<'stakeholder'>; declare const stockClassDamlData: DamlDataTypeFor<'stockClass'>; diff --git a/test/types/generatedOperationConstruction.types.ts b/test/types/generatedOperationConstruction.types.ts index 2b22d458..314a2ed8 100644 --- a/test/types/generatedOperationConstruction.types.ts +++ b/test/types/generatedOperationConstruction.types.ts @@ -1,20 +1,20 @@ /** Compile-time contracts for cast-free generated DAML operation builders. */ import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import type { + OcfCreateData, + OcfCreateDataFor, + OcfDeleteData, + OcfDeleteDataFor, + OcfEditData, + OcfEditDataFor, +} from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { buildOcfCreateData, buildOcfDeleteData, buildOcfEditData, - type OcfCreateData, - type OcfCreateDataFor, - type OcfDeleteData, - type OcfDeleteDataFor, - type OcfEditData, - type OcfEditDataFor, - type OcfIssuer, - type OcfStakeholder, - type OcfStockClass, -} from '../../src'; +} from '../../src/functions/OpenCapTable/capTable/generatedBatchOperations'; +import type { OcfIssuer, OcfStakeholder, OcfStockClass } from '../../src/types/native'; function verifyGeneratedOperationBuilders( stakeholder: OcfStakeholder, diff --git a/test/types/normalization.types.ts b/test/types/normalization.types.ts index d97ffaae..fd9e6a89 100644 --- a/test/types/normalization.types.ts +++ b/test/types/normalization.types.ts @@ -1,12 +1,12 @@ /** Compile-time contracts for canonicalization helpers exported from source. */ +import type { OcfPlanSecurityIssuance } from '../../src/types/native'; import { deepNormalizeNumericStrings, normalizeEntityType, normalizeObjectType, normalizeOcfData, - type OcfPlanSecurityIssuance, -} from '../../src'; +} from '../../src/utils/planSecurityAliases'; const normalizedNumericString: string = deepNormalizeNumericStrings('1.00' as const); void normalizedNumericString; From af6485167c60920b561105086842bfe86bce013c Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 23:49:54 -0400 Subject: [PATCH 10/47] Fix canonical stock plan quickstart fixtures --- .../OpenCapTable/capTable/batchTypes.ts | 3 ++- test/batch/damlToOcfDispatcher.test.ts | 22 +++++++++++++++++++ ...roductionDataRoundtrip.integration.test.ts | 9 ++++++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index 909c2a10..a7d70913 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -438,7 +438,8 @@ export const ENTITY_REGISTRY = { stockPlan: { objectType: 'STOCK_PLAN', templateId: Fairmint.OpenCapTable.OCF.StockPlan.StockPlan.templateId, - dataField: 'stock_plan_data', + dataField: 'plan_data', + dataFieldFallbacks: ['stock_plan_data'], capTableField: 'stock_plans', operations: mutableEntityOperations('StockPlan'), }, diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index b7ce2cb1..c61091b7 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -284,6 +284,28 @@ describe('damlToOcf dispatcher', () => { expect(result).toEqual({ id: 'acc-1', date: '2025-01-01T00:00:00Z', security_id: 'sec-1' }); }); + it('extracts stockPlan data from the canonical plan_data key', () => { + const planData = { + id: 'plan-1', + plan_name: '2025 Equity Plan', + initial_shares_reserved: '1000', + stock_class_ids: ['class-1'], + }; + + expect(extractEntityData('stockPlan', { plan_data: planData })).toEqual(planData); + }); + + it('extracts stockPlan data from the legacy stock_plan_data key', () => { + const planData = { + id: 'plan-legacy-1', + plan_name: 'Legacy Equity Plan', + initial_shares_reserved: '1000', + stock_class_ids: ['class-1'], + }; + + expect(extractEntityData('stockPlan', { stock_plan_data: planData })).toEqual(planData); + }); + it('extracts stakeholderRelationshipChangeEvent data from canonical event_data key', () => { const createArgument = { event_data: { diff --git a/test/integration/production/productionDataRoundtrip.integration.test.ts b/test/integration/production/productionDataRoundtrip.integration.test.ts index 2a77c568..402396ae 100644 --- a/test/integration/production/productionDataRoundtrip.integration.test.ts +++ b/test/integration/production/productionDataRoundtrip.integration.test.ts @@ -22,6 +22,7 @@ import { ocfCompare, stripInternalFields, } from '../../../src/utils/ocfComparison'; +import { parseOcfEntityInput } from '../../../src/utils/ocfZodSchemas'; import { normalizeOcfData } from '../../../src/utils/planSecurityAliases'; import { validateOcfObject } from '../../utils/ocfSchemaValidator'; import { loadProductionFixture, loadSyntheticFixture, stripSourceMetadata } from '../../utils/productionFixtures'; @@ -1973,10 +1974,12 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { }); const fixture = loadSyntheticFixture>('stakeholderRelationshipChangeEvent'); - const prepared = { + const legacyPrepared = { ...prepareFixture(fixture, 'relationship-change'), stakeholder_id: stakeholderSetup.stakeholderData.id, }; + expect(legacyPrepared.object_type).toBe('TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT'); + const prepared = parseOcfEntityInput('stakeholderRelationshipChangeEvent', normalizeOcfData(legacyPrepared)); const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stakeholderSetup.newCapTableContractId, @@ -2023,10 +2026,12 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { }); const fixture = loadSyntheticFixture>('stakeholderStatusChangeEvent'); - const prepared = { + const legacyPrepared = { ...prepareFixture(fixture, 'status-change'), stakeholder_id: stakeholderSetup.stakeholderData.id, }; + expect(legacyPrepared.object_type).toBe('TX_STAKEHOLDER_STATUS_CHANGE_EVENT'); + const prepared = parseOcfEntityInput('stakeholderStatusChangeEvent', normalizeOcfData(legacyPrepared)); const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stakeholderSetup.newCapTableContractId, From 3b3cde6f9fa200806999c36373cb1e1f115d4f58 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 08:53:04 -0400 Subject: [PATCH 11/47] Enforce canonical OCF ingestion --- .../OpenCapTable/capTable/batchTypes.ts | 5 +- src/utils/cantonOcfExtractor.ts | 16 +- src/utils/ocfZodSchemas.ts | 54 ++--- src/utils/planSecurityAliases.ts | 189 +----------------- src/utils/replicationHelpers.ts | 6 +- test/batch/CapTableBatch.test.ts | 36 ++-- test/batch/damlToOcfDispatcher.test.ts | 12 +- test/batch/remainingOcfTypes.test.ts | 12 +- test/declarations/normalization.types.ts | 14 +- .../stakeholderRelationshipChangeEvent.json | 4 +- .../stakeholderStatusChangeEvent.json | 4 +- .../synthetic/stockConsolidation.json | 2 +- ...roductionDataRoundtrip.integration.test.ts | 32 +-- test/types/normalization.types.ts | 12 +- test/utils/ocfZodSchemas.test.ts | 95 ++++----- test/utils/planSecurityAliases.test.ts | 88 -------- test/utils/replicationHelpers.test.ts | 16 +- test/utils/transactionSorting.test.ts | 4 +- 18 files changed, 138 insertions(+), 463 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index a7d70913..9efbda87 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -214,7 +214,7 @@ type OcfEntityRegistry = { /** * Single source of truth for entity-level metadata. * - * Legacy PlanSecurity objects normalize to the canonical EquityCompensation family + * Schema-supported PlanSecurity objects normalize to the canonical EquityCompensation family * before reaching typed batch operations. */ export const ENTITY_REGISTRY = { @@ -439,7 +439,6 @@ export const ENTITY_REGISTRY = { objectType: 'STOCK_PLAN', templateId: Fairmint.OpenCapTable.OCF.StockPlan.StockPlan.templateId, dataField: 'plan_data', - dataFieldFallbacks: ['stock_plan_data'], capTableField: 'stock_plans', operations: mutableEntityOperations('StockPlan'), }, @@ -695,7 +694,7 @@ export function isOcfDeletableEntityType(entityType: string): entityType is OcfD /** * Canonical OCF `object_type` to SDK entity reader mapping. * - * Legacy PlanSecurity aliases normalize to EquityCompensation before reaching this map, + * Schema-supported PlanSecurity aliases normalize to EquityCompensation before reaching this map, * so it exposes only canonical ledger object types with first-class read facades. */ export const OCF_OBJECT_TYPE_TO_ENTITY_TYPE = { diff --git a/src/utils/cantonOcfExtractor.ts b/src/utils/cantonOcfExtractor.ts index e58ed514..7f6f9d50 100644 --- a/src/utils/cantonOcfExtractor.ts +++ b/src/utils/cantonOcfExtractor.ts @@ -87,7 +87,7 @@ export function txWeight(tx: Record): number { // Creations first case 'TX_STOCK_ISSUANCE': case 'TX_EQUITY_COMPENSATION_ISSUANCE': - case 'TX_PLAN_SECURITY_ISSUANCE': // legacy alias + case 'TX_PLAN_SECURITY_ISSUANCE': // schema-supported OCF alias case 'TX_WARRANT_ISSUANCE': case 'TX_CONVERTIBLE_ISSUANCE': return 10; @@ -96,7 +96,7 @@ export function txWeight(tx: Record): number { case 'TX_STOCK_ACCEPTANCE': case 'TX_WARRANT_ACCEPTANCE': case 'TX_EQUITY_COMPENSATION_ACCEPTANCE': - case 'TX_PLAN_SECURITY_ACCEPTANCE': // legacy alias + case 'TX_PLAN_SECURITY_ACCEPTANCE': // schema-supported OCF alias return 11; // Vesting events @@ -116,7 +116,7 @@ export function txWeight(tx: Record): number { case 'TX_WARRANT_RETRACTION': case 'TX_CONVERTIBLE_RETRACTION': case 'TX_EQUITY_COMPENSATION_RETRACTION': - case 'TX_PLAN_SECURITY_RETRACTION': // legacy alias + case 'TX_PLAN_SECURITY_RETRACTION': // schema-supported OCF alias return 16; // Consolidation after retractions, before transfers @@ -136,7 +136,7 @@ export function txWeight(tx: Record): number { case 'TX_WARRANT_TRANSFER': case 'TX_CONVERTIBLE_TRANSFER': case 'TX_EQUITY_COMPENSATION_TRANSFER': - case 'TX_PLAN_SECURITY_TRANSFER': // legacy alias + case 'TX_PLAN_SECURITY_TRANSFER': // schema-supported OCF alias return 20; // Convertible acceptance requires preceding transfer @@ -145,12 +145,12 @@ export function txWeight(tx: Record): number { // Releases before exercises case 'TX_EQUITY_COMPENSATION_RELEASE': - case 'TX_PLAN_SECURITY_RELEASE': // legacy alias + case 'TX_PLAN_SECURITY_RELEASE': // schema-supported OCF alias return 25; // Exercises that may mint resulting stock case 'TX_EQUITY_COMPENSATION_EXERCISE': - case 'TX_PLAN_SECURITY_EXERCISE': // legacy alias + case 'TX_PLAN_SECURITY_EXERCISE': // schema-supported OCF alias case 'TX_WARRANT_EXERCISE': return 30; @@ -163,7 +163,7 @@ export function txWeight(tx: Record): number { case 'TX_STOCK_REPURCHASE': case 'TX_STOCK_CANCELLATION': case 'TX_EQUITY_COMPENSATION_CANCELLATION': - case 'TX_PLAN_SECURITY_CANCELLATION': // legacy alias + case 'TX_PLAN_SECURITY_CANCELLATION': // schema-supported OCF alias case 'TX_WARRANT_CANCELLATION': case 'TX_CONVERTIBLE_CANCELLATION': return 40; @@ -171,8 +171,6 @@ export function txWeight(tx: Record): number { // Stakeholder events - process after transactions that might create/modify stakes case 'CE_STAKEHOLDER_RELATIONSHIP': case 'CE_STAKEHOLDER_STATUS': - case 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT': // legacy alias - case 'TX_STAKEHOLDER_STATUS_CHANGE_EVENT': // legacy alias return 45; // Unknown types at the end diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index b224986d..37b413be 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -97,14 +97,6 @@ export const OCF_OBJECT_SCHEMA_PATHS = { export type OcfSchemaObjectType = keyof typeof OCF_OBJECT_SCHEMA_PATHS; -/** - * Legacy object_type aliases accepted as input and normalized to canonical schema keys. - */ -const LEGACY_OBJECT_TYPE_ALIASES: Partial> = { - TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT: 'CE_STAKEHOLDER_RELATIONSHIP', - TX_STAKEHOLDER_STATUS_CHANGE_EVENT: 'CE_STAKEHOLDER_STATUS', -}; - const OBJECTS_DIR_RELATIVE_PATH = 'objects'; const SCHEMA_FILE_SUFFIX = '.schema.json'; const PACKAGED_SCHEMA_DIR_RELATIVE_PATH = '../ocf-schema'; @@ -208,11 +200,6 @@ function resolveSchemaObjectType(objectType: string): OcfSchemaObjectType { return objectType as OcfSchemaObjectType; } - const alias = LEGACY_OBJECT_TYPE_ALIASES[objectType]; - if (alias) { - return alias; - } - throw new OcpValidationError('object_type', `Unsupported OCF object_type: ${objectType}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, expectedType: Object.keys(OCF_OBJECT_SCHEMA_PATHS).join(' | '), @@ -346,10 +333,22 @@ function isParsedEntityType( return value.object_type === expectedObjectType; } +function parseWithOcfSchema(input: Record, objectType: string): Record { + const schema = getOcfSchema(objectType); + try { + return schema.parse(input); + } catch (error) { + if (error instanceof ZodError) { + throw convertZodErrorToValidationError(error, 'ocfObject'); + } + throw error; + } +} + /** * Parse and validate an arbitrary OCF JSON object. * - * Deprecated/legacy aliases are normalized to canonical latest forms prior to strict validation. + * The declared source shape is validated before schema-supported aliases are normalized to the SDK's canonical forms. */ export function parseOcfObject(input: unknown): Record { if (!isRecord(input)) { @@ -360,9 +359,20 @@ export function parseOcfObject(input: unknown): Record { }); } + const declaredObjectType = input.object_type; + if (typeof declaredObjectType !== 'string' || declaredObjectType.length === 0) { + throw new OcpValidationError('object_type', 'Required field is missing or invalid', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'string', + receivedValue: declaredObjectType, + }); + } + + const source = parseWithOcfSchema(input, declaredObjectType); + let normalized: Record; try { - normalized = normalizeOcfData(input); + normalized = normalizeOcfData(source); } catch (error) { if (error instanceof OcpValidationError) { throw error; @@ -370,7 +380,7 @@ export function parseOcfObject(input: unknown): Record { const message = error instanceof Error ? error.message : 'Failed to normalize OCF data'; throw new OcpValidationError('ocfObject', message, { code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: input, + receivedValue: source, }); } const objectType = normalized.object_type; @@ -382,22 +392,14 @@ export function parseOcfObject(input: unknown): Record { }); } - const schema = getOcfSchema(objectType); - try { - return schema.parse(normalized); - } catch (error) { - if (error instanceof ZodError) { - throw convertZodErrorToValidationError(error, 'ocfObject'); - } - throw error; - } + return parseWithOcfSchema(normalized, objectType); } /** * Parse and validate OCF input for a specific SDK entity type. * * Typed SDK inputs must provide the exact canonical object_type for the entity. - * Legacy aliases remain supported only by the raw {@link parseOcfObject} ingestion boundary. + * Schema-supported aliases remain available only through the raw {@link parseOcfObject} ingestion boundary. */ export function parseOcfEntityInput(entityType: T, input: unknown): OcfDataTypeFor { if (!isRecord(input)) { diff --git a/src/utils/planSecurityAliases.ts b/src/utils/planSecurityAliases.ts index 83da6947..309fcad5 100644 --- a/src/utils/planSecurityAliases.ts +++ b/src/utils/planSecurityAliases.ts @@ -1,4 +1,4 @@ -import type { CompensationType, StakeholderRelationshipType } from '../types/native'; +import type { CompensationType } from '../types/native'; import { normalizeNumericString } from './typeConversions'; /** @@ -6,7 +6,7 @@ import { normalizeNumericString } from './typeConversions'; * * OCF defines both "PlanSecurity" and "EquityCompensation" transaction types that are * semantically equivalent. This module is the raw-ingestion compatibility boundary: - * legacy PlanSecurity JSON is normalized to canonical EquityCompensation objects + * schema-valid PlanSecurity JSON is normalized to canonical EquityCompensation objects * before it reaches the strongly typed SDK APIs. * * Reference: The OCF standard includes both TX_PLAN_SECURITY_* and TX_EQUITY_COMPENSATION_* @@ -39,24 +39,11 @@ export const PLAN_SECURITY_OBJECT_TYPE_MAP = { TX_PLAN_SECURITY_TRANSFER: 'TX_EQUITY_COMPENSATION_TRANSFER', } as const; -/** - * Legacy object_type aliases from older OCF event naming. - * - * Canonical OCF v2 names: - * - CE_STAKEHOLDER_RELATIONSHIP - * - CE_STAKEHOLDER_STATUS - */ -export const LEGACY_OBJECT_TYPE_MAP = { - TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT: 'CE_STAKEHOLDER_RELATIONSHIP', - TX_STAKEHOLDER_STATUS_CHANGE_EVENT: 'CE_STAKEHOLDER_STATUS', -} as const; - /** PlanSecurity entity type string union */ export type PlanSecurityEntityType = keyof typeof PLAN_SECURITY_TO_EQUITY_COMPENSATION_MAP; /** PlanSecurity object_type string union */ export type PlanSecurityObjectType = keyof typeof PLAN_SECURITY_OBJECT_TYPE_MAP; -export type LegacyObjectType = keyof typeof LEGACY_OBJECT_TYPE_MAP; /** Canonical entity kind produced by {@link normalizeEntityType}. */ export type NormalizedEntityType = T extends PlanSecurityEntityType @@ -66,9 +53,7 @@ export type NormalizedEntityType = T extends PlanSecurityEntit /** Canonical OCF discriminator produced by {@link normalizeObjectType}. */ export type NormalizedObjectType = T extends PlanSecurityObjectType ? (typeof PLAN_SECURITY_OBJECT_TYPE_MAP)[T] - : T extends LegacyObjectType - ? (typeof LEGACY_OBJECT_TYPE_MAP)[T] - : T; + : T; /** * Check if an entity type is a PlanSecurity alias. @@ -90,13 +75,6 @@ export function isPlanSecurityObjectType(objectType: string): objectType is Plan return Object.prototype.hasOwnProperty.call(PLAN_SECURITY_OBJECT_TYPE_MAP, objectType); } -/** - * Check if an object_type is a legacy alias. - */ -export function isLegacyObjectType(objectType: string): objectType is LegacyObjectType { - return Object.prototype.hasOwnProperty.call(LEGACY_OBJECT_TYPE_MAP, objectType); -} - /** * Normalize a PlanSecurity entity type to its EquityCompensation equivalent. * @@ -136,9 +114,6 @@ export function normalizeObjectType(objectType: T): Normalized if (isPlanSecurityObjectType(objectType)) { return PLAN_SECURITY_OBJECT_TYPE_MAP[objectType] as NormalizedObjectType; } - if (isLegacyObjectType(objectType)) { - return LEGACY_OBJECT_TYPE_MAP[objectType] as NormalizedObjectType; - } return objectType as NormalizedObjectType; } @@ -299,159 +274,6 @@ function normalizePlanSecurityType(data: Record): Record = new Set([ - 'EMPLOYEE', - 'ADVISOR', - 'INVESTOR', - 'FOUNDER', - 'BOARD_MEMBER', - 'OFFICER', - 'OTHER', -]); - -function isStakeholderRelationshipType(value: string): value is StakeholderRelationshipType { - return VALID_STAKEHOLDER_RELATIONSHIPS.has(value as StakeholderRelationshipType); -} - -/** - * Canonicalize stakeholder relationship change events to latest OCF format. - * - * Latest schema fields: - * - object_type: CE_STAKEHOLDER_RELATIONSHIP - * - relationship_started?: StakeholderRelationship - * - relationship_ended?: StakeholderRelationship - * - * Legacy compatibility: - * - object_type: TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT - * - new_relationships: StakeholderRelationship[] - */ -function normalizeStakeholderRelationshipChangeEvent(data: Record): Record { - const normalizedObjectType = normalizeObjectType(typeof data.object_type === 'string' ? data.object_type : ''); - const isRelationshipEvent = normalizedObjectType === 'CE_STAKEHOLDER_RELATIONSHIP'; - if (!isRelationshipEvent) return data; - - const result: Record = { - ...data, - object_type: 'CE_STAKEHOLDER_RELATIONSHIP', - }; - - const legacyRelationships = result.new_relationships; - if (legacyRelationships !== undefined) { - if (!Array.isArray(legacyRelationships)) { - throw new Error(`Invalid new_relationships: expected array, got ${typeof legacyRelationships}`); - } - - const normalizedRelationships = legacyRelationships.map((relationship) => { - if (typeof relationship !== 'string') { - throw new Error(`Invalid new_relationships entry: expected string, got ${typeof relationship}`); - } - const trimmed = relationship.trim().toUpperCase(); - if (!trimmed) { - throw new Error('Invalid new_relationships entry: empty string'); - } - if (!isStakeholderRelationshipType(trimmed)) { - throw new Error(`Invalid new_relationships entry: unknown relationship "${relationship}"`); - } - return trimmed; - }); - - if ( - normalizedRelationships.length > 1 && - result.relationship_started === undefined && - result.relationship_ended === undefined - ) { - throw new Error( - 'Invalid stakeholder relationship change event: legacy new_relationships with multiple entries is ambiguous; provide canonical relationship_started/relationship_ended fields' - ); - } - - if ( - normalizedRelationships.length === 1 && - result.relationship_started === undefined && - result.relationship_ended === undefined - ) { - result.relationship_started = normalizedRelationships[0]; - } - - delete result.new_relationships; - } - - const relationshipStarted = result.relationship_started; - const relationshipEnded = result.relationship_ended; - if (relationshipStarted === undefined && relationshipEnded === undefined) { - throw new Error( - 'Invalid stakeholder relationship change event: one of relationship_started or relationship_ended is required' - ); - } - if (relationshipStarted !== undefined && typeof relationshipStarted !== 'string') { - throw new Error(`Invalid relationship_started: expected string, got ${typeof relationshipStarted}`); - } - if (relationshipEnded !== undefined && typeof relationshipEnded !== 'string') { - throw new Error(`Invalid relationship_ended: expected string, got ${typeof relationshipEnded}`); - } - if (typeof relationshipStarted === 'string') { - const normalizedRelationshipStarted = relationshipStarted.trim().toUpperCase(); - if (!isStakeholderRelationshipType(normalizedRelationshipStarted)) { - throw new Error(`Invalid relationship_started: unknown relationship "${relationshipStarted}"`); - } - result.relationship_started = normalizedRelationshipStarted; - } - if (typeof relationshipEnded === 'string') { - const normalizedRelationshipEnded = relationshipEnded.trim().toUpperCase(); - if (!isStakeholderRelationshipType(normalizedRelationshipEnded)) { - throw new Error(`Invalid relationship_ended: unknown relationship "${relationshipEnded}"`); - } - result.relationship_ended = normalizedRelationshipEnded; - } - - return result; -} - -/** - * Canonicalize stakeholder status change events to latest OCF format. - * - * Latest schema fields: - * - object_type: CE_STAKEHOLDER_STATUS - * - new_status - * - * Legacy compatibility: - * - object_type: TX_STAKEHOLDER_STATUS_CHANGE_EVENT - * - reason_text (dropped during canonicalization) - */ -function normalizeStakeholderStatusChangeEvent(data: Record): Record { - const normalizedObjectType = normalizeObjectType(typeof data.object_type === 'string' ? data.object_type : ''); - if (normalizedObjectType !== 'CE_STAKEHOLDER_STATUS') return data; - - const result: Record = { - ...data, - object_type: 'CE_STAKEHOLDER_STATUS', - }; - - if (result.comments !== undefined && !Array.isArray(result.comments)) { - throw new Error( - `normalizeStakeholderStatusChangeEvent (CE_STAKEHOLDER_STATUS): comments must be an array of strings` - ); - } - const existingComments: string[] = []; - if (Array.isArray(result.comments)) { - for (const comment of result.comments) { - if (typeof comment !== 'string') { - throw new Error( - `normalizeStakeholderStatusChangeEvent (CE_STAKEHOLDER_STATUS): comments must contain only strings, received value ${JSON.stringify(comment)} of type ${typeof comment}` - ); - } - existingComments.push(comment); - } - } - - if (typeof result.reason_text === 'string' && result.reason_text.trim().length > 0) { - result.comments = [...existingComments, result.reason_text.trim()]; - } - delete result.reason_text; - - return result; -} - /** * Normalize quantity_source field for OCF objects. * @@ -946,11 +768,6 @@ export function normalizeOcfData(data: object): Record { // Canonicalize stock reissuance optional fields exported as explicit nulls result = normalizeStockReissuanceSplitTransactionId(result); - // Canonicalize stakeholder change events after object_type alias normalization above. - // This ordering ensures TX_STAKEHOLDER_* aliases are converted before field upgrades. - result = normalizeStakeholderRelationshipChangeEvent(result); - result = normalizeStakeholderStatusChangeEvent(result); - result = normalizeVestingTermsDefaults(result); result = normalizeConversionMechanismRoundTrip(result); diff --git a/src/utils/replicationHelpers.ts b/src/utils/replicationHelpers.ts index 4c141eca..e091fb11 100644 --- a/src/utils/replicationHelpers.ts +++ b/src/utils/replicationHelpers.ts @@ -123,10 +123,6 @@ export const TRANSACTION_SUBTYPE_MAP: Record = { // Stakeholder Events (2 types) CE_STAKEHOLDER_RELATIONSHIP: 'stakeholderRelationshipChangeEvent', CE_STAKEHOLDER_STATUS: 'stakeholderStatusChangeEvent', - - // Legacy aliases kept for backward compatibility with historical exports - TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT: 'stakeholderRelationshipChangeEvent', - TX_STAKEHOLDER_STATUS_CHANGE_EVENT: 'stakeholderStatusChangeEvent', }; /** @@ -348,7 +344,7 @@ export function buildCantonOcfDataMap(manifest: OcfManifest): CantonOcfDataMap { } // Normalize TX_PLAN_SECURITY_* to TX_EQUITY_COMPENSATION_* for lookup - // Canton can return legacy plan security types that need to be mapped to equity compensation + // Canton can return schema-supported PlanSecurity types that map to EquityCompensation const normalizedObjectType = normalizeObjectType(objectType); // Check if the normalized object type is a known transaction type diff --git a/test/batch/CapTableBatch.test.ts b/test/batch/CapTableBatch.test.ts index 852ae4b0..1d043a0f 100644 --- a/test/batch/CapTableBatch.test.ts +++ b/test/batch/CapTableBatch.test.ts @@ -81,13 +81,13 @@ describe('CapTableBatch', () => { expect(batch.size).toBe(1); }); - it('should accept legacy stock class split ratio fields via canonicalization', () => { + it('should reject non-schema stock class split ratio fields', () => { const batch = new CapTableBatch({ capTableContractId: 'cap-table-123', actAs: ['party-1'], }); - const legacySplitData = { + const invalidSplitData = { object_type: 'TX_STOCK_CLASS_SPLIT', id: 'split-123', date: '2024-01-15', @@ -96,23 +96,20 @@ describe('CapTableBatch', () => { split_ratio_denominator: '1', } as unknown as OcfStockClassSplit; - expect(() => batch.create('stockClassSplit', legacySplitData)).not.toThrow(); - const { command } = batch.build(); - if (!('ExerciseCommand' in command)) throw new Error('Expected ExerciseCommand'); + const create = () => batch.create('stockClassSplit', invalidSplitData); - const choiceArg = command.ExerciseCommand.choiceArgument as { - creates: Array<{ tag: string; value: Record }>; - }; - expect(choiceArg.creates[0].value.split_ratio).toEqual({ numerator: '2', denominator: '1' }); + expect(create).toThrow(OcpValidationError); + expect(create).toThrow('split_ratio_numerator'); + expect(batch.size).toBe(0); }); - it('should accept legacy stock class conversion ratio fields via canonicalization', () => { + it('should reject non-schema stock class conversion ratio fields', () => { const batch = new CapTableBatch({ capTableContractId: 'cap-table-123', actAs: ['party-1'], }); - const legacyRatioData: OcfStockClassConversionRatioAdjustment = { + const invalidRatioData: OcfStockClassConversionRatioAdjustment = { object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', id: 'ratio-123', date: '2024-01-15', @@ -121,18 +118,11 @@ describe('CapTableBatch', () => { new_ratio_denominator: '10', }; - expect(() => batch.create('stockClassConversionRatioAdjustment', legacyRatioData)).not.toThrow(); - const { command } = batch.build(); - if (!('ExerciseCommand' in command)) throw new Error('Expected ExerciseCommand'); + const create = () => batch.create('stockClassConversionRatioAdjustment', invalidRatioData); - const choiceArg = command.ExerciseCommand.choiceArgument as { - creates: Array<{ tag: string; value: Record }>; - }; - expect(choiceArg.creates[0].value.new_ratio_conversion_mechanism).toEqual({ - conversion_price: { amount: '0', currency: 'USD' }, - ratio: { numerator: '11', denominator: '10' }, - rounding_type: 'OcfRoundingNormal', - }); + expect(create).toThrow(OcpValidationError); + expect(create).toThrow('new_ratio_numerator'); + expect(batch.size).toBe(0); }); it('should chain multiple operations', () => { @@ -825,7 +815,7 @@ describe('ENTITY_TAG_MAP', () => { }); it('should have all 48 canonical entity types', () => { - // Legacy PlanSecurity values normalize to EquityCompensation before typed batch operations. + // Schema-supported PlanSecurity values normalize to EquityCompensation before typed batch operations. // Issuer is the one edit-only entity stored as a single reference rather than a map. expect(Object.keys(ENTITY_TAG_MAP)).toHaveLength(48); }); diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index c61091b7..7a21be22 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -295,15 +295,11 @@ describe('damlToOcf dispatcher', () => { expect(extractEntityData('stockPlan', { plan_data: planData })).toEqual(planData); }); - it('extracts stockPlan data from the legacy stock_plan_data key', () => { - const planData = { - id: 'plan-legacy-1', - plan_name: 'Legacy Equity Plan', - initial_shares_reserved: '1000', - stock_class_ids: ['class-1'], - }; + it('rejects the non-contract stock_plan_data key for stockPlan', () => { + const extract = () => extractEntityData('stockPlan', { stock_plan_data: { id: 'plan-invalid-1' } }); - expect(extractEntityData('stockPlan', { stock_plan_data: planData })).toEqual(planData); + expect(extract).toThrow(OcpParseError); + expect(extract).toThrow("Expected field 'plan_data' not found in contract create argument for stockPlan"); }); it('extracts stakeholderRelationshipChangeEvent data from canonical event_data key', () => { diff --git a/test/batch/remainingOcfTypes.test.ts b/test/batch/remainingOcfTypes.test.ts index a53ef21a..75f1a5de 100644 --- a/test/batch/remainingOcfTypes.test.ts +++ b/test/batch/remainingOcfTypes.test.ts @@ -333,7 +333,7 @@ describe('Stock Plan Event Converters', () => { describe('Stakeholder Change Event Converters', () => { describe('stakeholderRelationshipChangeEvent', () => { - it('should convert legacy single-relationship stakeholder relationship change event to DAML format', () => { + it('should convert a canonical single-relationship stakeholder change event to DAML format', () => { const batch = new CapTableBatch({ capTableContractId: 'cap-table-123', actAs: ['party-1'], @@ -344,8 +344,8 @@ describe('Stakeholder Change Event Converters', () => { id: 'rce-123', date: '2024-08-01', stakeholder_id: 'sh-001', - new_relationships: ['EMPLOYEE'], - comments: ['Initial relationship assignment from legacy payload'], + relationship_started: 'EMPLOYEE', + comments: ['Initial relationship assignment'], }; batch.create('stakeholderRelationshipChangeEvent', data); @@ -396,7 +396,7 @@ describe('Stakeholder Change Event Converters', () => { expect(value.relationship_ended).toBe('OcfRelInvestor'); }); - it('should reject legacy relationship list with multiple entries', () => { + it('should reject the non-schema relationship list field', () => { const batch = new CapTableBatch({ capTableContractId: 'cap-table-123', actAs: ['party-1'], @@ -410,9 +410,7 @@ describe('Stakeholder Change Event Converters', () => { new_relationships: ['FOUNDER', 'INVESTOR'], }; - expect(() => batch.create('stakeholderRelationshipChangeEvent', data)).toThrow( - 'new_relationships with multiple entries is ambiguous' - ); + expect(() => batch.create('stakeholderRelationshipChangeEvent', data)).toThrow('new_relationships'); }); it('should have correct ENTITY_TAG_MAP entry', () => { diff --git a/test/declarations/normalization.types.ts b/test/declarations/normalization.types.ts index 7186140a..1cd66703 100644 --- a/test/declarations/normalization.types.ts +++ b/test/declarations/normalization.types.ts @@ -23,14 +23,10 @@ const normalizedObjectType = normalizeObjectType('TX_PLAN_SECURITY_ISSUANCE'); const exactObjectType: 'TX_EQUITY_COMPENSATION_ISSUANCE' = normalizedObjectType; void exactObjectType; -const normalizedLegacyEvent = normalizeObjectType('TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT'); -const exactLegacyEvent: 'CE_STAKEHOLDER_RELATIONSHIP' = normalizedLegacyEvent; -void exactLegacyEvent; - -declare const legacyIssuance: OcfPlanSecurityIssuance; -const normalizedData: Record = normalizeOcfData(legacyIssuance); +declare const planSecurityIssuance: OcfPlanSecurityIssuance; +const normalizedData: Record = normalizeOcfData(planSecurityIssuance); void normalizedData; -// @ts-expect-error built declarations must not claim normalization preserves a legacy object shape -const unsoundLegacyClaim: OcfPlanSecurityIssuance = normalizeOcfData(legacyIssuance); -void unsoundLegacyClaim; +// @ts-expect-error built declarations must not claim normalization preserves a PlanSecurity object shape +const unsoundPlanSecurityClaim: OcfPlanSecurityIssuance = normalizeOcfData(planSecurityIssuance); +void unsoundPlanSecurityClaim; diff --git a/test/fixtures/synthetic/stakeholderRelationshipChangeEvent.json b/test/fixtures/synthetic/stakeholderRelationshipChangeEvent.json index e2b17fbc..bfbf1c98 100644 --- a/test/fixtures/synthetic/stakeholderRelationshipChangeEvent.json +++ b/test/fixtures/synthetic/stakeholderRelationshipChangeEvent.json @@ -1,10 +1,10 @@ { "_source": "synthetic", "id": "test-stakeholder-rel-change-001", - "object_type": "TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT", + "object_type": "CE_STAKEHOLDER_RELATIONSHIP", "date": "2025-03-01", "stakeholder_id": "test-stakeholder-individual-001", - "new_relationships": ["ADVISOR"], + "relationship_started": "ADVISOR", "comments": [ "Transition from full-time employee to consulting role", "Option exercise window extended per separation agreement" diff --git a/test/fixtures/synthetic/stakeholderStatusChangeEvent.json b/test/fixtures/synthetic/stakeholderStatusChangeEvent.json index 81429bff..cb6529d6 100644 --- a/test/fixtures/synthetic/stakeholderStatusChangeEvent.json +++ b/test/fixtures/synthetic/stakeholderStatusChangeEvent.json @@ -1,12 +1,12 @@ { "_source": "synthetic", "id": "test-stakeholder-status-change-001", - "object_type": "TX_STAKEHOLDER_STATUS_CHANGE_EVENT", + "object_type": "CE_STAKEHOLDER_STATUS", "date": "2025-04-15", "stakeholder_id": "test-stakeholder-individual-001", "new_status": "TERMINATION_VOLUNTARY_OTHER", - "reason_text": "Voluntary resignation effective April 15, 2025", "comments": [ + "Voluntary resignation effective April 15, 2025", "Final employment date: April 15, 2025", "Unvested equity cancelled per plan terms", "90-day post-termination exercise window for vested options" diff --git a/test/fixtures/synthetic/stockConsolidation.json b/test/fixtures/synthetic/stockConsolidation.json index 91216384..e9cb7e2f 100644 --- a/test/fixtures/synthetic/stockConsolidation.json +++ b/test/fixtures/synthetic/stockConsolidation.json @@ -8,7 +8,7 @@ "test-security-consolidated-002", "test-security-consolidated-003" ], - "resulting_security_ids": ["test-security-consolidated-result-001"], + "resulting_security_id": "test-security-consolidated-result-001", "reason_text": "Consolidation of multiple stock certificates into single certificate", "comments": ["Consolidating certificates CS-001, CS-002, and CS-003", "Total shares: 150,000"] } diff --git a/test/integration/production/productionDataRoundtrip.integration.test.ts b/test/integration/production/productionDataRoundtrip.integration.test.ts index 402396ae..bd6ce3d1 100644 --- a/test/integration/production/productionDataRoundtrip.integration.test.ts +++ b/test/integration/production/productionDataRoundtrip.integration.test.ts @@ -22,8 +22,6 @@ import { ocfCompare, stripInternalFields, } from '../../../src/utils/ocfComparison'; -import { parseOcfEntityInput } from '../../../src/utils/ocfZodSchemas'; -import { normalizeOcfData } from '../../../src/utils/planSecurityAliases'; import { validateOcfObject } from '../../utils/ocfSchemaValidator'; import { loadProductionFixture, loadSyntheticFixture, stripSourceMetadata } from '../../utils/productionFixtures'; import { createIntegrationTestSuite, type IntegrationTestContext } from '../setup'; @@ -1974,12 +1972,10 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { }); const fixture = loadSyntheticFixture>('stakeholderRelationshipChangeEvent'); - const legacyPrepared = { + const prepared = { ...prepareFixture(fixture, 'relationship-change'), stakeholder_id: stakeholderSetup.stakeholderData.id, }; - expect(legacyPrepared.object_type).toBe('TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT'); - const prepared = parseOcfEntityInput('stakeholderRelationshipChangeEvent', normalizeOcfData(legacyPrepared)); const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stakeholderSetup.newCapTableContractId, @@ -1996,13 +1992,10 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { await validateOcfObject(readBack.data as unknown as Record); - const sourceWithoutId = stripInternalFields( - normalizeOcfData({ - ...prepared, - object_type: 'CE_STAKEHOLDER_RELATIONSHIP', - id: readBack.data.id, - }) - ); + const sourceWithoutId = stripInternalFields({ + ...prepared, + id: readBack.data.id, + }); compareOcfData( sourceWithoutId, readBack.data as unknown as Record, @@ -2026,12 +2019,10 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { }); const fixture = loadSyntheticFixture>('stakeholderStatusChangeEvent'); - const legacyPrepared = { + const prepared = { ...prepareFixture(fixture, 'status-change'), stakeholder_id: stakeholderSetup.stakeholderData.id, }; - expect(legacyPrepared.object_type).toBe('TX_STAKEHOLDER_STATUS_CHANGE_EVENT'); - const prepared = parseOcfEntityInput('stakeholderStatusChangeEvent', normalizeOcfData(legacyPrepared)); const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stakeholderSetup.newCapTableContractId, @@ -2048,13 +2039,10 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { await validateOcfObject(readBack.data as unknown as Record); - const sourceWithoutId = stripInternalFields( - normalizeOcfData({ - ...prepared, - object_type: 'CE_STAKEHOLDER_STATUS', - id: readBack.data.id, - }) - ); + const sourceWithoutId = stripInternalFields({ + ...prepared, + id: readBack.data.id, + }); compareOcfData( sourceWithoutId, readBack.data as unknown as Record, diff --git a/test/types/normalization.types.ts b/test/types/normalization.types.ts index d97ffaae..d8827619 100644 --- a/test/types/normalization.types.ts +++ b/test/types/normalization.types.ts @@ -27,18 +27,14 @@ const normalizedPlanSecurity = normalizeObjectType('TX_PLAN_SECURITY_ISSUANCE'); const exactPlanSecurity: 'TX_EQUITY_COMPENSATION_ISSUANCE' = normalizedPlanSecurity; void exactPlanSecurity; -const normalizedLegacyEvent = normalizeObjectType('TX_STAKEHOLDER_STATUS_CHANGE_EVENT'); -const exactLegacyEvent: 'CE_STAKEHOLDER_STATUS' = normalizedLegacyEvent; -void exactLegacyEvent; - const unchangedObjectType = normalizeObjectType('TX_STOCK_ISSUANCE'); const exactUnchangedObjectType: 'TX_STOCK_ISSUANCE' = unchangedObjectType; void exactUnchangedObjectType; -declare const legacyIssuance: OcfPlanSecurityIssuance; -const normalizedData: Record = normalizeOcfData(legacyIssuance); +declare const planSecurityIssuance: OcfPlanSecurityIssuance; +const normalizedData: Record = normalizeOcfData(planSecurityIssuance); void normalizedData; // @ts-expect-error normalization may rename the discriminator and fields, so the result cannot retain the input type -const unsoundLegacyClaim: OcfPlanSecurityIssuance = normalizeOcfData(legacyIssuance); -void unsoundLegacyClaim; +const unsoundPlanSecurityClaim: OcfPlanSecurityIssuance = normalizeOcfData(planSecurityIssuance); +void unsoundPlanSecurityClaim; diff --git a/test/utils/ocfZodSchemas.test.ts b/test/utils/ocfZodSchemas.test.ts index 0185827b..45d969f8 100644 --- a/test/utils/ocfZodSchemas.test.ts +++ b/test/utils/ocfZodSchemas.test.ts @@ -118,19 +118,16 @@ describe('ocfZodSchemas', () => { ); }); - it('raw parsing canonicalizes legacy plan security issuance + option_grant_type before validation', () => { + it('normalizes a schema-valid PlanSecurity issuance after validating its declared shape', () => { const fixture = stripSourceMetadata( loadProductionFixture>('equityCompensationIssuance', 'option-iso') ); - const legacyFixture: Record = { + const planSecurityFixture: Record = { ...fixture, object_type: 'TX_PLAN_SECURITY_ISSUANCE', - option_grant_type: 'ISO', }; - delete legacyFixture.compensation_type; - delete legacyFixture.quantity_source; - const parsed = parseOcfObject(legacyFixture); + const parsed = parseOcfObject(planSecurityFixture); const parsedRecord = toRecord(parsed); expect(parsedRecord.object_type).toBe('TX_EQUITY_COMPENSATION_ISSUANCE'); @@ -138,81 +135,69 @@ describe('ocfZodSchemas', () => { expect(parsedRecord).not.toHaveProperty('option_grant_type'); }); - it('raw parsing canonicalizes legacy plan security issuance + plan_security_type before validation', () => { + it('does not let normalization rescue a schema-invalid PlanSecurity issuance', () => { const fixture = stripSourceMetadata( loadProductionFixture>('equityCompensationIssuance', 'option-nso') ); - const legacyFixture: Record = { + const malformedFixture: Record = { ...fixture, object_type: 'TX_PLAN_SECURITY_ISSUANCE', plan_security_type: 'OPTION', }; - delete legacyFixture.compensation_type; - delete legacyFixture.option_grant_type; - delete legacyFixture.quantity_source; + delete malformedFixture.compensation_type; + delete malformedFixture.option_grant_type; - const parsed = parseOcfObject(legacyFixture); - const parsedRecord = toRecord(parsed); - - expect(parsedRecord.object_type).toBe('TX_EQUITY_COMPENSATION_ISSUANCE'); - expect(parsedRecord.compensation_type).toBe('OPTION'); - expect(parsedRecord).not.toHaveProperty('plan_security_type'); + expect(() => parseOcfObject(malformedFixture)).toThrow('plan_security_type'); }); - it('canonicalizes legacy stakeholder status change event object_type + reason_text', () => { - const fixture = stripSourceMetadata(loadSyntheticFixture>('stakeholderStatusChangeEvent')); - const legacyFixture = { - ...fixture, - object_type: 'TX_STAKEHOLDER_STATUS_CHANGE_EVENT', - reason_text: 'Legacy reason', - }; - - const parsed = parseOcfObject(legacyFixture); - const parsedRecord = toRecord(parsed); + it.each(['TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', 'TX_STAKEHOLDER_STATUS_CHANGE_EVENT'])( + 'rejects the non-schema stakeholder event name %s', + (objectType) => { + const error = captureValidationError(() => + parseOcfObject({ + object_type: objectType, + id: 'event-1', + date: '2024-01-15', + stakeholder_id: 'stakeholder-1', + }) + ); - expect(parsedRecord.object_type).toBe('CE_STAKEHOLDER_STATUS'); - expect(parsedRecord).not.toHaveProperty('reason_text'); - expect(parsedRecord.comments).toContain('Legacy reason'); - }); + expect(error.fieldPath).toBe('object_type'); + expect(error.code).toBe('UNKNOWN_ENUM_VALUE'); + expect(error.receivedValue).toBe(objectType); + } + ); - it('canonicalizes legacy stakeholder relationship event shape', () => { + it('rejects non-schema new_relationships instead of rewriting it', () => { const fixture = stripSourceMetadata( loadSyntheticFixture>('stakeholderRelationshipChangeEvent') ); - const legacyFixture = { - ...fixture, - object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', - new_relationships: ['ADVISOR'], - }; - const parsed = parseOcfObject(legacyFixture); - const parsedRecord = toRecord(parsed); - - expect(parsedRecord.object_type).toBe('CE_STAKEHOLDER_RELATIONSHIP'); - expect(parsedRecord.relationship_started).toBe('ADVISOR'); - expect(parsedRecord).not.toHaveProperty('new_relationships'); + expect(() => + parseOcfObject({ + ...fixture, + new_relationships: ['ADVISOR'], + }) + ).toThrow('new_relationships'); }); - it('rejects ambiguous legacy stakeholder relationship event shape with two relationships', () => { - const fixture = stripSourceMetadata( - loadSyntheticFixture>('stakeholderRelationshipChangeEvent') - ); - const legacyFixture = { - ...fixture, - object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', - new_relationships: ['ADVISOR', 'INVESTOR'], - }; + it('rejects non-schema reason_text instead of rewriting it', () => { + const fixture = stripSourceMetadata(loadSyntheticFixture>('stakeholderStatusChangeEvent')); - expect(() => parseOcfObject(legacyFixture)).toThrow('legacy new_relationships with multiple entries is ambiguous'); + expect(() => + parseOcfObject({ + ...fixture, + reason_text: 'Non-schema reason', + }) + ).toThrow('reason_text'); }); - it('canonicalizes stock consolidation legacy resulting_security_ids field', () => { + it('parses the canonical stock consolidation resulting_security_id field', () => { const fixture = stripSourceMetadata(loadSyntheticFixture>('stockConsolidation')); const parsed = parseOcfEntityInput('stockConsolidation', fixture); const parsedRecord = toRecord(parsed); expect(parsedRecord.resulting_security_id).toBe('test-security-consolidated-result-001'); - expect(parsedRecord).not.toHaveProperty('resulting_security_ids'); }); it('rejects entity/object_type mismatches', () => { diff --git a/test/utils/planSecurityAliases.test.ts b/test/utils/planSecurityAliases.test.ts index 28080239..8f60bc0a 100644 --- a/test/utils/planSecurityAliases.test.ts +++ b/test/utils/planSecurityAliases.test.ts @@ -3,10 +3,8 @@ */ import { - isLegacyObjectType, isPlanSecurityEntityType, isPlanSecurityObjectType, - LEGACY_OBJECT_TYPE_MAP, normalizeEntityType, normalizeObjectType, normalizeOcfData, @@ -55,19 +53,6 @@ describe('PlanSecurity alias utilities', () => { }); }); - describe('isLegacyObjectType', () => { - it('returns true for legacy stakeholder event object types', () => { - expect(isLegacyObjectType('TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT')).toBe(true); - expect(isLegacyObjectType('TX_STAKEHOLDER_STATUS_CHANGE_EVENT')).toBe(true); - }); - - it('returns false for canonical object types', () => { - expect(isLegacyObjectType('CE_STAKEHOLDER_RELATIONSHIP')).toBe(false); - expect(isLegacyObjectType('CE_STAKEHOLDER_STATUS')).toBe(false); - expect(isLegacyObjectType('TX_STOCK_ISSUANCE')).toBe(false); - }); - }); - describe('normalizeEntityType', () => { it('converts PlanSecurity entity types to EquityCompensation types', () => { expect(normalizeEntityType('planSecurityIssuance')).toBe('equityCompensationIssuance'); @@ -103,11 +88,6 @@ describe('PlanSecurity alias utilities', () => { expect(normalizeObjectType('TX_STOCK_ISSUANCE')).toBe('TX_STOCK_ISSUANCE'); expect(normalizeObjectType('STAKEHOLDER')).toBe('STAKEHOLDER'); }); - - it('converts legacy stakeholder event object types to canonical CE_* values', () => { - expect(normalizeObjectType('TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT')).toBe('CE_STAKEHOLDER_RELATIONSHIP'); - expect(normalizeObjectType('TX_STAKEHOLDER_STATUS_CHANGE_EVENT')).toBe('CE_STAKEHOLDER_STATUS'); - }); }); describe('normalizeOcfData', () => { @@ -606,67 +586,6 @@ describe('PlanSecurity alias utilities', () => { expect(() => normalizeOcfData(input)).toThrow('conflicts with compensation_type'); }); - it('canonicalizes legacy stakeholder relationship change event fields', async () => { - const input = { - object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', - id: 'event-1', - date: '2024-01-15', - stakeholder_id: 'stakeholder-1', - new_relationships: ['INVESTOR'], - }; - - const result = normalizeOcfData(input); - const resultRecord = result; - await validateOcfObject(resultRecord); - - expect(resultRecord.object_type).toBe('CE_STAKEHOLDER_RELATIONSHIP'); - expect(resultRecord.relationship_started).toBe('INVESTOR'); - expect(resultRecord).not.toHaveProperty('new_relationships'); - }); - - it('rejects stakeholder relationship change events with ambiguous legacy multi-value relationships', () => { - const input = { - object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', - id: 'event-1', - date: '2024-01-15', - stakeholder_id: 'stakeholder-1', - new_relationships: ['INVESTOR', 'FOUNDER'], - }; - - expect(() => normalizeOcfData(input)).toThrow('legacy new_relationships with multiple entries is ambiguous'); - }); - - it('rejects unknown stakeholder relationship values', () => { - const input = { - object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', - id: 'event-1', - date: '2024-01-15', - stakeholder_id: 'stakeholder-1', - new_relationships: ['UNKNOWN_RELATIONSHIP'], - }; - - expect(() => normalizeOcfData(input)).toThrow('unknown relationship'); - }); - - it('canonicalizes legacy stakeholder status change event reason_text into comments', async () => { - const input = { - object_type: 'TX_STAKEHOLDER_STATUS_CHANGE_EVENT', - id: 'event-1', - date: '2024-01-15', - stakeholder_id: 'stakeholder-1', - new_status: 'ACTIVE', - reason_text: 'legacy reason', - comments: ['existing'], - }; - - const result = normalizeOcfData(input); - await validateOcfObject(result); - - expect(result.object_type).toBe('CE_STAKEHOLDER_STATUS'); - expect(result.comments).toEqual(['existing', 'legacy reason']); - expect(result).not.toHaveProperty('reason_text'); - }); - it('canonicalizes stock consolidation resulting_security_ids to resulting_security_id', async () => { const input = { object_type: 'TX_STOCK_CONSOLIDATION', @@ -1111,13 +1030,6 @@ describe('PlanSecurity alias utilities', () => { TX_PLAN_SECURITY_TRANSFER: 'TX_EQUITY_COMPENSATION_TRANSFER', }); }); - - it('has correct legacy event object_type mappings', () => { - expect(LEGACY_OBJECT_TYPE_MAP).toEqual({ - TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT: 'CE_STAKEHOLDER_RELATIONSHIP', - TX_STAKEHOLDER_STATUS_CHANGE_EVENT: 'CE_STAKEHOLDER_STATUS', - }); - }); }); describe('vesting terms defaults', () => { diff --git a/test/utils/replicationHelpers.test.ts b/test/utils/replicationHelpers.test.ts index b89d1886..f8def8a6 100644 --- a/test/utils/replicationHelpers.test.ts +++ b/test/utils/replicationHelpers.test.ts @@ -23,8 +23,7 @@ import { validateOcfObject } from './ocfSchemaValidator'; describe('TRANSACTION_SUBTYPE_MAP', () => { it('has correct count of transaction types', () => { // 9 stock + 8 equity comp + 6 convertible + 6 warrant + 4 stock class adj + 2 stock plan + 3 vesting + 2 stakeholder - // + 2 legacy stakeholder aliases = 42 - expect(Object.keys(TRANSACTION_SUBTYPE_MAP)).toHaveLength(42); + expect(Object.keys(TRANSACTION_SUBTYPE_MAP)).toHaveLength(40); }); describe('Stock Transactions (9 types)', () => { @@ -128,12 +127,10 @@ describe('TRANSACTION_SUBTYPE_MAP', () => { }); }); - describe('Stakeholder Events (canonical + legacy aliases)', () => { + describe('Stakeholder Events', () => { const stakeholderTypes: Array<[string, OcfEntityType]> = [ ['CE_STAKEHOLDER_RELATIONSHIP', 'stakeholderRelationshipChangeEvent'], ['CE_STAKEHOLDER_STATUS', 'stakeholderStatusChangeEvent'], - ['TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', 'stakeholderRelationshipChangeEvent'], // legacy - ['TX_STAKEHOLDER_STATUS_CHANGE_EVENT', 'stakeholderStatusChangeEvent'], // legacy ]; it.each(stakeholderTypes)('maps %s to %s', (objectType, entityType) => { @@ -141,8 +138,13 @@ describe('TRANSACTION_SUBTYPE_MAP', () => { }); }); - it('does not include deprecated Plan Security types', () => { - // Plan Security types were removed after confirming no data exists in dev/prod + it('does not map non-schema stakeholder event names', () => { + expect(TRANSACTION_SUBTYPE_MAP['TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT']).toBeUndefined(); + expect(TRANSACTION_SUBTYPE_MAP['TX_STAKEHOLDER_STATUS_CHANGE_EVENT']).toBeUndefined(); + }); + + it('keeps schema-supported PlanSecurity aliases out of the canonical lookup map', () => { + // PlanSecurity values normalize to EquityCompensation before this canonical map is consulted. expect(TRANSACTION_SUBTYPE_MAP['TX_PLAN_SECURITY_ISSUANCE']).toBeUndefined(); expect(TRANSACTION_SUBTYPE_MAP['TX_PLAN_SECURITY_EXERCISE']).toBeUndefined(); expect(TRANSACTION_SUBTYPE_MAP['TX_PLAN_SECURITY_CANCELLATION']).toBeUndefined(); diff --git a/test/utils/transactionSorting.test.ts b/test/utils/transactionSorting.test.ts index 02a21615..59cb9879 100644 --- a/test/utils/transactionSorting.test.ts +++ b/test/utils/transactionSorting.test.ts @@ -113,12 +113,12 @@ describe('txWeight', () => { it('returns weight 45 for stakeholder events', () => { expect(txWeight({ object_type: 'CE_STAKEHOLDER_RELATIONSHIP' })).toBe(45); expect(txWeight({ object_type: 'CE_STAKEHOLDER_STATUS' })).toBe(45); - expect(txWeight({ object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT' })).toBe(45); - expect(txWeight({ object_type: 'TX_STAKEHOLDER_STATUS_CHANGE_EVENT' })).toBe(45); }); it('returns weight 50 (default) for unknown types', () => { expect(txWeight({ object_type: 'TX_UNKNOWN_TYPE' })).toBe(50); + expect(txWeight({ object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT' })).toBe(50); + expect(txWeight({ object_type: 'TX_STAKEHOLDER_STATUS_CHANGE_EVENT' })).toBe(50); expect(txWeight({ object_type: undefined })).toBe(50); expect(txWeight({})).toBe(50); }); From 8698e3e6b020dc41a01100e13d5f16eb10393377 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 08:55:27 -0400 Subject: [PATCH 12/47] Cover every schema-supported PlanSecurity wrapper --- test/utils/ocfZodSchemas.test.ts | 65 +++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/test/utils/ocfZodSchemas.test.ts b/test/utils/ocfZodSchemas.test.ts index 45d969f8..17ef3017 100644 --- a/test/utils/ocfZodSchemas.test.ts +++ b/test/utils/ocfZodSchemas.test.ts @@ -41,6 +41,44 @@ const entityDiscriminatorCases = entityTypes.map((entityType, index) => { }; }); +const planSecurityDiscriminatorCases = [ + { + sourceObjectType: 'TX_PLAN_SECURITY_ACCEPTANCE', + canonicalObjectType: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', + fixture: () => loadSyntheticFixture>('equityCompensationAcceptance'), + }, + { + sourceObjectType: 'TX_PLAN_SECURITY_CANCELLATION', + canonicalObjectType: 'TX_EQUITY_COMPENSATION_CANCELLATION', + fixture: () => loadProductionFixture>('equityCompensationCancellation'), + }, + { + sourceObjectType: 'TX_PLAN_SECURITY_EXERCISE', + canonicalObjectType: 'TX_EQUITY_COMPENSATION_EXERCISE', + fixture: () => loadProductionFixture>('equityCompensationExercise'), + }, + { + sourceObjectType: 'TX_PLAN_SECURITY_ISSUANCE', + canonicalObjectType: 'TX_EQUITY_COMPENSATION_ISSUANCE', + fixture: () => loadProductionFixture>('equityCompensationIssuance', 'option-iso'), + }, + { + sourceObjectType: 'TX_PLAN_SECURITY_RELEASE', + canonicalObjectType: 'TX_EQUITY_COMPENSATION_RELEASE', + fixture: () => loadSyntheticFixture>('equityCompensationRelease'), + }, + { + sourceObjectType: 'TX_PLAN_SECURITY_RETRACTION', + canonicalObjectType: 'TX_EQUITY_COMPENSATION_RETRACTION', + fixture: () => loadSyntheticFixture>('equityCompensationRetraction'), + }, + { + sourceObjectType: 'TX_PLAN_SECURITY_TRANSFER', + canonicalObjectType: 'TX_EQUITY_COMPENSATION_TRANSFER', + fixture: () => loadSyntheticFixture>('equityCompensationTransfer'), + }, +] as const; + describe('ocfZodSchemas', () => { beforeAll(() => { if (schemaAvailabilityError) { @@ -118,22 +156,21 @@ describe('ocfZodSchemas', () => { ); }); - it('normalizes a schema-valid PlanSecurity issuance after validating its declared shape', () => { - const fixture = stripSourceMetadata( - loadProductionFixture>('equityCompensationIssuance', 'option-iso') - ); - const planSecurityFixture: Record = { - ...fixture, - object_type: 'TX_PLAN_SECURITY_ISSUANCE', - }; + it.each(planSecurityDiscriminatorCases)( + 'normalizes schema-valid $sourceObjectType to $canonicalObjectType after validating its declared shape', + ({ sourceObjectType, canonicalObjectType, fixture: loadFixture }) => { + const fixture = stripSourceMetadata(loadFixture()); + const planSecurityFixture: Record = { + ...fixture, + object_type: sourceObjectType, + }; - const parsed = parseOcfObject(planSecurityFixture); - const parsedRecord = toRecord(parsed); + const parsedRecord = toRecord(parseOcfObject(planSecurityFixture)); - expect(parsedRecord.object_type).toBe('TX_EQUITY_COMPENSATION_ISSUANCE'); - expect(parsedRecord.compensation_type).toBe('OPTION_ISO'); - expect(parsedRecord).not.toHaveProperty('option_grant_type'); - }); + expect(parsedRecord.object_type).toBe(canonicalObjectType); + expect(parsedRecord.id).toBe(fixture.id); + } + ); it('does not let normalization rescue a schema-invalid PlanSecurity issuance', () => { const fixture = stripSourceMetadata( From 365067bff7828f8fe319323aa54922873747e353 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 09:03:47 -0400 Subject: [PATCH 13/47] Reject non-object normalization input cleanly --- src/utils/planSecurityAliases.ts | 11 ++++++----- test/utils/planSecurityAliases.test.ts | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/utils/planSecurityAliases.ts b/src/utils/planSecurityAliases.ts index 309fcad5..649b60f3 100644 --- a/src/utils/planSecurityAliases.ts +++ b/src/utils/planSecurityAliases.ts @@ -718,11 +718,12 @@ export function deepNormalizeNumericStrings(value: unknown): unknown { * // => { object_type: 'STOCK_PLAN', stock_class_ids: ['sc-1'], id: 'sp-1', plan_name: 'Plan', initial_shares_reserved: '1000' } * ``` */ -export function normalizeOcfData(data: object): Record { - if ( - Array.isArray(data) || - (Object.getPrototypeOf(data) !== Object.prototype && Object.getPrototypeOf(data) !== null) - ) { +export function normalizeOcfData(data: unknown): Record { + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + throw new Error('Invalid OCF data: expected a plain object'); + } + const prototype = Object.getPrototypeOf(data); + if (prototype !== Object.prototype && prototype !== null) { throw new Error('Invalid OCF data: expected a plain object'); } const input = data as Record; diff --git a/test/utils/planSecurityAliases.test.ts b/test/utils/planSecurityAliases.test.ts index 8f60bc0a..928d76c7 100644 --- a/test/utils/planSecurityAliases.test.ts +++ b/test/utils/planSecurityAliases.test.ts @@ -140,7 +140,7 @@ describe('PlanSecurity alias utilities', () => { expect(result).toBe(input); }); - it.each([[], new Date('2026-01-01T00:00:00.000Z')])('rejects non-plain object input', (input) => { + it.each([null, [], new Date('2026-01-01T00:00:00.000Z')])('rejects non-plain object input', (input) => { expect(() => normalizeOcfData(input)).toThrow('Invalid OCF data: expected a plain object'); }); From bfc9f56c09c68e5f98552450af49d0412b07fd53 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 12:19:39 -0400 Subject: [PATCH 14/47] Limit RFC3339 fractional seconds --- src/utils/typeConversions.ts | 2 +- test/validation/typeCoercion.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 59a3517c..7e348ff3 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -26,7 +26,7 @@ export function isRecord(value: unknown): value is Record { const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const RFC3339_DATE_TIME_PATTERN = - /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/; + /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,9})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/; const DATE_EXPECTED_TYPE = 'YYYY-MM-DD or RFC 3339 date-time string with Z or numeric offset'; diff --git a/test/validation/typeCoercion.test.ts b/test/validation/typeCoercion.test.ts index 320b3717..a6b88a5d 100644 --- a/test/validation/typeCoercion.test.ts +++ b/test/validation/typeCoercion.test.ts @@ -160,6 +160,19 @@ describe('Type Coercion Utilities', () => { expect(tryIsoDateToDateString('2100-02-29')).toBeNull(); }); + test('accepts at most nine fractional-second digits', () => { + const nineDigits = '2024-01-15T23:59:59.123456789Z'; + const tenDigits = '2024-01-15T23:59:59.1234567890Z'; + + expect(tryIsoDateToDateString(nineDigits)).toBe('2024-01-15'); + expect(dateStringToDAMLTime(nineDigits)).toBe(nineDigits); + expect(damlTimeToDateString(nineDigits)).toBe('2024-01-15'); + + expect(tryIsoDateToDateString(tenDigits)).toBeNull(); + expect(() => dateStringToDAMLTime(tenDigits)).toThrow(OcpValidationError); + expect(() => damlTimeToDateString(tenDigits)).toThrow(OcpValidationError); + }); + test('reports the caller field path and invalid value', () => { const value = '2024-02-30T00:00:00Z'; From 9e80facf458ee244bcd39c164f33b401030ad0ce Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 12:23:32 -0400 Subject: [PATCH 15/47] Route authorization response through public types --- scripts/check-declarations.ts | 17 ++++++++++++++++ .../OpenCapTable/issuerAuthorization/types.ts | 3 +-- test/declarations/publicApi.types.ts | 11 ++++++++++ ...issuerAuthorizationPublicBoundary.types.ts | 20 +++++++++++++++++++ 4 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 test/types/issuerAuthorizationPublicBoundary.types.ts diff --git a/scripts/check-declarations.ts b/scripts/check-declarations.ts index 65596abb..07cac7d1 100644 --- a/scripts/check-declarations.ts +++ b/scripts/check-declarations.ts @@ -8,6 +8,8 @@ const declarationEntryPoint = path.join(projectRoot, 'dist', 'index.d.ts'); const strictConsumerEntryPoint = path.join(projectRoot, 'test', 'declarations', 'publicApi.types.ts'); const declarationRoot = `${path.dirname(declarationEntryPoint)}${path.sep}`; const generatedDamlPackage = '@fairmint/open-captable-protocol-daml-js'; +const cantonTransactionTreeOperationsModule = '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; +const commonTypesDeclaration = path.join(declarationRoot, 'types', 'common.d.ts'); const diagnosticHost: ts.FormatDiagnosticsHost = { getCanonicalFileName: (fileName) => fileName, getCurrentDirectory: () => projectRoot, @@ -55,3 +57,18 @@ if (generatedDamlLeaks.length > 0) { `Public declaration graph references ${generatedDamlPackage}:\n${generatedDamlLeaks.map((file) => `- ${file}`).join('\n')}` ); } + +const duplicatedTransactionTreeResponseImports = program + .getSourceFiles() + .filter((sourceFile) => sourceFile.fileName.startsWith(declarationRoot)) + .filter((sourceFile) => sourceFile.fileName !== commonTypesDeclaration) + .filter((sourceFile) => sourceFile.text.includes(cantonTransactionTreeOperationsModule)) + .map((sourceFile) => path.relative(projectRoot, sourceFile.fileName)); + +if (duplicatedTransactionTreeResponseImports.length > 0) { + throw new Error( + `Public declarations must import transaction-tree response types through src/types/common:\n${duplicatedTransactionTreeResponseImports + .map((file) => `- ${file}`) + .join('\n')}` + ); +} diff --git a/src/functions/OpenCapTable/issuerAuthorization/types.ts b/src/functions/OpenCapTable/issuerAuthorization/types.ts index 8a99a6c6..1b03dd92 100644 --- a/src/functions/OpenCapTable/issuerAuthorization/types.ts +++ b/src/functions/OpenCapTable/issuerAuthorization/types.ts @@ -1,6 +1,5 @@ -import type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; import type { CommandObservabilityOptions } from '../../../observability'; -import type { DisclosedContract } from '../../../types/common'; +import type { DisclosedContract, SubmitAndWaitForTransactionTreeResponse } from '../../../types/common'; /** Parameters for authorizing an issuer through the OCP Factory. */ export interface AuthorizeIssuerParams extends CommandObservabilityOptions { diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 6af28f02..acaa9867 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -8,6 +8,7 @@ import { OcpClient, OcpValidationError, withdrawAuthorization, + type AuthorizeIssuerResult, type CapTableBatchExecuteResult, type CapTableBatchOperations, type CreateIssuerParams, @@ -22,6 +23,8 @@ import { type OcfStockAcceptance, type OcfStockClass, type OcfVestingStart, + type SubmitAndWaitForTransactionTreeResponse, + type WithdrawAuthorizationResult, } from '../../dist'; type Assert = T; @@ -53,10 +56,18 @@ const publishedOcfObjectExcludesLegacyPlanSecurity: Assert< IsExactly, never> > = true; const generatedAndLegacyValuesAreNotRootExports: Assert> = true; +const authorizeIssuerResponseUsesPublicLedgerType: Assert< + IsExactly +> = true; +const withdrawAuthorizationResponseUsesPublicLedgerType: Assert< + IsExactly +> = true; void publishedOcfObjectIsExact; void publishedOcfObjectExcludesLegacyPlanSecurity; void generatedAndLegacyValuesAreNotRootExports; +void authorizeIssuerResponseUsesPublicLedgerType; +void withdrawAuthorizationResponseUsesPublicLedgerType; void authorizeIssuer; void buildCreateIssuerCommand; void CapTableBatch; diff --git a/test/types/issuerAuthorizationPublicBoundary.types.ts b/test/types/issuerAuthorizationPublicBoundary.types.ts new file mode 100644 index 00000000..143c84c8 --- /dev/null +++ b/test/types/issuerAuthorizationPublicBoundary.types.ts @@ -0,0 +1,20 @@ +/** Compile-time contracts for the public issuer-authorization response boundary. */ + +import type { + AuthorizeIssuerResult, + SubmitAndWaitForTransactionTreeResponse, + WithdrawAuthorizationResult, +} from '../../src'; + +type Assert = T; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +const authorizeResponseUsesPublicLedgerType: Assert< + IsExactly +> = true; +const withdrawResponseUsesPublicLedgerType: Assert< + IsExactly +> = true; + +void authorizeResponseUsesPublicLedgerType; +void withdrawResponseUsesPublicLedgerType; From de68023bef35497aeb06273b6bc25e59d221d3d6 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 12:24:01 -0400 Subject: [PATCH 16/47] Keep authorization boundary test focused --- ...issuerAuthorizationPublicBoundary.types.ts | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 test/types/issuerAuthorizationPublicBoundary.types.ts diff --git a/test/types/issuerAuthorizationPublicBoundary.types.ts b/test/types/issuerAuthorizationPublicBoundary.types.ts deleted file mode 100644 index 143c84c8..00000000 --- a/test/types/issuerAuthorizationPublicBoundary.types.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** Compile-time contracts for the public issuer-authorization response boundary. */ - -import type { - AuthorizeIssuerResult, - SubmitAndWaitForTransactionTreeResponse, - WithdrawAuthorizationResult, -} from '../../src'; - -type Assert = T; -type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; - -const authorizeResponseUsesPublicLedgerType: Assert< - IsExactly -> = true; -const withdrawResponseUsesPublicLedgerType: Assert< - IsExactly -> = true; - -void authorizeResponseUsesPublicLedgerType; -void withdrawResponseUsesPublicLedgerType; From c18f1921cc5816f0b424ee0c759625e3fa092d1d Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 12:34:44 -0400 Subject: [PATCH 17/47] Format restacked type contracts --- src/functions/OpenCapTable/capTable/batchTypes.ts | 1 + test/declarations/publicApi.types.ts | 1 + test/types/capTableBatch.types.ts | 3 +++ 3 files changed, 5 insertions(+) diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index 9fc20993..9efbda87 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -615,6 +615,7 @@ export type DamlDataTypeFor = Extract< export type DamlEntityArguments = { [EntityType in OcfEntityType]: readonly [type: EntityType, damlData: DamlDataTypeFor]; }[OcfEntityType]; + /** Correlated entity-kind and native-data tuples accepted by the converter dispatcher. */ export type OcfEntityArguments = { [EntityType in OcfEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index b39c6cb3..91232e39 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -103,6 +103,7 @@ function verifyPublishedBatchApi( security_id: 'security-2', }; void wrongObjectType; + const operations: CapTableBatchOperations = { creates: [{ type: 'stakeholder', data: stakeholder }], edits: [{ type: 'issuer', data: issuer }], diff --git a/test/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index 511da9e9..ed172fea 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -43,6 +43,7 @@ const publicOcfObjectExcludesLegacyPlanSecurity: Assert< void publicOcfObjectIsExact; void publicOcfObjectExcludesLegacyPlanSecurity; + function verifyCapTableBatchContract( batch: CapTableBatch, stakeholder: OcfStakeholder, @@ -109,6 +110,7 @@ function verifyCapTableBatchContract( security_id: 'security-2', }; void wrongObjectType; + const createOperation: OcfCreateOperation = { type: 'stakeholder', data: stakeholder, @@ -121,6 +123,7 @@ function verifyCapTableBatchContract( data: stockAcceptance, }; void invalidIdentityOperation; + const operations: CapTableBatchOperations = { creates: [ { type: 'stakeholder', data: stakeholder }, From 435e805eb5c2456c8b21c9399a8fcf83d8d08e9a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Jul 2026 17:04:40 +0000 Subject: [PATCH 18/47] Auto-fix: build changes --- src/utils/cantonOcfExtractor.ts | 2 +- src/utils/enumConversions.ts | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/utils/cantonOcfExtractor.ts b/src/utils/cantonOcfExtractor.ts index 37ee9f18..8a742fc8 100644 --- a/src/utils/cantonOcfExtractor.ts +++ b/src/utils/cantonOcfExtractor.ts @@ -13,9 +13,9 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpErrorCodes } from '../errors/codes'; import { OcpValidationError } from '../errors/OcpValidationError'; -import type { OcfEntityType } from '../functions/OpenCapTable/capTable/entityTypes'; import type { SupportedOcfReadType } from '../functions/OpenCapTable/capTable/damlToOcf'; import { getEntityAsOcf } from '../functions/OpenCapTable/capTable/damlToOcf'; +import type { OcfEntityType } from '../functions/OpenCapTable/capTable/entityTypes'; import type { CapTableState } from '../functions/OpenCapTable/capTable/getCapTableState'; import { getConvertibleIssuanceAsOcf } from '../functions/OpenCapTable/convertibleIssuance'; import { getDocumentAsOcf } from '../functions/OpenCapTable/document'; diff --git a/src/utils/enumConversions.ts b/src/utils/enumConversions.ts index b02186b0..a71703f8 100644 --- a/src/utils/enumConversions.ts +++ b/src/utils/enumConversions.ts @@ -151,9 +151,7 @@ export function damlPhoneTypeToNative(damlType: DamlPhoneType): PhoneType { * @returns DAML stakeholder type enum value * @throws Error if stakeholderType is not a valid value */ -export function stakeholderTypeToDaml( - stakeholderType: StakeholderType -): DamlStakeholderType { +export function stakeholderTypeToDaml(stakeholderType: StakeholderType): DamlStakeholderType { switch (stakeholderType) { case 'INDIVIDUAL': return 'OcfStakeholderTypeIndividual'; @@ -176,9 +174,7 @@ export function stakeholderTypeToDaml( * @returns Native stakeholder type * @throws Error if damlType is not a valid value */ -export function damlStakeholderTypeToNative( - damlType: DamlStakeholderType -): StakeholderType { +export function damlStakeholderTypeToNative(damlType: DamlStakeholderType): StakeholderType { switch (damlType) { case 'OcfStakeholderTypeIndividual': return 'INDIVIDUAL'; From 2919049e14a9ecfc7d0aa890582a1246ef31fa49 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 13:24:39 -0400 Subject: [PATCH 19/47] docs: clarify entity guard export --- src/utils/replicationHelpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/replicationHelpers.ts b/src/utils/replicationHelpers.ts index 7723d956..557aa613 100644 --- a/src/utils/replicationHelpers.ts +++ b/src/utils/replicationHelpers.ts @@ -15,7 +15,7 @@ import type { OcfManifest } from './cantonOcfExtractor'; import { DEFAULT_DEPRECATED_FIELDS, DEFAULT_INTERNAL_FIELDS, ocfDeepEqual } from './ocfComparison'; import { normalizeObjectType, normalizeOcfData } from './planSecurityAliases'; -// Preserve the public utils import path while keeping the registry as the single implementation. +// Preserve the public utils import path while keeping the protocol-native guard implementation centralized. export { isOcfEntityType } from '../functions/OpenCapTable/capTable/entityTypes'; // ============================================================================ From cf8877ecdcfd88957cdf6b2938c3a8bf7e1bfeb8 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 13:33:47 -0400 Subject: [PATCH 20/47] fix: harden curated package exports --- package.json | 3 ++- src/functions/OpenCapTable/capTable/entityTypes.ts | 4 ++++ test/batch/batchTypes.test.ts | 5 +++++ test/publicApi/rootExports.test.ts | 13 +++++++++++++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 6c266659..a788ce6f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "import": "./dist/index.js", "require": "./dist/index.js", "default": "./dist/index.js" - } + }, + "./package.json": "./package.json" }, "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/functions/OpenCapTable/capTable/entityTypes.ts b/src/functions/OpenCapTable/capTable/entityTypes.ts index 253b0e82..5ce03c05 100644 --- a/src/functions/OpenCapTable/capTable/entityTypes.ts +++ b/src/functions/OpenCapTable/capTable/entityTypes.ts @@ -224,6 +224,10 @@ export const OCF_OBJECT_TYPE_TO_ENTITY_TYPE = { VESTING_TERMS: 'vestingTerms', } as const satisfies Record; +type MappedOcfEntityType = (typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE)[keyof typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE]; +const ALL_OCF_ENTITY_TYPES_ARE_MAPPED: [OcfEntityType] extends [MappedOcfEntityType] ? true : never = true; +void ALL_OCF_ENTITY_TYPES_ARE_MAPPED; + /** OCF object types supported by the high-level entity readers. */ export type OcfReadableObjectType = keyof typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE; diff --git a/test/batch/batchTypes.test.ts b/test/batch/batchTypes.test.ts index 76cbf065..6afca747 100644 --- a/test/batch/batchTypes.test.ts +++ b/test/batch/batchTypes.test.ts @@ -3,6 +3,7 @@ import { isOcfDeletableEntityType, isOcfEditableEntityType, isOcfEntityType, + OCF_OBJECT_TYPE_TO_ENTITY_TYPE, type OcfEntityType, } from '../../src'; import { ENTITY_REGISTRY } from '../../src/functions/OpenCapTable/capTable/batchTypes'; @@ -10,6 +11,10 @@ import { ENTITY_REGISTRY } from '../../src/functions/OpenCapTable/capTable/batch const entityTypes = Object.keys(ENTITY_REGISTRY) as OcfEntityType[]; describe('batch entity capabilities', () => { + it('maps every registered entity type from a canonical OCF object type', () => { + expect(new Set(Object.values(OCF_OBJECT_TYPE_TO_ENTITY_TYPE))).toEqual(new Set(entityTypes)); + }); + it.each(entityTypes)('recognizes %s as a supported entity type', (entityType) => { expect(isOcfEntityType(entityType)).toBe(true); }); diff --git a/test/publicApi/rootExports.test.ts b/test/publicApi/rootExports.test.ts index 8c6b76a9..4ae065d2 100644 --- a/test/publicApi/rootExports.test.ts +++ b/test/publicApi/rootExports.test.ts @@ -1,6 +1,19 @@ +import packageJson from '../../package.json'; import * as sdk from '../../src'; describe('package root exports', () => { + it('exports only the SDK root and package metadata subpaths', () => { + expect(packageJson.exports).toEqual({ + '.': { + types: './dist/index.d.ts', + import: './dist/index.js', + require: './dist/index.js', + default: './dist/index.js', + }, + './package.json': './package.json', + }); + }); + it('exposes only the curated high-level runtime surface', () => { expect(Object.keys(sdk).sort()).toEqual([ 'CUSTOM_PRESET', From d7ef4f1ca8ef11e280e88a39ab315a67dcf47da0 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 15:31:35 -0400 Subject: [PATCH 21/47] Align date conversion types with validation --- src/utils/typeConversions.ts | 1 - test/declarations/publicApi.types.ts | 5 ++++- test/property/typeConversions.property.test.ts | 2 +- test/validation/typeCoercion.test.ts | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 7e348ff3..ba24d309 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -79,7 +79,6 @@ function requireIsoDate(value: unknown, fieldPath: string): { source: string; da * Convert a valid OCF date to DAML Time format. RFC 3339 date-times are validated and preserved exactly; date-only * values receive a UTC midnight suffix. */ -export function dateStringToDAMLTime(value: string, fieldPath?: string): string; export function dateStringToDAMLTime(value: unknown, fieldPath = 'date'): string { const { source, date } = requireIsoDate(value, fieldPath); return source === date ? `${date}T00:00:00.000Z` : source; diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 91232e39..ff6979f4 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -18,7 +18,7 @@ import { type OcfVestingStart, type OcfWarrantAcceptance, } from '../../dist'; -import { isOcfEntityType as isOcfEntityTypeFromUtils } from '../../dist/utils'; +import { dateStringToDAMLTime, isOcfEntityType as isOcfEntityTypeFromUtils } from '../../dist/utils'; type Assert = T; type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; @@ -36,9 +36,12 @@ const publishedOcfObjectIsExact: Assert, never> > = true; +declare const unknownDateInput: unknown; +const validatedDamlTime: string = dateStringToDAMLTime(unknownDateInput, 'transaction.date'); void publishedOcfObjectIsExact; void publishedOcfObjectExcludesLegacyPlanSecurity; +void validatedDamlTime; function verifyPublishedBatchApi( batch: CapTableBatch, diff --git a/test/property/typeConversions.property.test.ts b/test/property/typeConversions.property.test.ts index 7baec64a..a9ce1bfc 100644 --- a/test/property/typeConversions.property.test.ts +++ b/test/property/typeConversions.property.test.ts @@ -318,7 +318,7 @@ describe('Property-based tests: Type Conversions', () => { ), (value) => { expect(tryIsoDateToDateString(value)).toBeNull(); - expect(() => Reflect.apply(dateStringToDAMLTime, undefined, [value])).toThrow(); + expect(() => dateStringToDAMLTime(value)).toThrow(); } ), { numRuns: 300 } diff --git a/test/validation/typeCoercion.test.ts b/test/validation/typeCoercion.test.ts index a6b88a5d..a223b3d6 100644 --- a/test/validation/typeCoercion.test.ts +++ b/test/validation/typeCoercion.test.ts @@ -150,7 +150,7 @@ describe('Type Coercion Utilities', () => { }); test.each(invalidValues)('both conversion boundaries reject invalid input %#', (value) => { - expect(() => Reflect.apply(dateStringToDAMLTime, undefined, [value])).toThrow(OcpValidationError); + expect(() => dateStringToDAMLTime(value)).toThrow(OcpValidationError); expect(() => damlTimeToDateString(value)).toThrow(OcpValidationError); }); @@ -193,7 +193,7 @@ describe('Type Coercion Utilities', () => { test('distinguishes wrong runtime types from malformed date strings', () => { for (const action of [ () => damlTimeToDateString({ seconds: 1 }, 'transaction.date'), - () => Reflect.apply(dateStringToDAMLTime, undefined, [42, 'transaction.date']), + () => dateStringToDAMLTime(42, 'transaction.date'), ]) { try { action(); From a21d3fa3cb1bbd87f689bdf579db07f5b4019a7a Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 15:48:58 -0400 Subject: [PATCH 22/47] Validate issuance approval dates on read --- .../getConvertibleIssuanceAsOcf.ts | 4 +-- .../getWarrantIssuanceAsOcf.ts | 4 +-- .../convertibleIssuanceConverters.test.ts | 26 +++++++++++++++++++ .../warrantIssuanceConverters.test.ts | 22 +++++++++++++++- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 134b6886..1b863665 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -655,12 +655,12 @@ export function damlConvertibleIssuanceDataToNative(d: Record): security_id: d.security_id, custom_id: d.custom_id as string, stakeholder_id: d.stakeholder_id as string, - ...(typeof d.board_approval_date === 'string' && d.board_approval_date.length + ...(d.board_approval_date !== null && d.board_approval_date !== undefined ? { board_approval_date: damlTimeToDateString(d.board_approval_date, 'convertibleIssuance.board_approval_date'), } : {}), - ...(typeof d.stockholder_approval_date === 'string' && d.stockholder_approval_date.length + ...(d.stockholder_approval_date !== null && d.stockholder_approval_date !== undefined ? { stockholder_approval_date: damlTimeToDateString( d.stockholder_approval_date, diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 2c938cde..4d154904 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -503,12 +503,12 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf } : {}), ...(d.vesting_terms_id && typeof d.vesting_terms_id === 'string' ? { vesting_terms_id: d.vesting_terms_id } : {}), - ...(d.board_approval_date && typeof d.board_approval_date === 'string' + ...(d.board_approval_date !== null && d.board_approval_date !== undefined ? { board_approval_date: damlTimeToDateString(d.board_approval_date, 'warrantIssuance.board_approval_date'), } : {}), - ...(d.stockholder_approval_date && typeof d.stockholder_approval_date === 'string' + ...(d.stockholder_approval_date !== null && d.stockholder_approval_date !== undefined ? { stockholder_approval_date: damlTimeToDateString( d.stockholder_approval_date, diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 16822a0c..c47ac17c 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -11,6 +11,7 @@ * - OcfInterestPayoutDeferred / OcfInterestPayoutCash */ +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { loadProductionFixture } from '../utils/productionFixtures'; @@ -405,6 +406,31 @@ describe('SAFE conversion_timing round-trip', () => { }); }); +describe('convertible issuance approval-date read boundaries', () => { + test.each(['board_approval_date', 'stockholder_approval_date'] as const)( + 'rejects a present non-string %s', + (field) => { + const invalidDate = { seconds: 1 }; + const daml = convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE], + }); + + try { + damlConvertibleIssuanceDataToNative({ ...daml, [field]: invalidDate }); + throw new Error('Expected approval date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `convertibleIssuance.${field}`, + receivedValue: invalidDate, + }); + } + } + ); +}); + /** * Uses the production SAFE post-money fixture to exercise the full converter path: * AUTOMATIC_ON_CONDITION trigger, capitalization_definition_rules, converts_to_future_round, diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 10073d8a..fd356af5 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -6,7 +6,7 @@ * infinite edit loops in the replication script. */ -import { OcpParseError, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import { ocfDeepEqual } from '../../src/utils/ocfComparison'; @@ -154,6 +154,26 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(ocfDeepEqual(dbData as Record, cantonData)).toBe(true); }); + test.each(['board_approval_date', 'stockholder_approval_date'] as const)( + 'rejects a present non-string %s on readback', + (field) => { + const invalidDate = { seconds: 1 }; + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + + try { + damlWarrantIssuanceDataToNative({ ...daml, [field]: invalidDate }); + throw new Error('Expected approval date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `warrantIssuance.${field}`, + receivedValue: invalidDate, + }); + } + } + ); + test('STOCK_CLASS_CONVERSION_RIGHT rejects non-NORMAL rounding_type (not persisted in DAML)', () => { const input = { ...baseWarrantIssuance, From cf98eb6a27ecb7d5c12cbe89fcbf49954ff84aa8 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 15:58:16 -0400 Subject: [PATCH 23/47] Validate optional trigger dates on read --- .../getConvertibleIssuanceAsOcf.ts | 18 ++--- .../getWarrantIssuanceAsOcf.ts | 18 ++--- .../convertibleIssuanceConverters.test.ts | 59 ++++++++++++++++ .../warrantIssuanceConverters.test.ts | 69 +++++++++++++++++++ 4 files changed, 146 insertions(+), 18 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 1b863665..d1885a16 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -522,19 +522,19 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers const trigger_description: string | undefined = typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; const trigger_date: string | undefined = - typeof r.trigger_date === 'string' && r.trigger_date.length - ? damlTimeToDateString(r.trigger_date, 'convertibleIssuance.conversion_triggers[].trigger_date') - : undefined; + r.trigger_date === null || r.trigger_date === undefined + ? undefined + : damlTimeToDateString(r.trigger_date, 'convertibleIssuance.conversion_triggers[].trigger_date'); const trigger_condition: string | undefined = typeof r.trigger_condition === 'string' && r.trigger_condition.length ? r.trigger_condition : undefined; const start_date: string | undefined = - typeof r.start_date === 'string' && r.start_date.length - ? damlTimeToDateString(r.start_date, 'convertibleIssuance.conversion_triggers[].start_date') - : undefined; + r.start_date === null || r.start_date === undefined + ? undefined + : damlTimeToDateString(r.start_date, 'convertibleIssuance.conversion_triggers[].start_date'); const end_date: string | undefined = - typeof r.end_date === 'string' && r.end_date.length - ? damlTimeToDateString(r.end_date, 'convertibleIssuance.conversion_triggers[].end_date') - : undefined; + r.end_date === null || r.end_date === undefined + ? undefined + : damlTimeToDateString(r.end_date, 'convertibleIssuance.conversion_triggers[].end_date'); // Parse conversion_right if present and convertible variant is used let conversion_right: ConvertibleConversionRight | undefined; diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 4d154904..c3deaee9 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -348,19 +348,19 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf const trigger_description: string | undefined = typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; const trigger_date: string | undefined = - typeof r.trigger_date === 'string' && r.trigger_date.length - ? damlTimeToDateString(r.trigger_date, 'warrantIssuance.exercise_triggers[].trigger_date') - : undefined; + r.trigger_date === null || r.trigger_date === undefined + ? undefined + : damlTimeToDateString(r.trigger_date, 'warrantIssuance.exercise_triggers[].trigger_date'); const trigger_condition: string | undefined = typeof r.trigger_condition === 'string' && r.trigger_condition.length ? r.trigger_condition : undefined; const start_date: string | undefined = - typeof r.start_date === 'string' && r.start_date.length - ? damlTimeToDateString(r.start_date, 'warrantIssuance.exercise_triggers[].start_date') - : undefined; + r.start_date === null || r.start_date === undefined + ? undefined + : damlTimeToDateString(r.start_date, 'warrantIssuance.exercise_triggers[].start_date'); const end_date: string | undefined = - typeof r.end_date === 'string' && r.end_date.length - ? damlTimeToDateString(r.end_date, 'warrantIssuance.exercise_triggers[].end_date') - : undefined; + r.end_date === null || r.end_date === undefined + ? undefined + : damlTimeToDateString(r.end_date, 'warrantIssuance.exercise_triggers[].end_date'); const conversion_right: WarrantTriggerConversionRight = mapAnyConversionRightFromDaml(r.conversion_right); diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index c47ac17c..0c184907 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -429,6 +429,65 @@ describe('convertible issuance approval-date read boundaries', () => { } } ); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'rejects a present non-string conversion trigger %s', + (field) => { + const invalidDate = { seconds: 1 }; + + try { + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [{ ...buildDamlSafeTrigger(), [field]: invalidDate }], + }); + throw new Error('Expected trigger date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `convertibleIssuance.conversion_triggers[].${field}`, + receivedValue: invalidDate, + }); + } + } + ); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'rejects a present empty conversion trigger %s', + (field) => { + try { + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [{ ...buildDamlSafeTrigger(), [field]: '' }], + }); + throw new Error('Expected trigger date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `convertibleIssuance.conversion_triggers[].${field}`, + receivedValue: '', + }); + } + } + ); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'omits a null or absent conversion trigger %s', + (field) => { + const withNull = damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [{ ...buildDamlSafeTrigger(), [field]: null }], + }).conversion_triggers[0] as unknown as Record; + const withoutField = damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [buildDamlSafeTrigger()], + }).conversion_triggers[0] as unknown as Record; + + expect(withNull[field]).toBeUndefined(); + expect(withoutField[field]).toBeUndefined(); + } + ); }); /** diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index fd356af5..9eaae20d 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -174,6 +174,75 @@ describe('WarrantIssuance round-trip equivalence', () => { } ); + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'rejects a present non-string exercise trigger %s on readback', + (field) => { + const invalidDate = { seconds: 1 }; + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const trigger = daml.exercise_triggers[0]; + + try { + damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [{ ...trigger, [field]: invalidDate }], + }); + throw new Error('Expected trigger date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `warrantIssuance.exercise_triggers[].${field}`, + receivedValue: invalidDate, + }); + } + } + ); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'rejects a present empty exercise trigger %s on readback', + (field) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const trigger = daml.exercise_triggers[0]; + + try { + damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [{ ...trigger, [field]: '' }], + }); + throw new Error('Expected trigger date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `warrantIssuance.exercise_triggers[].${field}`, + receivedValue: '', + }); + } + } + ); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'omits a null or absent exercise trigger %s on readback', + (field) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const trigger = { ...daml.exercise_triggers[0] } as unknown as Record; + const absentTrigger = { ...trigger }; + delete absentTrigger[field]; + + const withNull = damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [{ ...trigger, [field]: null }], + }).exercise_triggers[0] as unknown as Record; + const withoutField = damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [absentTrigger], + }).exercise_triggers[0] as unknown as Record; + + expect(withNull[field]).toBeUndefined(); + expect(withoutField[field]).toBeUndefined(); + } + ); + test('STOCK_CLASS_CONVERSION_RIGHT rejects non-NORMAL rounding_type (not persisted in DAML)', () => { const input = { ...baseWarrantIssuance, From ea9dac13fef98f80c35b32e6aa7d55c5d4c787e2 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 16:23:42 -0400 Subject: [PATCH 24/47] Validate present optional dates --- .../createConvertibleIssuance.ts | 52 +++++--- .../getConvertibleIssuanceAsOcf.ts | 2 +- .../getEquityCompensationIssuanceAsOcf.ts | 11 +- ...etIssuerAuthorizedSharesAdjustmentAsOcf.ts | 4 +- .../getStockPlanPoolAdjustmentAsOcf.ts | 13 +- .../getWarrantIssuanceAsOcf.ts | 2 +- .../convertibleIssuanceConverters.test.ts | 125 +++++++++++++++++- .../converters/dateBoundaryValidation.test.ts | 82 ++++++++++++ .../warrantIssuanceConverters.test.ts | 30 +++++ 9 files changed, 289 insertions(+), 32 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index e3e3e190..93d7e1e4 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -187,15 +187,17 @@ function mechanismInputToDamlEnum( Array.isArray(arr) ? arr.map((ir) => ({ rate: ir?.rate != null ? normalizeNumericString(String(ir.rate)) : null, - accrual_start_date: ir?.accrual_start_date - ? dateStringToDAMLTime( - ir.accrual_start_date, - 'convertibleIssuance.interest_rates[].accrual_start_date' - ) - : null, - accrual_end_date: ir?.accrual_end_date - ? dateStringToDAMLTime(ir.accrual_end_date, 'convertibleIssuance.interest_rates[].accrual_end_date') - : null, + accrual_start_date: + ir?.accrual_start_date !== null && ir?.accrual_start_date !== undefined + ? dateStringToDAMLTime( + ir.accrual_start_date, + 'convertibleIssuance.interest_rates[].accrual_start_date' + ) + : null, + accrual_end_date: + ir?.accrual_end_date !== null && ir?.accrual_end_date !== undefined + ? dateStringToDAMLTime(ir.accrual_end_date, 'convertibleIssuance.interest_rates[].accrual_end_date') + : null, })) : []; const accrualToDaml = (v: unknown): string => { @@ -417,18 +419,29 @@ function buildTriggerToDaml(t: ConversionTriggerInput, _index: number, _issuance const { trigger_id } = t; const nickname = typeof t === 'object' && t.nickname ? t.nickname : null; const trigger_description = typeof t === 'object' && t.trigger_description ? t.trigger_description : null; - const trigger_dateStr = typeof t === 'object' && t.trigger_date ? t.trigger_date : undefined; + const triggerDate: unknown = t.trigger_date; const trigger_condition = typeof t === 'object' && t.trigger_condition ? t.trigger_condition : null; const conversion_right = buildConvertibleRight(t); - const start_date = typeof t === 'object' && t.start_date ? dateStringToDAMLTime(t.start_date) : null; - const end_date = typeof t === 'object' && t.end_date ? dateStringToDAMLTime(t.end_date) : null; + const startDate: unknown = t.start_date; + const endDate: unknown = t.end_date; + const start_date = + startDate === null || startDate === undefined + ? null + : dateStringToDAMLTime(startDate, 'convertibleIssuance.conversion_triggers[].start_date'); + const end_date = + endDate === null || endDate === undefined + ? null + : dateStringToDAMLTime(endDate, 'convertibleIssuance.conversion_triggers[].end_date'); return { type_: typeEnum, trigger_id, nickname, trigger_description, conversion_right, - trigger_date: trigger_dateStr ? dateStringToDAMLTime(trigger_dateStr) : null, + trigger_date: + triggerDate === null || triggerDate === undefined + ? null + : dateStringToDAMLTime(triggerDate, 'convertibleIssuance.conversion_triggers[].trigger_date'), trigger_condition, start_date, end_date, @@ -452,14 +465,23 @@ export function convertibleIssuanceDataToDaml(d: { seniority: number; comments?: string[]; }): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData { + const boardApprovalDate: unknown = d.board_approval_date; + const stockholderApprovalDate: unknown = d.stockholder_approval_date; + return { id: d.id, date: dateStringToDAMLTime(d.date), security_id: d.security_id, custom_id: d.custom_id, stakeholder_id: d.stakeholder_id, - board_approval_date: d.board_approval_date ? dateStringToDAMLTime(d.board_approval_date) : null, - stockholder_approval_date: d.stockholder_approval_date ? dateStringToDAMLTime(d.stockholder_approval_date) : null, + board_approval_date: + boardApprovalDate === null || boardApprovalDate === undefined + ? null + : dateStringToDAMLTime(boardApprovalDate, 'convertibleIssuance.board_approval_date'), + stockholder_approval_date: + stockholderApprovalDate === null || stockholderApprovalDate === undefined + ? null + : dateStringToDAMLTime(stockholderApprovalDate, 'convertibleIssuance.stockholder_approval_date'), consideration_text: optionalString(d.consideration_text), security_law_exemptions: d.security_law_exemptions, investment_amount: monetaryToDaml(d.investment_amount), diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index d1885a16..7371f615 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -372,7 +372,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers irObj.accrual_start_date, 'convertibleIssuance.interest_rates[].accrual_start_date' ), - ...(irObj.accrual_end_date + ...(irObj.accrual_end_date !== null && irObj.accrual_end_date !== undefined ? { accrual_end_date: damlTimeToDateString( irObj.accrual_end_date, diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index f4035a45..214deb08 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -210,16 +210,17 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record): Ocf } return {}; })(), - ...(d.warrant_expiration_date + ...(d.warrant_expiration_date !== null && d.warrant_expiration_date !== undefined ? { warrant_expiration_date: damlTimeToDateString( d.warrant_expiration_date, diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 0c184907..5d67254a 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -11,7 +11,7 @@ * - OcfInterestPayoutDeferred / OcfInterestPayoutCash */ -import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { loadProductionFixture } from '../utils/productionFixtures'; @@ -42,6 +42,21 @@ const SAFE_TRIGGER_BASE = { }, }; +function expectInvalidDate( + action: () => unknown, + fieldPath: string, + receivedValue: unknown, + code: OcpErrorCode = OcpErrorCodes.INVALID_FORMAT +): void { + try { + action(); + throw new Error('Expected date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ code, fieldPath, receivedValue }); + } +} + describe('SAFE conversion_timing DAML constructor names', () => { it('emits OcfConvTimingPostMoney for POST_MONEY (not OcfConversionTimingPostMoney)', () => { const input = { @@ -488,6 +503,114 @@ describe('convertible issuance approval-date read boundaries', () => { expect(withoutField[field]).toBeUndefined(); } ); + + test.each([ + ['', OcpErrorCodes.INVALID_FORMAT], + [{ seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a present invalid note accrual_end_date on readback', (invalidDate, code) => { + const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); + const mechanism = trigger.conversion_right.OcfRightConvertible.conversion_mechanism; + mechanism.value.interest_rates[0] = { + ...mechanism.value.interest_rates[0], + accrual_end_date: invalidDate, + } as unknown as (typeof mechanism.value.interest_rates)[number]; + + expectInvalidDate( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [trigger], + }), + 'convertibleIssuance.interest_rates[].accrual_end_date', + invalidDate, + code + ); + }); + + test('omits a null note accrual_end_date on readback', () => { + const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); + const mechanism = trigger.conversion_right.OcfRightConvertible.conversion_mechanism; + mechanism.value.interest_rates[0] = { + ...mechanism.value.interest_rates[0], + accrual_end_date: null, + } as unknown as (typeof mechanism.value.interest_rates)[number]; + + const result = damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [trigger], + }); + const nativeMechanism = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + interest_rates: Array<{ accrual_end_date?: string }>; + }; + + expect(nativeMechanism.interest_rates[0].accrual_end_date).toBeUndefined(); + }); +}); + +describe('convertible issuance write date boundaries', () => { + test.each(['board_approval_date', 'stockholder_approval_date'] as const)( + 'validates a present %s instead of treating a falsy value as absent', + (field) => { + expectInvalidDate( + () => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE], + [field]: '', + }), + `convertibleIssuance.${field}`, + '' + ); + } + ); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'validates a present conversion trigger %s instead of treating a falsy value as absent', + (field) => { + expectInvalidDate( + () => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [{ ...SAFE_TRIGGER_BASE, [field]: '' }], + }), + `convertibleIssuance.conversion_triggers[].${field}`, + '' + ); + } + ); + + test.each(['accrual_start_date', 'accrual_end_date'] as const)( + 'validates a present note %s instead of treating a falsy value as absent', + (field) => { + const noteTrigger = { + ...SAFE_TRIGGER_BASE, + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, + conversion_mechanism: { + type: 'CONVERTIBLE_NOTE_CONVERSION' as const, + interest_rates: [{ rate: '0.05', accrual_start_date: '2024-01-15', [field]: '' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'CASH', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + }, + }, + }; + + expectInvalidDate( + () => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + convertible_type: 'NOTE', + conversion_triggers: [noteTrigger], + } as unknown as Parameters[0]), + `convertibleIssuance.interest_rates[].${field}`, + '' + ); + } + ); }); /** diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index 015bee56..0d5a7cf1 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -1,6 +1,8 @@ import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { damlEquityCompensationIssuanceDataToNative } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; import { damlIssuerAuthorizedSharesAdjustmentDataToNative } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf'; import { damlStockClassSplitToNative } from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; +import { damlStockPlanPoolAdjustmentDataToNative } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf'; function expectInvalidDate( action: () => unknown, @@ -21,6 +23,73 @@ function expectInvalidDate( } } +const ISSUER_ADJUSTMENT_BASE = { + id: 'adjustment-1', + issuer_id: 'issuer-1', + date: '2024-01-15T00:00:00Z', + new_shares_authorized: '1000', +}; + +const STOCK_PLAN_POOL_ADJUSTMENT_BASE = { + id: 'pool-adjustment-1', + date: '2024-01-15T00:00:00Z', + stock_plan_id: 'plan-1', + shares_reserved: '1000', + board_approval_date: null, + stockholder_approval_date: null, + comments: [], +}; + +const EQUITY_COMPENSATION_ISSUANCE_BASE = { + id: 'equity-compensation-1', + date: '2024-01-15T00:00:00Z', + security_id: 'security-1', + custom_id: 'OPTION-1', + stakeholder_id: 'stakeholder-1', + compensation_type: 'OcfCompensationTypeOption', + quantity: '1000', + expiration_date: null, + board_approval_date: null, + stockholder_approval_date: null, + termination_exercise_windows: [], + security_law_exemptions: [], + comments: [], +}; + +const OPTIONAL_READ_DATE_CASES: Array<{ + name: string; + fieldPath: string; + convert: (value: unknown) => unknown; +}> = [ + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `issuer adjustment ${field}`, + fieldPath: `issuerAuthorizedSharesAdjustment.${field}`, + convert: (value: unknown) => + damlIssuerAuthorizedSharesAdjustmentDataToNative({ + ...ISSUER_ADJUSTMENT_BASE, + [field]: value, + }), + })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `stock plan pool adjustment ${field}`, + fieldPath: `stockPlanPoolAdjustment.${field}`, + convert: (value: unknown) => + damlStockPlanPoolAdjustmentDataToNative({ + ...STOCK_PLAN_POOL_ADJUSTMENT_BASE, + [field]: value, + }), + })), + ...(['expiration_date', 'board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `equity compensation issuance ${field}`, + fieldPath: `equityCompensationIssuance.${field}`, + convert: (value: unknown) => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + [field]: value, + }), + })), +]; + describe('DAML read converter date boundaries', () => { test('preserves the lexical date when an offset crosses the UTC day', () => { const result = damlStockClassSplitToNative({ @@ -86,4 +155,17 @@ describe('DAML read converter date boundaries', () => { OcpErrorCodes.INVALID_TYPE ); }); + + test.each(OPTIONAL_READ_DATE_CASES)('rejects a present empty $name', ({ convert, fieldPath }) => { + expectInvalidDate(() => convert(''), fieldPath, ''); + }); + + test.each(OPTIONAL_READ_DATE_CASES)('rejects a present non-string $name', ({ convert, fieldPath }) => { + const invalidDate = { seconds: 1 }; + expectInvalidDate(() => convert(invalidDate), fieldPath, invalidDate, OcpErrorCodes.INVALID_TYPE); + }); + + test.each(OPTIONAL_READ_DATE_CASES)('accepts a null $name as absent', ({ convert }) => { + expect(() => convert(null)).not.toThrow(); + }); }); diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 9eaae20d..9749c8ed 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -174,6 +174,36 @@ describe('WarrantIssuance round-trip equivalence', () => { } ); + test.each([ + ['', OcpErrorCodes.INVALID_FORMAT], + [{ seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a present invalid warrant_expiration_date on readback', (invalidDate, code) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + + try { + damlWarrantIssuanceDataToNative({ ...daml, warrant_expiration_date: invalidDate }); + throw new Error('Expected warrant expiration date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath: 'warrantIssuance.warrant_expiration_date', + receivedValue: invalidDate, + }); + } + }); + + test('omits a null or absent warrant_expiration_date on readback', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const withoutExpiration = { ...daml } as Record; + delete withoutExpiration.warrant_expiration_date; + + expect(damlWarrantIssuanceDataToNative({ ...daml, warrant_expiration_date: null }).warrant_expiration_date).toBe( + undefined + ); + expect(damlWarrantIssuanceDataToNative(withoutExpiration).warrant_expiration_date).toBeUndefined(); + }); + test.each(['trigger_date', 'start_date', 'end_date'] as const)( 'rejects a present non-string exercise trigger %s on readback', (field) => { From de8b39181837e52d3d67ccc44c3b9cf75ba8ae0d Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 16:44:53 -0400 Subject: [PATCH 25/47] Report convertible issuance date context --- .../createConvertibleIssuance.ts | 2 +- .../convertibleIssuanceConverters.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 93d7e1e4..a58bec84 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -470,7 +470,7 @@ export function convertibleIssuanceDataToDaml(d: { return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'convertibleIssuance.date'), security_id: d.security_id, custom_id: d.custom_id, stakeholder_id: d.stakeholder_id, diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 5d67254a..7e699cf9 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -550,6 +550,19 @@ describe('convertible issuance approval-date read boundaries', () => { }); describe('convertible issuance write date boundaries', () => { + test('reports the contextual field path for an invalid required date', () => { + expectInvalidDate( + () => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + date: '', + conversion_triggers: [SAFE_TRIGGER_BASE], + }), + 'convertibleIssuance.date', + '' + ); + }); + test.each(['board_approval_date', 'stockholder_approval_date'] as const)( 'validates a present %s instead of treating a falsy value as absent', (field) => { From baab60dfceef626c3b9d22f2456613e4f11ad99a Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 17:18:43 -0400 Subject: [PATCH 26/47] Enforce contextual OCF date boundaries --- .../convertibleAcceptanceDataToDaml.ts | 4 +- .../getConvertibleAcceptanceAsOcf.ts | 2 +- .../createConvertibleCancellation.ts | 2 +- .../convertibleCancellation/damlToOcf.ts | 2 +- .../convertibleConversionDataToDaml.ts | 2 +- .../convertibleConversion/damlToOcf.ts | 2 +- .../getConvertibleConversionAsOcf.ts | 4 +- .../createConvertibleIssuance.ts | 78 +++++----- .../getConvertibleIssuanceAsOcf.ts | 91 ++++------- .../convertibleRetractionDataToDaml.ts | 2 +- .../convertibleRetraction/damlToOcf.ts | 2 +- .../convertibleTransferDataToDaml.ts | 2 +- .../convertibleTransfer/damlToOcf.ts | 2 +- .../equityCompensationAcceptanceDataToDaml.ts | 4 +- .../getEquityCompensationAcceptanceAsOcf.ts | 2 +- .../createEquityCompensationCancellation.ts | 2 +- .../damlToOcf.ts | 2 +- .../createEquityCompensationExercise.ts | 2 +- .../getEquityCompensationExerciseAsOcf.ts | 6 - .../createEquityCompensationIssuance.ts | 18 ++- .../getEquityCompensationIssuanceAsOcf.ts | 40 ++--- .../equityCompensationRelease/damlToOcf.ts | 4 +- .../equityCompensationReleaseDataToDaml.ts | 4 +- .../equityCompensationRepricing/damlToOcf.ts | 2 +- .../equityCompensationRepricingDataToDaml.ts | 2 +- .../equityCompensationRetraction/damlToOcf.ts | 2 +- .../equityCompensationRetractionDataToDaml.ts | 2 +- .../equityCompensationTransfer/damlToOcf.ts | 2 +- .../equityCompensationTransferDataToDaml.ts | 2 +- .../OpenCapTable/issuer/createIssuer.ts | 2 +- .../OpenCapTable/issuer/getIssuerAsOcf.ts | 2 +- .../createIssuerAuthorizedSharesAdjustment.ts | 19 ++- ...etIssuerAuthorizedSharesAdjustmentAsOcf.ts | 37 ++--- .../planSecurityExerciseDataToDaml.ts | 2 +- .../planSecurityIssuanceDataToDaml.ts | 18 ++- .../damlToOcf.ts | 2 +- ...StakeholderRelationshipChangeEventAsOcf.ts | 3 +- ...holderRelationshipChangeEventDataToDaml.ts | 2 +- .../stakeholderStatusChangeEvent/damlToOcf.ts | 2 +- .../getStakeholderStatusChangeEventAsOcf.ts | 3 +- .../stakeholderStatusChangeEventDataToDaml.ts | 2 +- .../getStockAcceptanceAsOcf.ts | 2 +- .../stockAcceptanceDataToDaml.ts | 4 +- .../createStockCancellation.ts | 2 +- .../stockCancellation/damlToOcf.ts | 2 +- .../stockClass/getStockClassAsOcf.ts | 23 ++- .../stockClass/stockClassDataToDaml.ts | 11 +- ...ateStockClassAuthorizedSharesAdjustment.ts | 19 ++- ...ockClassAuthorizedSharesAdjustmentAsOcf.ts | 23 ++- ...lassConversionRatioAdjustmentDataToDaml.ts | 2 +- .../stockClassSplitDataToDaml.ts | 2 +- .../stockConsolidationDataToDaml.ts | 2 +- .../OpenCapTable/stockConversion/damlToOcf.ts | 2 +- .../getStockConversionAsOcf.ts | 4 +- .../stockConversionDataToDaml.ts | 2 +- .../stockIssuance/createStockIssuance.ts | 12 +- .../stockIssuance/getStockIssuanceAsOcf.ts | 25 +-- .../OpenCapTable/stockPlan/createStockPlan.ts | 9 +- .../stockPlan/getStockPlanAsOcf.ts | 16 +- .../createStockPlanPoolAdjustment.ts | 19 ++- .../getStockPlanPoolAdjustmentAsOcf.ts | 31 ++-- .../stockPlanReturnToPool/damlToOcf.ts | 2 +- .../stockPlanReturnToPoolDataToDaml.ts | 2 +- .../stockReissuanceDataToDaml.ts | 2 +- .../OpenCapTable/stockRepurchase/damlToOcf.ts | 2 +- .../stockRepurchaseDataToDaml.ts | 8 +- .../OpenCapTable/stockRetraction/damlToOcf.ts | 2 +- .../stockRetractionDataToDaml.ts | 2 +- .../stockTransfer/createStockTransfer.ts | 2 +- .../OpenCapTable/stockTransfer/damlToOcf.ts | 2 +- .../OpenCapTable/valuation/damlToOcf.ts | 20 ++- .../valuation/valuationDataToDaml.ts | 17 +- .../vestingAcceleration/damlToOcf.ts | 2 +- .../vestingAccelerationDataToDaml.ts | 2 +- .../OpenCapTable/vestingEvent/damlToOcf.ts | 2 +- .../vestingEvent/vestingEventDataToDaml.ts | 2 +- .../OpenCapTable/vestingStart/damlToOcf.ts | 2 +- .../vestingStart/vestingStartDataToDaml.ts | 2 +- .../vestingTerms/createVestingTerms.ts | 13 +- .../vestingTerms/getVestingTermsAsOcf.ts | 7 +- .../getWarrantAcceptanceAsOcf.ts | 2 +- .../warrantAcceptanceDataToDaml.ts | 4 +- .../createWarrantCancellation.ts | 2 +- .../warrantCancellation/damlToOcf.ts | 2 +- .../warrantExerciseDataToDaml.ts | 2 +- .../warrantIssuance/createWarrantIssuance.ts | 30 ++-- .../getWarrantIssuanceAsOcf.ts | 71 +++------ .../warrantRetraction/damlToOcf.ts | 2 +- .../warrantRetractionDataToDaml.ts | 2 +- .../OpenCapTable/warrantTransfer/damlToOcf.ts | 2 +- .../warrantTransferDataToDaml.ts | 2 +- src/utils/typeConversions.ts | 68 ++++++-- src/utils/typeGuards.ts | 7 +- src/utils/validation.ts | 14 +- .../convertibleIssuanceConverters.test.ts | 134 +++++++++++++--- .../converters/dateBoundaryValidation.test.ts | 130 ++++++++++++++++ .../converters/planSecurityConverters.test.ts | 34 +++- .../warrantIssuanceConverters.test.ts | 146 +++++++++++++++++- test/declarations/publicApi.types.ts | 23 ++- .../property/typeConversions.property.test.ts | 25 +-- .../dateBoundaryInvariants.test.ts | 143 +++++++++++++++++ test/utils/typeGuards.test.ts | 9 +- test/utils/validation.test.ts | 9 +- test/validation/typeCoercion.test.ts | 61 ++++++-- 104 files changed, 1132 insertions(+), 482 deletions(-) create mode 100644 test/schemaAlignment/dateBoundaryInvariants.test.ts diff --git a/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts index bdee117a..8571ec2a 100644 --- a/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts @@ -27,7 +27,7 @@ export function convertibleAcceptanceDataToDaml(d: OcfConvertibleAcceptance): Re } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'convertibleAcceptance.date'), security_id: d.security_id, comments: cleanComments(d.comments), }; @@ -43,7 +43,7 @@ export function damlConvertibleAcceptanceToNative(damlData: DamlConvertibleAccep return { object_type: 'TX_CONVERTIBLE_ACCEPTANCE', id: damlData.id, - date: damlTimeToDateString(damlData.date), + date: damlTimeToDateString(damlData.date, 'convertibleAcceptance.date'), security_id: damlData.security_id, ...(Array.isArray(damlData.comments) && damlData.comments.length > 0 ? { comments: damlData.comments } : {}), }; diff --git a/src/functions/OpenCapTable/convertibleAcceptance/getConvertibleAcceptanceAsOcf.ts b/src/functions/OpenCapTable/convertibleAcceptance/getConvertibleAcceptanceAsOcf.ts index 8c13f6b6..42ea8b9d 100644 --- a/src/functions/OpenCapTable/convertibleAcceptance/getConvertibleAcceptanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleAcceptance/getConvertibleAcceptanceAsOcf.ts @@ -63,7 +63,7 @@ export async function getConvertibleAcceptanceAsOcf( const event: OcfConvertibleAcceptanceEvent = { object_type: 'TX_CONVERTIBLE_ACCEPTANCE', id: data.id, - date: damlTimeToDateString(data.date), + date: damlTimeToDateString(data.date, 'convertibleAcceptance.date'), security_id: data.security_id, ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), }; diff --git a/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts b/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts index bb9658f9..506e4bba 100644 --- a/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts +++ b/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts @@ -7,7 +7,7 @@ export function convertibleCancellationDataToDaml(d: OcfConvertibleCancellation) security_id: d.security_id, amount: monetaryToDaml(d.amount), reason_text: d.reason_text, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'convertibleCancellation.date'), balance_security_id: optionalString(d.balance_security_id), comments: cleanComments(d.comments), }; diff --git a/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts b/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts index 80d67f88..bc8fc0e7 100644 --- a/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts @@ -43,7 +43,7 @@ export function damlConvertibleCancellationToNative(d: DamlConvertibleCancellati return { object_type: 'TX_CONVERTIBLE_CANCELLATION', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'convertibleCancellation.date'), security_id: d.security_id, amount: damlMonetaryToNative(d.amount), reason_text: d.reason_text, diff --git a/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts b/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts index d2100334..5186493c 100644 --- a/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts @@ -27,7 +27,7 @@ export function convertibleConversionDataToDaml(d: OcfConvertibleConversion): Re } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'convertibleConversion.date'), reason_text: d.reason_text, security_id: d.security_id, trigger_id: d.trigger_id, diff --git a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts index 9fc9a644..527ff6ee 100644 --- a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts @@ -32,7 +32,7 @@ export function damlConvertibleConversionToNative(d: DamlConvertibleConversionDa return { object_type: 'TX_CONVERTIBLE_CONVERSION', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'convertibleConversion.date'), reason_text: d.reason_text, security_id: d.security_id, trigger_id: d.trigger_id, diff --git a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts index b4665462..d369d072 100644 --- a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts @@ -6,7 +6,8 @@ import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; import type { DamlConvertibleConversionData } from './damlToOcf'; -type DamlConvertibleConversionInput = Pick & { +type DamlConvertibleConversionInput = Pick & { + date?: unknown; reason_text?: string | null; trigger_id?: string | null; resulting_security_ids?: string[] | null; @@ -21,7 +22,6 @@ function isDamlConvertibleConversionData(value: unknown): value is DamlConvertib return ( typeof value.id === 'string' && - typeof value.date === 'string' && typeof value.security_id === 'string' && (value.reason_text === undefined || value.reason_text === null || typeof value.reason_text === 'string') && (value.trigger_id === undefined || value.trigger_id === null || typeof value.trigger_id === 'string') && diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index a58bec84..938658a2 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -6,6 +6,7 @@ import { dateStringToDAMLTime, monetaryToDaml, normalizeNumericString, + optionalDateStringToDAMLTime, optionalString, safeString, } from '../../../utils/typeConversions'; @@ -177,28 +178,38 @@ function mechanismInputToDamlEnum( } case 'CONVERTIBLE_NOTE_CONVERSION': { const anyM = m as Record; + const interestRatePath = + 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[]'; const mapIR = ( arr: unknown ): Array<{ rate: unknown; - accrual_start_date: string | null; + accrual_start_date: string; accrual_end_date: string | null; }> => Array.isArray(arr) - ? arr.map((ir) => ({ - rate: ir?.rate != null ? normalizeNumericString(String(ir.rate)) : null, - accrual_start_date: - ir?.accrual_start_date !== null && ir?.accrual_start_date !== undefined - ? dateStringToDAMLTime( - ir.accrual_start_date, - 'convertibleIssuance.interest_rates[].accrual_start_date' - ) - : null, - accrual_end_date: - ir?.accrual_end_date !== null && ir?.accrual_end_date !== undefined - ? dateStringToDAMLTime(ir.accrual_end_date, 'convertibleIssuance.interest_rates[].accrual_end_date') - : null, - })) + ? arr.map((ir) => { + const accrualStartDate: unknown = ir?.accrual_start_date; + if (accrualStartDate === null || accrualStartDate === undefined) { + throw new OcpValidationError( + `${interestRatePath}.accrual_start_date`, + 'accrual_start_date is required for each convertible note interest rate', + { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: accrualStartDate, + } + ); + } + + return { + rate: ir?.rate != null ? normalizeNumericString(String(ir.rate)) : null, + accrual_start_date: dateStringToDAMLTime(accrualStartDate, `${interestRatePath}.accrual_start_date`), + accrual_end_date: optionalDateStringToDAMLTime( + ir?.accrual_end_date, + `${interestRatePath}.accrual_end_date` + ), + }; + }) : []; const accrualToDaml = (v: unknown): string => { const s = safeString(v).toUpperCase(); @@ -419,29 +430,20 @@ function buildTriggerToDaml(t: ConversionTriggerInput, _index: number, _issuance const { trigger_id } = t; const nickname = typeof t === 'object' && t.nickname ? t.nickname : null; const trigger_description = typeof t === 'object' && t.trigger_description ? t.trigger_description : null; - const triggerDate: unknown = t.trigger_date; const trigger_condition = typeof t === 'object' && t.trigger_condition ? t.trigger_condition : null; const conversion_right = buildConvertibleRight(t); - const startDate: unknown = t.start_date; - const endDate: unknown = t.end_date; - const start_date = - startDate === null || startDate === undefined - ? null - : dateStringToDAMLTime(startDate, 'convertibleIssuance.conversion_triggers[].start_date'); - const end_date = - endDate === null || endDate === undefined - ? null - : dateStringToDAMLTime(endDate, 'convertibleIssuance.conversion_triggers[].end_date'); + const start_date = optionalDateStringToDAMLTime(t.start_date, 'convertibleIssuance.conversion_triggers[].start_date'); + const end_date = optionalDateStringToDAMLTime(t.end_date, 'convertibleIssuance.conversion_triggers[].end_date'); return { type_: typeEnum, trigger_id, nickname, trigger_description, conversion_right, - trigger_date: - triggerDate === null || triggerDate === undefined - ? null - : dateStringToDAMLTime(triggerDate, 'convertibleIssuance.conversion_triggers[].trigger_date'), + trigger_date: optionalDateStringToDAMLTime( + t.trigger_date, + 'convertibleIssuance.conversion_triggers[].trigger_date' + ), trigger_condition, start_date, end_date, @@ -465,23 +467,17 @@ export function convertibleIssuanceDataToDaml(d: { seniority: number; comments?: string[]; }): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData { - const boardApprovalDate: unknown = d.board_approval_date; - const stockholderApprovalDate: unknown = d.stockholder_approval_date; - return { id: d.id, date: dateStringToDAMLTime(d.date, 'convertibleIssuance.date'), security_id: d.security_id, custom_id: d.custom_id, stakeholder_id: d.stakeholder_id, - board_approval_date: - boardApprovalDate === null || boardApprovalDate === undefined - ? null - : dateStringToDAMLTime(boardApprovalDate, 'convertibleIssuance.board_approval_date'), - stockholder_approval_date: - stockholderApprovalDate === null || stockholderApprovalDate === undefined - ? null - : dateStringToDAMLTime(stockholderApprovalDate, 'convertibleIssuance.stockholder_approval_date'), + board_approval_date: optionalDateStringToDAMLTime(d.board_approval_date, 'convertibleIssuance.board_approval_date'), + stockholder_approval_date: optionalDateStringToDAMLTime( + d.stockholder_approval_date, + 'convertibleIssuance.stockholder_approval_date' + ), consideration_text: optionalString(d.consideration_text), security_law_exemptions: d.security_law_exemptions, investment_amount: monetaryToDaml(d.investment_amount), diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 7371f615..dd461b0d 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -11,6 +11,7 @@ import { damlTimeToDateString, mapDamlTriggerTypeToOcf, normalizeNumericString, + optionalDamlTimeToDateString, safeString, } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; @@ -334,18 +335,20 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers return mech; } case 'OcfConvMechNote': { + const interestRatePath = + 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[]'; const interest_rates = Array.isArray(value.interest_rates) ? value.interest_rates.map((ir: unknown) => { const irObj = ir as Record; // Validate interest rate if (irObj.rate === undefined || irObj.rate === null) { - throw new OcpValidationError('interest_rate.rate', 'Required field is missing', { + throw new OcpValidationError(`${interestRatePath}.rate`, 'Required field is missing', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, }); } if (typeof irObj.rate !== 'string' && typeof irObj.rate !== 'number') { throw new OcpValidationError( - 'interest_rate.rate', + `${interestRatePath}.rate`, `Must be string or number, got ${typeof irObj.rate}`, { code: OcpErrorCodes.INVALID_TYPE, @@ -354,32 +357,17 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers } ); } - // Validate accrual_start_date - if (typeof irObj.accrual_start_date !== 'string' || !irObj.accrual_start_date) { - throw new OcpValidationError( - 'interest_rate.accrual_start_date', - 'Required field must be a non-empty string', - { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'string', - receivedValue: irObj.accrual_start_date, - } - ); - } + const accrualEndDate = optionalDamlTimeToDateString( + irObj.accrual_end_date, + `${interestRatePath}.accrual_end_date` + ); return { rate: normalizeNumericString(typeof irObj.rate === 'number' ? irObj.rate.toString() : irObj.rate), accrual_start_date: damlTimeToDateString( irObj.accrual_start_date, - 'convertibleIssuance.interest_rates[].accrual_start_date' + `${interestRatePath}.accrual_start_date` ), - ...(irObj.accrual_end_date !== null && irObj.accrual_end_date !== undefined - ? { - accrual_end_date: damlTimeToDateString( - irObj.accrual_end_date, - 'convertibleIssuance.interest_rates[].accrual_end_date' - ), - } - : {}), + ...(accrualEndDate !== undefined ? { accrual_end_date: accrualEndDate } : {}), }; }) : null; @@ -521,20 +509,17 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers const nickname: string | undefined = typeof r.nickname === 'string' && r.nickname.length ? r.nickname : undefined; const trigger_description: string | undefined = typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; - const trigger_date: string | undefined = - r.trigger_date === null || r.trigger_date === undefined - ? undefined - : damlTimeToDateString(r.trigger_date, 'convertibleIssuance.conversion_triggers[].trigger_date'); + const trigger_date = optionalDamlTimeToDateString( + r.trigger_date, + 'convertibleIssuance.conversion_triggers[].trigger_date' + ); const trigger_condition: string | undefined = typeof r.trigger_condition === 'string' && r.trigger_condition.length ? r.trigger_condition : undefined; - const start_date: string | undefined = - r.start_date === null || r.start_date === undefined - ? undefined - : damlTimeToDateString(r.start_date, 'convertibleIssuance.conversion_triggers[].start_date'); - const end_date: string | undefined = - r.end_date === null || r.end_date === undefined - ? undefined - : damlTimeToDateString(r.end_date, 'convertibleIssuance.conversion_triggers[].end_date'); + const start_date = optionalDamlTimeToDateString( + r.start_date, + 'convertibleIssuance.conversion_triggers[].start_date' + ); + const end_date = optionalDamlTimeToDateString(r.end_date, 'convertibleIssuance.conversion_triggers[].end_date'); // Parse conversion_right if present and convertible variant is used let conversion_right: ConvertibleConversionRight | undefined; @@ -584,10 +569,10 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers conversion_right, ...(nickname ? { nickname } : {}), ...(trigger_description ? { trigger_description } : {}), - ...(trigger_date ? { trigger_date } : {}), + ...(trigger_date !== undefined ? { trigger_date } : {}), ...(trigger_condition ? { trigger_condition } : {}), - ...(start_date ? { start_date } : {}), - ...(end_date ? { end_date } : {}), + ...(start_date !== undefined ? { start_date } : {}), + ...(end_date !== undefined ? { end_date } : {}), }; return trigger; }); @@ -602,12 +587,6 @@ export function damlConvertibleIssuanceDataToNative(d: Record): receivedValue: d.id, }); } - if (typeof d.date !== 'string' || !d.date) { - throw new OcpValidationError('convertibleIssuance.date', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.date, - }); - } if (typeof d.security_id !== 'string' || !d.security_id) { throw new OcpValidationError('convertibleIssuance.security_id', 'Required field is missing or invalid', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -648,6 +627,15 @@ export function damlConvertibleIssuanceDataToNative(d: Record): const investmentAmountStr = typeof investmentAmount.amount === 'number' ? investmentAmount.amount.toString() : investmentAmount.amount; + const boardApprovalDate = optionalDamlTimeToDateString( + d.board_approval_date, + 'convertibleIssuance.board_approval_date' + ); + const stockholderApprovalDate = optionalDamlTimeToDateString( + d.stockholder_approval_date, + 'convertibleIssuance.stockholder_approval_date' + ); + const issuance = { object_type: 'TX_CONVERTIBLE_ISSUANCE', id: d.id, @@ -655,19 +643,8 @@ export function damlConvertibleIssuanceDataToNative(d: Record): security_id: d.security_id, custom_id: d.custom_id as string, stakeholder_id: d.stakeholder_id as string, - ...(d.board_approval_date !== null && d.board_approval_date !== undefined - ? { - board_approval_date: damlTimeToDateString(d.board_approval_date, 'convertibleIssuance.board_approval_date'), - } - : {}), - ...(d.stockholder_approval_date !== null && d.stockholder_approval_date !== undefined - ? { - stockholder_approval_date: damlTimeToDateString( - d.stockholder_approval_date, - 'convertibleIssuance.stockholder_approval_date' - ), - } - : {}), + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), investment_amount: { amount: normalizeNumericString(investmentAmountStr), currency: investmentAmount.currency, diff --git a/src/functions/OpenCapTable/convertibleRetraction/convertibleRetractionDataToDaml.ts b/src/functions/OpenCapTable/convertibleRetraction/convertibleRetractionDataToDaml.ts index 18c63df6..68485aa6 100644 --- a/src/functions/OpenCapTable/convertibleRetraction/convertibleRetractionDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleRetraction/convertibleRetractionDataToDaml.ts @@ -22,7 +22,7 @@ export function convertibleRetractionDataToDaml(d: OcfConvertibleRetraction): Re } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'convertibleRetraction.date'), security_id: d.security_id, reason_text: d.reason_text, comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/convertibleRetraction/damlToOcf.ts b/src/functions/OpenCapTable/convertibleRetraction/damlToOcf.ts index 6f244537..4118f769 100644 --- a/src/functions/OpenCapTable/convertibleRetraction/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleRetraction/damlToOcf.ts @@ -27,7 +27,7 @@ export function damlConvertibleRetractionToNative(d: DamlConvertibleRetractionDa return { object_type: 'TX_CONVERTIBLE_RETRACTION', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'convertibleRetraction.date'), security_id: d.security_id, reason_text: d.reason_text, ...(d.comments.length > 0 && { comments: d.comments }), diff --git a/src/functions/OpenCapTable/convertibleTransfer/convertibleTransferDataToDaml.ts b/src/functions/OpenCapTable/convertibleTransfer/convertibleTransferDataToDaml.ts index ca259429..d1dbe9b8 100644 --- a/src/functions/OpenCapTable/convertibleTransfer/convertibleTransferDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleTransfer/convertibleTransferDataToDaml.ts @@ -18,7 +18,7 @@ export function convertibleTransferDataToDaml(d: OcfConvertibleTransfer): Record } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'convertibleTransfer.date'), security_id: d.security_id, amount: monetaryToDaml(d.amount), resulting_security_ids: d.resulting_security_ids, diff --git a/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts b/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts index 80a9a2dd..dbad8dad 100644 --- a/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts @@ -30,7 +30,7 @@ export function damlConvertibleTransferToNative(d: DamlConvertibleTransferData): return { object_type: 'TX_CONVERTIBLE_TRANSFER', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'convertibleTransfer.date'), security_id: d.security_id, amount: damlMonetaryToNative(d.amount), resulting_security_ids: d.resulting_security_ids, diff --git a/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts index 1b55853d..fc0a7044 100644 --- a/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts +++ b/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts @@ -27,7 +27,7 @@ export function equityCompensationAcceptanceDataToDaml(d: OcfEquityCompensationA } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'equityCompensationAcceptance.date'), security_id: d.security_id, comments: cleanComments(d.comments), }; @@ -45,7 +45,7 @@ export function damlEquityCompensationAcceptanceToNative( return { object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', id: damlData.id, - date: damlTimeToDateString(damlData.date), + date: damlTimeToDateString(damlData.date, 'equityCompensationAcceptance.date'), security_id: damlData.security_id, ...(Array.isArray(damlData.comments) && damlData.comments.length > 0 ? { comments: damlData.comments } : {}), }; diff --git a/src/functions/OpenCapTable/equityCompensationAcceptance/getEquityCompensationAcceptanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationAcceptance/getEquityCompensationAcceptanceAsOcf.ts index af14203f..87f782cc 100644 --- a/src/functions/OpenCapTable/equityCompensationAcceptance/getEquityCompensationAcceptanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationAcceptance/getEquityCompensationAcceptanceAsOcf.ts @@ -63,7 +63,7 @@ export async function getEquityCompensationAcceptanceAsOcf( const event: OcfEquityCompensationAcceptanceEvent = { object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', id: data.id, - date: damlTimeToDateString(data.date), + date: damlTimeToDateString(data.date, 'equityCompensationAcceptance.date'), security_id: data.security_id, ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), }; diff --git a/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts b/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts index eb8326dc..cd4e7da7 100644 --- a/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts +++ b/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts @@ -13,7 +13,7 @@ export function equityCompensationCancellationDataToDaml( id: d.id, security_id: d.security_id, reason_text: d.reason_text, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'equityCompensationCancellation.date'), quantity: normalizeNumericString(d.quantity), balance_security_id: optionalString(d.balance_security_id), comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts b/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts index fded1cf5..ac047fc0 100644 --- a/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts @@ -21,7 +21,7 @@ export function damlEquityCompensationCancellationToNative( d: DamlEquityCompensationCancellationData ): OcfEquityCompensationCancellation { return { - ...quantityCancellationToNative(d), + ...quantityCancellationToNative(d, 'equityCompensationCancellation.date'), object_type: 'TX_EQUITY_COMPENSATION_CANCELLATION', }; } diff --git a/src/functions/OpenCapTable/equityCompensationExercise/createEquityCompensationExercise.ts b/src/functions/OpenCapTable/equityCompensationExercise/createEquityCompensationExercise.ts index 4fbbdaeb..eb04e818 100644 --- a/src/functions/OpenCapTable/equityCompensationExercise/createEquityCompensationExercise.ts +++ b/src/functions/OpenCapTable/equityCompensationExercise/createEquityCompensationExercise.ts @@ -10,7 +10,7 @@ export function equityCompensationExerciseDataToDaml(d: OcfEquityCompensationExe return { id: d.id, security_id: d.security_id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'equityCompensationExercise.date'), quantity: normalizeNumericString(d.quantity), consideration_text: optionalString(d.consideration_text), resulting_security_ids: d.resulting_security_ids, diff --git a/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts b/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts index c7b09d8f..c9aa6df8 100644 --- a/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts @@ -17,12 +17,6 @@ export function damlEquityCompensationExerciseDataToNative(d: Record ({ description: e.description, @@ -98,10 +106,10 @@ export function equityCompensationIssuanceDataToDaml( base_price: d.base_price ? monetaryToDaml(d.base_price) : null, early_exercisable: d.early_exercisable ?? null, vestings: filteredVestings.map((v) => ({ - date: dateStringToDAMLTime(v.date), + date: dateStringToDAMLTime(v.date, 'equityCompensationIssuance.vestings[].date'), amount: normalizeNumericString(v.amount), })), - expiration_date: d.expiration_date ? dateStringToDAMLTime(d.expiration_date) : null, + expiration_date: nullableDateStringToDAMLTime(d.expiration_date, 'equityCompensationIssuance.expiration_date'), termination_exercise_windows: d.termination_exercise_windows.map((w) => ({ reason: terminationWindowReasonMap[w.reason], period: w.period.toString(), diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 214deb08..65996b53 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -12,6 +12,8 @@ import { damlMonetaryToNativeWithValidation, damlTimeToDateString, normalizeNumericString, + nullableDamlTimeToDateString, + optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; @@ -129,12 +131,6 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record 0 && { comments: d.comments }), diff --git a/src/functions/OpenCapTable/equityCompensationRelease/equityCompensationReleaseDataToDaml.ts b/src/functions/OpenCapTable/equityCompensationRelease/equityCompensationReleaseDataToDaml.ts index c457cfb7..f9c59ef0 100644 --- a/src/functions/OpenCapTable/equityCompensationRelease/equityCompensationReleaseDataToDaml.ts +++ b/src/functions/OpenCapTable/equityCompensationRelease/equityCompensationReleaseDataToDaml.ts @@ -28,11 +28,11 @@ export function equityCompensationReleaseDataToDaml(d: OcfEquityCompensationRele } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'equityCompensationRelease.date'), security_id: d.security_id, quantity: normalizeNumericString(d.quantity), release_price: monetaryToDaml(d.release_price), - settlement_date: dateStringToDAMLTime(d.settlement_date), + settlement_date: dateStringToDAMLTime(d.settlement_date, 'equityCompensationRelease.settlement_date'), resulting_security_ids: d.resulting_security_ids, consideration_text: optionalString(d.consideration_text), comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/equityCompensationRepricing/damlToOcf.ts b/src/functions/OpenCapTable/equityCompensationRepricing/damlToOcf.ts index 0d0bab41..0af01bc0 100644 --- a/src/functions/OpenCapTable/equityCompensationRepricing/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationRepricing/damlToOcf.ts @@ -31,7 +31,7 @@ export function damlEquityCompensationRepricingToNative( return { object_type: 'TX_EQUITY_COMPENSATION_REPRICING', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'equityCompensationRepricing.date'), security_id: d.security_id, new_exercise_price: damlMonetaryToNative(d.new_exercise_price), ...(d.comments.length > 0 && { comments: d.comments }), diff --git a/src/functions/OpenCapTable/equityCompensationRepricing/equityCompensationRepricingDataToDaml.ts b/src/functions/OpenCapTable/equityCompensationRepricing/equityCompensationRepricingDataToDaml.ts index e9d1e746..9aa4b05d 100644 --- a/src/functions/OpenCapTable/equityCompensationRepricing/equityCompensationRepricingDataToDaml.ts +++ b/src/functions/OpenCapTable/equityCompensationRepricing/equityCompensationRepricingDataToDaml.ts @@ -22,7 +22,7 @@ export function equityCompensationRepricingDataToDaml(d: OcfEquityCompensationRe } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'equityCompensationRepricing.date'), security_id: d.security_id, new_exercise_price: monetaryToDaml(d.new_exercise_price), comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/equityCompensationRetraction/damlToOcf.ts b/src/functions/OpenCapTable/equityCompensationRetraction/damlToOcf.ts index 29f104b1..3649ac19 100644 --- a/src/functions/OpenCapTable/equityCompensationRetraction/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationRetraction/damlToOcf.ts @@ -29,7 +29,7 @@ export function damlEquityCompensationRetractionToNative( return { object_type: 'TX_EQUITY_COMPENSATION_RETRACTION', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'equityCompensationRetraction.date'), security_id: d.security_id, reason_text: d.reason_text, ...(d.comments.length > 0 && { comments: d.comments }), diff --git a/src/functions/OpenCapTable/equityCompensationRetraction/equityCompensationRetractionDataToDaml.ts b/src/functions/OpenCapTable/equityCompensationRetraction/equityCompensationRetractionDataToDaml.ts index 106c8983..47bcbbe7 100644 --- a/src/functions/OpenCapTable/equityCompensationRetraction/equityCompensationRetractionDataToDaml.ts +++ b/src/functions/OpenCapTable/equityCompensationRetraction/equityCompensationRetractionDataToDaml.ts @@ -22,7 +22,7 @@ export function equityCompensationRetractionDataToDaml(d: OcfEquityCompensationR } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'equityCompensationRetraction.date'), security_id: d.security_id, reason_text: d.reason_text, comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/equityCompensationTransfer/damlToOcf.ts b/src/functions/OpenCapTable/equityCompensationTransfer/damlToOcf.ts index c427e816..d5a936c3 100644 --- a/src/functions/OpenCapTable/equityCompensationTransfer/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationTransfer/damlToOcf.ts @@ -21,7 +21,7 @@ export function damlEquityCompensationTransferToNative( d: DamlEquityCompensationTransferData ): OcfEquityCompensationTransfer { return { - ...quantityTransferToNative(d), + ...quantityTransferToNative(d, 'equityCompensationTransfer.date'), object_type: 'TX_EQUITY_COMPENSATION_TRANSFER', }; } diff --git a/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts b/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts index 5e9af620..8e66aea9 100644 --- a/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts +++ b/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts @@ -24,7 +24,7 @@ export function equityCompensationTransferDataToDaml(d: OcfEquityCompensationTra } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'equityCompensationTransfer.date'), security_id: d.security_id, quantity: normalizeNumericString(d.quantity), resulting_security_ids: d.resulting_security_ids, diff --git a/src/functions/OpenCapTable/issuer/createIssuer.ts b/src/functions/OpenCapTable/issuer/createIssuer.ts index 956351cb..f6752429 100644 --- a/src/functions/OpenCapTable/issuer/createIssuer.ts +++ b/src/functions/OpenCapTable/issuer/createIssuer.ts @@ -107,7 +107,7 @@ function issuerDataToDamlInternal( legal_name: normalizedData.legal_name, country_of_formation: normalizedData.country_of_formation, dba: optionalString(normalizedData.dba), - formation_date: dateStringToDAMLTime(normalizedData.formation_date), + formation_date: dateStringToDAMLTime(normalizedData.formation_date, 'issuer.formation_date'), country_subdivision_of_formation: optionalString(normalizedData.country_subdivision_of_formation), country_subdivision_name_of_formation: optionalString(normalizedData.country_subdivision_name_of_formation), tax_ids: normalizedData.tax_ids ?? [], diff --git a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts index 15a4f06f..846bff91 100644 --- a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts +++ b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts @@ -47,7 +47,7 @@ export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issue id: dataWithId.id, legal_name: damlData.legal_name, country_of_formation: damlData.country_of_formation, - formation_date: damlTimeToDateString(damlData.formation_date), + formation_date: damlTimeToDateString(damlData.formation_date, 'issuer.formation_date'), tax_ids: [], comments: [], }; diff --git a/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment.ts b/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment.ts index f981dc29..56c8573c 100644 --- a/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment.ts +++ b/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment.ts @@ -1,5 +1,10 @@ import type { OcfIssuerAuthorizedSharesAdjustment } from '../../../types'; -import { cleanComments, dateStringToDAMLTime, normalizeNumericString } from '../../../utils/typeConversions'; +import { + cleanComments, + dateStringToDAMLTime, + normalizeNumericString, + optionalDateStringToDAMLTime, +} from '../../../utils/typeConversions'; export function issuerAuthorizedSharesAdjustmentDataToDaml( d: OcfIssuerAuthorizedSharesAdjustment @@ -7,10 +12,16 @@ export function issuerAuthorizedSharesAdjustmentDataToDaml( return { id: d.id, issuer_id: d.issuer_id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'issuerAuthorizedSharesAdjustment.date'), new_shares_authorized: normalizeNumericString(d.new_shares_authorized), - board_approval_date: d.board_approval_date ? dateStringToDAMLTime(d.board_approval_date) : null, - stockholder_approval_date: d.stockholder_approval_date ? dateStringToDAMLTime(d.stockholder_approval_date) : null, + board_approval_date: optionalDateStringToDAMLTime( + d.board_approval_date, + 'issuerAuthorizedSharesAdjustment.board_approval_date' + ), + stockholder_approval_date: optionalDateStringToDAMLTime( + d.stockholder_approval_date, + 'issuerAuthorizedSharesAdjustment.stockholder_approval_date' + ), comments: cleanComments(d.comments), }; } diff --git a/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf.ts b/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf.ts index 3a0c890d..a3092081 100644 --- a/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf.ts @@ -2,7 +2,11 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfIssuerAuthorizedSharesAdjustment } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { + damlTimeToDateString, + normalizeNumericString, + optionalDamlTimeToDateString, +} from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; export interface GetIssuerAuthorizedSharesAdjustmentAsOcfParams extends GetByContractIdParams {} @@ -40,11 +44,14 @@ export function damlIssuerAuthorizedSharesAdjustmentDataToNative( `Must be string or number, got ${typeof d.new_shares_authorized}`, { code: OcpErrorCodes.INVALID_TYPE, expectedType: 'string | number', receivedValue: d.new_shares_authorized } ); - if (!d.date || typeof d.date !== 'string') - throw new OcpValidationError('issuerAuthorizedSharesAdjustment.date', 'Missing or invalid date', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.date, - }); + const boardApprovalDate = optionalDamlTimeToDateString( + d.board_approval_date, + 'issuerAuthorizedSharesAdjustment.board_approval_date' + ); + const stockholderApprovalDate = optionalDamlTimeToDateString( + d.stockholder_approval_date, + 'issuerAuthorizedSharesAdjustment.stockholder_approval_date' + ); return { object_type: 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT', @@ -54,22 +61,8 @@ export function damlIssuerAuthorizedSharesAdjustmentDataToNative( new_shares_authorized: normalizeNumericString( typeof d.new_shares_authorized === 'number' ? String(d.new_shares_authorized) : d.new_shares_authorized ), - ...(d.board_approval_date !== null && d.board_approval_date !== undefined - ? { - board_approval_date: damlTimeToDateString( - d.board_approval_date, - 'issuerAuthorizedSharesAdjustment.board_approval_date' - ), - } - : {}), - ...(d.stockholder_approval_date !== null && d.stockholder_approval_date !== undefined - ? { - stockholder_approval_date: damlTimeToDateString( - d.stockholder_approval_date, - 'issuerAuthorizedSharesAdjustment.stockholder_approval_date' - ), - } - : {}), + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), ...(Array.isArray(d.comments) && d.comments.length ? { comments: d.comments } : {}), }; } diff --git a/src/functions/OpenCapTable/planSecurityExercise/planSecurityExerciseDataToDaml.ts b/src/functions/OpenCapTable/planSecurityExercise/planSecurityExerciseDataToDaml.ts index f24d75ec..377ae735 100644 --- a/src/functions/OpenCapTable/planSecurityExercise/planSecurityExerciseDataToDaml.ts +++ b/src/functions/OpenCapTable/planSecurityExercise/planSecurityExerciseDataToDaml.ts @@ -36,7 +36,7 @@ export function planSecurityExerciseDataToDaml(d: OcfPlanSecurityExercise): Reco return { id: d.id, security_id: d.security_id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'planSecurityExercise.date'), quantity: normalizeNumericString(d.quantity), consideration_text: optionalString(d.consideration_text), resulting_security_ids: d.resulting_security_ids, diff --git a/src/functions/OpenCapTable/planSecurityIssuance/planSecurityIssuanceDataToDaml.ts b/src/functions/OpenCapTable/planSecurityIssuance/planSecurityIssuanceDataToDaml.ts index 2cae405c..fbc4208a 100644 --- a/src/functions/OpenCapTable/planSecurityIssuance/planSecurityIssuanceDataToDaml.ts +++ b/src/functions/OpenCapTable/planSecurityIssuance/planSecurityIssuanceDataToDaml.ts @@ -12,6 +12,8 @@ import { dateStringToDAMLTime, monetaryToDaml, normalizeNumericString, + nullableDateStringToDAMLTime, + optionalDateStringToDAMLTime, optionalString, } from '../../../utils/typeConversions'; import { @@ -48,9 +50,15 @@ export function planSecurityIssuanceDataToDaml(d: OcfPlanSecurityIssuance): Reco security_id: d.security_id, custom_id: d.custom_id, stakeholder_id: d.stakeholder_id, - date: dateStringToDAMLTime(d.date), - board_approval_date: d.board_approval_date ? dateStringToDAMLTime(d.board_approval_date) : null, - stockholder_approval_date: d.stockholder_approval_date ? dateStringToDAMLTime(d.stockholder_approval_date) : null, + date: dateStringToDAMLTime(d.date, 'planSecurityIssuance.date'), + board_approval_date: optionalDateStringToDAMLTime( + d.board_approval_date, + 'planSecurityIssuance.board_approval_date' + ), + stockholder_approval_date: optionalDateStringToDAMLTime( + d.stockholder_approval_date, + 'planSecurityIssuance.stockholder_approval_date' + ), consideration_text: optionalString(d.consideration_text), security_law_exemptions: d.security_law_exemptions.map((e) => ({ description: e.description, @@ -65,10 +73,10 @@ export function planSecurityIssuanceDataToDaml(d: OcfPlanSecurityIssuance): Reco base_price: d.base_price ? monetaryToDaml(d.base_price) : null, early_exercisable: d.early_exercisable ?? null, vestings: filteredVestings.map((v) => ({ - date: dateStringToDAMLTime(v.date), + date: dateStringToDAMLTime(v.date, 'planSecurityIssuance.vestings[].date'), amount: normalizeNumericString(v.amount), })), - expiration_date: d.expiration_date ? dateStringToDAMLTime(d.expiration_date) : null, + expiration_date: nullableDateStringToDAMLTime(d.expiration_date, 'planSecurityIssuance.expiration_date'), termination_exercise_windows: d.termination_exercise_windows.map((w) => ({ reason: terminationWindowReasonMap[w.reason], period: w.period.toString(), diff --git a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts index a5b5c16d..3253951e 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts @@ -34,7 +34,7 @@ export function damlStakeholderRelationshipChangeEventToNative( return { object_type: 'CE_STAKEHOLDER_RELATIONSHIP', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'stakeholderRelationshipChangeEvent.date'), stakeholder_id: d.stakeholder_id, ...(d.relationship_started ? { relationship_started: damlStakeholderRelationshipToNative(d.relationship_started) } diff --git a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts index 222af385..44247fdb 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts @@ -27,7 +27,7 @@ export interface GetStakeholderRelationshipChangeEventAsOcfResult { /** Type for DAML StakeholderRelationshipChangeEvent createArgument */ interface DamlStakeholderRelationshipChangeEventData { id: string; - date: string; + date?: unknown; stakeholder_id: string; relationship_started: DamlStakeholderRelationshipType | null; relationship_ended: DamlStakeholderRelationshipType | null; @@ -64,7 +64,6 @@ function isDamlStakeholderRelationshipChangeEventData( return ( typeof value.id === 'string' && - typeof value.date === 'string' && typeof value.stakeholder_id === 'string' && (typeof value.relationship_started === 'string' || value.relationship_started === null) && (typeof value.relationship_ended === 'string' || value.relationship_ended === null) && diff --git a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts index 5048e696..0d83bc1d 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts @@ -63,7 +63,7 @@ export function stakeholderRelationshipChangeEventDataToDaml( return { id: data.id, - date: dateStringToDAMLTime(data.date), + date: dateStringToDAMLTime(data.date, 'stakeholderRelationshipChangeEvent.date'), stakeholder_id: data.stakeholder_id, relationship_started: relationshipStarted ? stakeholderRelationshipTypeToDaml(relationshipStarted) : null, relationship_ended: relationshipEnded ? stakeholderRelationshipTypeToDaml(relationshipEnded) : null, diff --git a/src/functions/OpenCapTable/stakeholderStatusChangeEvent/damlToOcf.ts b/src/functions/OpenCapTable/stakeholderStatusChangeEvent/damlToOcf.ts index 45a6e535..ce58f9bb 100644 --- a/src/functions/OpenCapTable/stakeholderStatusChangeEvent/damlToOcf.ts +++ b/src/functions/OpenCapTable/stakeholderStatusChangeEvent/damlToOcf.ts @@ -32,7 +32,7 @@ export function damlStakeholderStatusChangeEventToNative( return { object_type: 'CE_STAKEHOLDER_STATUS', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'stakeholderStatusChangeEvent.date'), stakeholder_id: d.stakeholder_id, new_status: damlStakeholderStatusToNative(d.new_status), ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}), diff --git a/src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf.ts b/src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf.ts index 0daa4087..c97afb59 100644 --- a/src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf.ts +++ b/src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf.ts @@ -25,7 +25,7 @@ export interface GetStakeholderStatusChangeEventAsOcfResult { /** Type for DAML StakeholderStatusChangeEvent createArgument */ interface DamlStakeholderStatusChangeEventData { id: string; - date: string; + date?: unknown; stakeholder_id: string; new_status: Fairmint.OpenCapTable.OCF.Stakeholder.OcfStakeholderStatusType; comments: string[]; @@ -41,7 +41,6 @@ function isDamlStakeholderStatusChangeEventData(value: unknown): value is DamlSt return ( typeof value.id === 'string' && - typeof value.date === 'string' && typeof value.stakeholder_id === 'string' && typeof value.new_status === 'string' && Array.isArray(value.comments) && diff --git a/src/functions/OpenCapTable/stakeholderStatusChangeEvent/stakeholderStatusChangeEventDataToDaml.ts b/src/functions/OpenCapTable/stakeholderStatusChangeEvent/stakeholderStatusChangeEventDataToDaml.ts index 9cc2e297..b38818d1 100644 --- a/src/functions/OpenCapTable/stakeholderStatusChangeEvent/stakeholderStatusChangeEventDataToDaml.ts +++ b/src/functions/OpenCapTable/stakeholderStatusChangeEvent/stakeholderStatusChangeEventDataToDaml.ts @@ -22,7 +22,7 @@ export function stakeholderStatusChangeEventDataToDaml(data: OcfStakeholderStatu } return { id: data.id, - date: dateStringToDAMLTime(data.date), + date: dateStringToDAMLTime(data.date, 'stakeholderStatusChangeEvent.date'), stakeholder_id: data.stakeholder_id, new_status: stakeholderStatusToDaml(data.new_status), comments: cleanComments(data.comments), diff --git a/src/functions/OpenCapTable/stockAcceptance/getStockAcceptanceAsOcf.ts b/src/functions/OpenCapTable/stockAcceptance/getStockAcceptanceAsOcf.ts index 68f5359a..be541335 100644 --- a/src/functions/OpenCapTable/stockAcceptance/getStockAcceptanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockAcceptance/getStockAcceptanceAsOcf.ts @@ -63,7 +63,7 @@ export async function getStockAcceptanceAsOcf( const event: OcfStockAcceptanceEvent = { object_type: 'TX_STOCK_ACCEPTANCE', id: data.id, - date: damlTimeToDateString(data.date), + date: damlTimeToDateString(data.date, 'stockAcceptance.date'), security_id: data.security_id, ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), }; diff --git a/src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml.ts index 6a9b4e2f..4e77d824 100644 --- a/src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml.ts +++ b/src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml.ts @@ -27,7 +27,7 @@ export function stockAcceptanceDataToDaml(d: OcfStockAcceptance): Record 0 ? { comments: damlData.comments } : {}), }; diff --git a/src/functions/OpenCapTable/stockCancellation/createStockCancellation.ts b/src/functions/OpenCapTable/stockCancellation/createStockCancellation.ts index 4cea8a5c..f9e18928 100644 --- a/src/functions/OpenCapTable/stockCancellation/createStockCancellation.ts +++ b/src/functions/OpenCapTable/stockCancellation/createStockCancellation.ts @@ -11,7 +11,7 @@ export function stockCancellationDataToDaml(d: OcfStockCancellation): Record { @@ -207,7 +210,7 @@ export function stockClassDataToDaml(stockClassData: OcfStockClass): Record & { +type DamlStockConversionInput = Pick & { + date?: unknown; quantity_converted?: string | number; resulting_security_ids?: unknown; comments?: unknown; @@ -18,7 +19,6 @@ function isDamlStockConversionData(value: unknown): value is DamlStockConversion return ( typeof value.id === 'string' && - typeof value.date === 'string' && typeof value.security_id === 'string' && (value.balance_security_id === undefined || value.balance_security_id === null || diff --git a/src/functions/OpenCapTable/stockConversion/stockConversionDataToDaml.ts b/src/functions/OpenCapTable/stockConversion/stockConversionDataToDaml.ts index 3de74cf2..8bbc9046 100644 --- a/src/functions/OpenCapTable/stockConversion/stockConversionDataToDaml.ts +++ b/src/functions/OpenCapTable/stockConversion/stockConversionDataToDaml.ts @@ -27,7 +27,7 @@ export function stockConversionDataToDaml(d: OcfStockConversion): Record ({ description: e.description, @@ -77,7 +81,7 @@ export function stockIssuanceDataToDaml(d: OcfStockIssuance): PkgStockIssuanceOc return parseFloat(normalized) > 0; }) .map((v) => ({ - date: dateStringToDAMLTime(v.date), + date: dateStringToDAMLTime(v.date, 'stockIssuance.vestings[].date'), amount: normalizeNumericString(v.amount), })), cost_basis: d.cost_basis ? monetaryToDaml(d.cost_basis) : null, diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index f9a7c28d..0f043fce 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -3,7 +3,12 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockIssuance, SecurityExemption, ShareNumberRange, StockIssuanceType } from '../../../types/native'; -import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { + damlMonetaryToNative, + damlTimeToDateString, + normalizeNumericString, + optionalDamlTimeToDateString, +} from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; function damlSecurityExemptionToNative(e: Fairmint.OpenCapTable.Types.Stock.OcfSecurityExemption): SecurityExemption { @@ -45,24 +50,26 @@ export function damlStockIssuanceDataToNative( } const vestings = Array.isArray((anyD as { vestings?: unknown }).vestings) ? (anyD as { vestings: Array<{ date: string; amount: string }> }).vestings.map((vesting) => ({ - date: damlTimeToDateString(vesting.date), + date: damlTimeToDateString(vesting.date, 'stockIssuance.vestings[].date'), amount: normalizeNumericString(vesting.amount), })) : []; + const boardApprovalDate = optionalDamlTimeToDateString(d.board_approval_date, 'stockIssuance.board_approval_date'); + const stockholderApprovalDate = optionalDamlTimeToDateString( + d.stockholder_approval_date, + 'stockIssuance.stockholder_approval_date' + ); + return { object_type: 'TX_STOCK_ISSUANCE', id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'stockIssuance.date'), security_id: d.security_id, custom_id: d.custom_id, stakeholder_id: d.stakeholder_id, - ...(d.board_approval_date && { - board_approval_date: damlTimeToDateString(d.board_approval_date), - }), - ...(d.stockholder_approval_date && { - stockholder_approval_date: damlTimeToDateString(d.stockholder_approval_date), - }), + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), ...(d.consideration_text && { consideration_text: d.consideration_text }), security_law_exemptions: (Array.isArray((anyD as { security_law_exemptions?: unknown }).security_law_exemptions) ? (anyD as { security_law_exemptions: Fairmint.OpenCapTable.Types.Stock.OcfSecurityExemption[] }) diff --git a/src/functions/OpenCapTable/stockPlan/createStockPlan.ts b/src/functions/OpenCapTable/stockPlan/createStockPlan.ts index 5507c479..e7c98e7e 100644 --- a/src/functions/OpenCapTable/stockPlan/createStockPlan.ts +++ b/src/functions/OpenCapTable/stockPlan/createStockPlan.ts @@ -1,7 +1,7 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { OcfStockPlan, StockPlanCancellationBehavior } from '../../../types'; -import { cleanComments, dateStringToDAMLTime, normalizeNumericString } from '../../../utils/typeConversions'; +import { cleanComments, normalizeNumericString, optionalDateStringToDAMLTime } from '../../../utils/typeConversions'; function cancellationBehaviorToDaml( b: StockPlanCancellationBehavior | undefined @@ -63,8 +63,11 @@ export function stockPlanDataToDaml(d: OcfStockPlan): Fairmint.OpenCapTable.OCF. return { id: d.id, plan_name: d.plan_name, - board_approval_date: d.board_approval_date ? dateStringToDAMLTime(d.board_approval_date) : null, - stockholder_approval_date: d.stockholder_approval_date ? dateStringToDAMLTime(d.stockholder_approval_date) : null, + board_approval_date: optionalDateStringToDAMLTime(d.board_approval_date, 'stockPlan.board_approval_date'), + stockholder_approval_date: optionalDateStringToDAMLTime( + d.stockholder_approval_date, + 'stockPlan.stockholder_approval_date' + ), initial_shares_reserved: normalizeNumericString(d.initial_shares_reserved), default_cancellation_behavior: cancellationBehaviorToDaml(d.default_cancellation_behavior), stock_class_ids: resolveStockClassIds(d), diff --git a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts index 83028d3e..7b92e877 100644 --- a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts @@ -3,7 +3,7 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockPlan, StockPlanCancellationBehavior } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { normalizeNumericString, optionalDamlTimeToDateString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; function damlCancellationBehaviorToNative(b: string | null): StockPlanCancellationBehavior | undefined { @@ -57,16 +57,18 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan }); } + const boardApprovalDate = optionalDamlTimeToDateString(d.board_approval_date, 'stockPlan.board_approval_date'); + const stockholderApprovalDate = optionalDamlTimeToDateString( + d.stockholder_approval_date, + 'stockPlan.stockholder_approval_date' + ); + return { object_type: 'STOCK_PLAN', id: dataWithId.id, plan_name: d.plan_name, - ...(d.board_approval_date && { - board_approval_date: damlTimeToDateString(d.board_approval_date), - }), - ...(d.stockholder_approval_date && { - stockholder_approval_date: damlTimeToDateString(d.stockholder_approval_date), - }), + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), initial_shares_reserved: normalizeNumericString(initialSharesReserved.toString()), ...(d.default_cancellation_behavior && { default_cancellation_behavior: damlCancellationBehaviorToNative(d.default_cancellation_behavior), diff --git a/src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment.ts b/src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment.ts index 85a293f7..1c692c86 100644 --- a/src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment.ts +++ b/src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment.ts @@ -1,13 +1,24 @@ import type { OcfStockPlanPoolAdjustment } from '../../../types'; -import { cleanComments, dateStringToDAMLTime, normalizeNumericString } from '../../../utils/typeConversions'; +import { + cleanComments, + dateStringToDAMLTime, + normalizeNumericString, + optionalDateStringToDAMLTime, +} from '../../../utils/typeConversions'; export function stockPlanPoolAdjustmentDataToDaml(d: OcfStockPlanPoolAdjustment): Record { return { id: d.id, stock_plan_id: d.stock_plan_id, - date: dateStringToDAMLTime(d.date), - board_approval_date: d.board_approval_date ? dateStringToDAMLTime(d.board_approval_date) : null, - stockholder_approval_date: d.stockholder_approval_date ? dateStringToDAMLTime(d.stockholder_approval_date) : null, + date: dateStringToDAMLTime(d.date, 'stockPlanPoolAdjustment.date'), + board_approval_date: optionalDateStringToDAMLTime( + d.board_approval_date, + 'stockPlanPoolAdjustment.board_approval_date' + ), + stockholder_approval_date: optionalDateStringToDAMLTime( + d.stockholder_approval_date, + 'stockPlanPoolAdjustment.stockholder_approval_date' + ), shares_reserved: normalizeNumericString(d.shares_reserved), comments: cleanComments(d.comments), }; diff --git a/src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf.ts b/src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf.ts index 8948055d..d5790368 100644 --- a/src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf.ts @@ -2,7 +2,11 @@ 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 { OcfStockPlanPoolAdjustment } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { + damlTimeToDateString, + normalizeNumericString, + optionalDamlTimeToDateString, +} from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; export interface GetStockPlanPoolAdjustmentAsOcfParams extends GetByContractIdParams {} @@ -27,8 +31,14 @@ export function damlStockPlanPoolAdjustmentDataToNative( // Convert shares_reserved to string for normalization (DAML Numeric may come as number at runtime) const sharesReserved = data.shares_reserved as string | number; const sharesReservedStr = typeof sharesReserved === 'number' ? sharesReserved.toString() : sharesReserved; - const boardApprovalDate: unknown = data.board_approval_date; - const stockholderApprovalDate: unknown = data.stockholder_approval_date; + const boardApprovalDate = optionalDamlTimeToDateString( + data.board_approval_date, + 'stockPlanPoolAdjustment.board_approval_date' + ); + const stockholderApprovalDate = optionalDamlTimeToDateString( + data.stockholder_approval_date, + 'stockPlanPoolAdjustment.stockholder_approval_date' + ); return { object_type: 'TX_STOCK_PLAN_POOL_ADJUSTMENT', @@ -36,19 +46,8 @@ export function damlStockPlanPoolAdjustmentDataToNative( date: damlTimeToDateString(data.date, 'stockPlanPoolAdjustment.date'), stock_plan_id: data.stock_plan_id, shares_reserved: normalizeNumericString(sharesReservedStr), - ...(boardApprovalDate !== null && boardApprovalDate !== undefined - ? { - board_approval_date: damlTimeToDateString(boardApprovalDate, 'stockPlanPoolAdjustment.board_approval_date'), - } - : {}), - ...(stockholderApprovalDate !== null && stockholderApprovalDate !== undefined - ? { - stockholder_approval_date: damlTimeToDateString( - stockholderApprovalDate, - 'stockPlanPoolAdjustment.stockholder_approval_date' - ), - } - : {}), + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), }; } diff --git a/src/functions/OpenCapTable/stockPlanReturnToPool/damlToOcf.ts b/src/functions/OpenCapTable/stockPlanReturnToPool/damlToOcf.ts index 95d25244..c7047532 100644 --- a/src/functions/OpenCapTable/stockPlanReturnToPool/damlToOcf.ts +++ b/src/functions/OpenCapTable/stockPlanReturnToPool/damlToOcf.ts @@ -29,7 +29,7 @@ export function damlStockPlanReturnToPoolToNative(d: DamlStockPlanReturnToPoolDa return { object_type: 'TX_STOCK_PLAN_RETURN_TO_POOL', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'stockPlanReturnToPool.date'), security_id: d.security_id, stock_plan_id: d.stock_plan_id, quantity: normalizeNumericString(d.quantity), diff --git a/src/functions/OpenCapTable/stockPlanReturnToPool/stockPlanReturnToPoolDataToDaml.ts b/src/functions/OpenCapTable/stockPlanReturnToPool/stockPlanReturnToPoolDataToDaml.ts index 535b6b0b..29f76f1e 100644 --- a/src/functions/OpenCapTable/stockPlanReturnToPool/stockPlanReturnToPoolDataToDaml.ts +++ b/src/functions/OpenCapTable/stockPlanReturnToPool/stockPlanReturnToPoolDataToDaml.ts @@ -22,7 +22,7 @@ export function stockPlanReturnToPoolDataToDaml(d: OcfStockPlanReturnToPool): Re } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'stockPlanReturnToPool.date'), security_id: d.security_id, stock_plan_id: d.stock_plan_id, quantity: normalizeNumericString(d.quantity), diff --git a/src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml.ts b/src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml.ts index f4093de9..0dc6218c 100644 --- a/src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml.ts +++ b/src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml.ts @@ -18,7 +18,7 @@ export function stockReissuanceDataToDaml(d: OcfStockReissuance): Record 0 && { comments: d.comments }), diff --git a/src/functions/OpenCapTable/stockRetraction/stockRetractionDataToDaml.ts b/src/functions/OpenCapTable/stockRetraction/stockRetractionDataToDaml.ts index 8baa0340..549422b4 100644 --- a/src/functions/OpenCapTable/stockRetraction/stockRetractionDataToDaml.ts +++ b/src/functions/OpenCapTable/stockRetraction/stockRetractionDataToDaml.ts @@ -22,7 +22,7 @@ export function stockRetractionDataToDaml(d: OcfStockRetraction): Record = { @@ -51,18 +55,22 @@ export interface DamlValuationData { * @returns The native OCF Valuation object */ export function damlValuationToNative(d: DamlValuationData): OcfValuation { + const boardApprovalDate = optionalDamlTimeToDateString(d.board_approval_date, 'valuation.board_approval_date'); + const stockholderApprovalDate = optionalDamlTimeToDateString( + d.stockholder_approval_date, + 'valuation.stockholder_approval_date' + ); + return { object_type: 'VALUATION', id: d.id, stock_class_id: d.stock_class_id, price_per_share: damlMonetaryToNative(d.price_per_share), - effective_date: damlTimeToDateString(d.effective_date), + effective_date: damlTimeToDateString(d.effective_date, 'valuation.effective_date'), valuation_type: damlValuationTypeToNative(d.valuation_type), ...(d.provider && { provider: d.provider }), - ...(d.board_approval_date && { board_approval_date: damlTimeToDateString(d.board_approval_date) }), - ...(d.stockholder_approval_date && { - stockholder_approval_date: damlTimeToDateString(d.stockholder_approval_date), - }), + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), ...(d.comments.length > 0 && { comments: d.comments }), }; } diff --git a/src/functions/OpenCapTable/valuation/valuationDataToDaml.ts b/src/functions/OpenCapTable/valuation/valuationDataToDaml.ts index 054cfb86..c7cfd0dd 100644 --- a/src/functions/OpenCapTable/valuation/valuationDataToDaml.ts +++ b/src/functions/OpenCapTable/valuation/valuationDataToDaml.ts @@ -4,7 +4,13 @@ import type { OcfValuation, ValuationType } from '../../../types'; import { validateValuationData } from '../../../utils/entityValidators'; -import { cleanComments, dateStringToDAMLTime, monetaryToDaml, optionalString } from '../../../utils/typeConversions'; +import { + cleanComments, + dateStringToDAMLTime, + monetaryToDaml, + optionalDateStringToDAMLTime, + optionalString, +} from '../../../utils/typeConversions'; /** * Map from OCF ValuationType to DAML OcfValuationType. @@ -31,10 +37,13 @@ export function valuationDataToDaml(d: OcfValuation): Record { id: d.id, stock_class_id: d.stock_class_id, provider: optionalString(d.provider), - board_approval_date: d.board_approval_date ? dateStringToDAMLTime(d.board_approval_date) : null, - stockholder_approval_date: d.stockholder_approval_date ? dateStringToDAMLTime(d.stockholder_approval_date) : null, + board_approval_date: optionalDateStringToDAMLTime(d.board_approval_date, 'valuation.board_approval_date'), + stockholder_approval_date: optionalDateStringToDAMLTime( + d.stockholder_approval_date, + 'valuation.stockholder_approval_date' + ), price_per_share: monetaryToDaml(d.price_per_share), - effective_date: dateStringToDAMLTime(d.effective_date), + effective_date: dateStringToDAMLTime(d.effective_date, 'valuation.effective_date'), valuation_type: damlValuationType, comments: cleanComments(d.comments), }; diff --git a/src/functions/OpenCapTable/vestingAcceleration/damlToOcf.ts b/src/functions/OpenCapTable/vestingAcceleration/damlToOcf.ts index 770cb195..b3a8b8c2 100644 --- a/src/functions/OpenCapTable/vestingAcceleration/damlToOcf.ts +++ b/src/functions/OpenCapTable/vestingAcceleration/damlToOcf.ts @@ -28,7 +28,7 @@ export function damlVestingAccelerationToNative(d: DamlVestingAccelerationData): return { object_type: 'TX_VESTING_ACCELERATION', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'vestingAcceleration.date'), security_id: d.security_id, quantity: normalizeNumericString(d.quantity), reason_text: d.reason_text, diff --git a/src/functions/OpenCapTable/vestingAcceleration/vestingAccelerationDataToDaml.ts b/src/functions/OpenCapTable/vestingAcceleration/vestingAccelerationDataToDaml.ts index e5b7a402..98805485 100644 --- a/src/functions/OpenCapTable/vestingAcceleration/vestingAccelerationDataToDaml.ts +++ b/src/functions/OpenCapTable/vestingAcceleration/vestingAccelerationDataToDaml.ts @@ -22,7 +22,7 @@ export function vestingAccelerationDataToDaml(d: OcfVestingAcceleration): Record } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'vestingAcceleration.date'), security_id: d.security_id, quantity: normalizeNumericString(d.quantity), reason_text: d.reason_text, diff --git a/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts b/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts index 154bf30f..f6ba590e 100644 --- a/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts +++ b/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts @@ -27,7 +27,7 @@ export function damlVestingEventToNative(d: DamlVestingEventData): OcfVestingEve return { object_type: 'TX_VESTING_EVENT', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'vestingEvent.date'), security_id: d.security_id, vesting_condition_id: d.vesting_condition_id, ...(d.comments.length > 0 && { comments: d.comments }), diff --git a/src/functions/OpenCapTable/vestingEvent/vestingEventDataToDaml.ts b/src/functions/OpenCapTable/vestingEvent/vestingEventDataToDaml.ts index 2510c618..3080b7ba 100644 --- a/src/functions/OpenCapTable/vestingEvent/vestingEventDataToDaml.ts +++ b/src/functions/OpenCapTable/vestingEvent/vestingEventDataToDaml.ts @@ -22,7 +22,7 @@ export function vestingEventDataToDaml(d: OcfVestingEvent): Record 0 && { comments: d.comments }), diff --git a/src/functions/OpenCapTable/vestingStart/vestingStartDataToDaml.ts b/src/functions/OpenCapTable/vestingStart/vestingStartDataToDaml.ts index 3bab0616..f9ff57fd 100644 --- a/src/functions/OpenCapTable/vestingStart/vestingStartDataToDaml.ts +++ b/src/functions/OpenCapTable/vestingStart/vestingStartDataToDaml.ts @@ -22,7 +22,7 @@ export function vestingStartDataToDaml(d: OcfVestingStart): Record 0 ? { comments: damlData.comments } : {}), }; diff --git a/src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation.ts b/src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation.ts index 83e1beac..53ccb05d 100644 --- a/src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation.ts +++ b/src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation.ts @@ -11,7 +11,7 @@ export function warrantCancellationDataToDaml(d: OcfWarrantCancellation): Record id: d.id, security_id: d.security_id, reason_text: d.reason_text, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'warrantCancellation.date'), quantity: normalizeNumericString(d.quantity), balance_security_id: optionalString(d.balance_security_id), comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/warrantCancellation/damlToOcf.ts b/src/functions/OpenCapTable/warrantCancellation/damlToOcf.ts index 99d9444a..5f7a70a1 100644 --- a/src/functions/OpenCapTable/warrantCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/warrantCancellation/damlToOcf.ts @@ -19,7 +19,7 @@ export type DamlWarrantCancellationData = DamlQuantityCancellationData; */ export function damlWarrantCancellationToNative(d: DamlWarrantCancellationData): OcfWarrantCancellation { return { - ...quantityCancellationToNative(d), + ...quantityCancellationToNative(d, 'warrantCancellation.date'), object_type: 'TX_WARRANT_CANCELLATION', }; } diff --git a/src/functions/OpenCapTable/warrantExercise/warrantExerciseDataToDaml.ts b/src/functions/OpenCapTable/warrantExercise/warrantExerciseDataToDaml.ts index 00fdeab5..276dcb23 100644 --- a/src/functions/OpenCapTable/warrantExercise/warrantExerciseDataToDaml.ts +++ b/src/functions/OpenCapTable/warrantExercise/warrantExerciseDataToDaml.ts @@ -33,7 +33,7 @@ export function warrantExerciseDataToDaml(d: OcfWarrantExercise): Record buildWarrantTrigger(t, idx, d.id)), - warrant_expiration_date: d.warrant_expiration_date ? dateStringToDAMLTime(d.warrant_expiration_date) : null, + warrant_expiration_date: optionalDateStringToDAMLTime( + d.warrant_expiration_date, + 'warrantIssuance.warrant_expiration_date' + ), vesting_terms_id: optionalString(d.vesting_terms_id), vestings: (d.vestings ?? []) .filter((v) => { @@ -469,7 +475,7 @@ export function warrantIssuanceDataToDaml(d: { return parseFloat(normalized) > 0; }) .map((v) => ({ - date: dateStringToDAMLTime(v.date), + date: dateStringToDAMLTime(v.date, 'warrantIssuance.vestings[].date'), amount: normalizeNumericString(v.amount), })), comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index ed562326..b101e61c 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -18,6 +18,7 @@ import { damlTimeToDateString, mapDamlTriggerTypeToOcf, normalizeNumericString, + optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; @@ -347,20 +348,14 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf typeof r.nickname === 'string' && r.nickname.length ? r.nickname : undefined; const trigger_description: string | undefined = typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; - const trigger_date: string | undefined = - r.trigger_date === null || r.trigger_date === undefined - ? undefined - : damlTimeToDateString(r.trigger_date, 'warrantIssuance.exercise_triggers[].trigger_date'); + const trigger_date = optionalDamlTimeToDateString( + r.trigger_date, + 'warrantIssuance.exercise_triggers[].trigger_date' + ); const trigger_condition: string | undefined = typeof r.trigger_condition === 'string' && r.trigger_condition.length ? r.trigger_condition : undefined; - const start_date: string | undefined = - r.start_date === null || r.start_date === undefined - ? undefined - : damlTimeToDateString(r.start_date, 'warrantIssuance.exercise_triggers[].start_date'); - const end_date: string | undefined = - r.end_date === null || r.end_date === undefined - ? undefined - : damlTimeToDateString(r.end_date, 'warrantIssuance.exercise_triggers[].end_date'); + const start_date = optionalDamlTimeToDateString(r.start_date, 'warrantIssuance.exercise_triggers[].start_date'); + const end_date = optionalDamlTimeToDateString(r.end_date, 'warrantIssuance.exercise_triggers[].end_date'); const conversion_right: WarrantTriggerConversionRight = mapAnyConversionRightFromDaml(r.conversion_right); @@ -370,10 +365,10 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf conversion_right, ...(nickname ? { nickname } : {}), ...(trigger_description ? { trigger_description } : {}), - ...(trigger_date ? { trigger_date } : {}), + ...(trigger_date !== undefined ? { trigger_date } : {}), ...(trigger_condition ? { trigger_condition } : {}), - ...(start_date ? { start_date } : {}), - ...(end_date ? { end_date } : {}), + ...(start_date !== undefined ? { start_date } : {}), + ...(end_date !== undefined ? { end_date } : {}), }; return t; }) @@ -414,12 +409,6 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf ); } const amountStr = typeof v.amount === 'number' ? v.amount.toString() : v.amount; - if (typeof v.date !== 'string' || !v.date) { - throw new OcpValidationError('warrantIssuance.vestings.date', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: v.date, - }); - } return { date: damlTimeToDateString(v.date, 'warrantIssuance.vestings[].date'), amount: normalizeNumericString(amountStr), @@ -434,12 +423,6 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf receivedValue: d.id, }); } - if (typeof d.date !== 'string' || !d.date) { - throw new OcpValidationError('warrantIssuance.date', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.date, - }); - } if (typeof d.security_id !== 'string' || !d.security_id) { throw new OcpValidationError('warrantIssuance.security_id', 'Required field is missing or invalid', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -459,6 +442,16 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf }); } + const warrantExpirationDate = optionalDamlTimeToDateString( + d.warrant_expiration_date, + 'warrantIssuance.warrant_expiration_date' + ); + const boardApprovalDate = optionalDamlTimeToDateString(d.board_approval_date, 'warrantIssuance.board_approval_date'); + const stockholderApprovalDate = optionalDamlTimeToDateString( + d.stockholder_approval_date, + 'warrantIssuance.stockholder_approval_date' + ); + return { object_type: 'TX_WARRANT_ISSUANCE', id: d.id, @@ -494,28 +487,10 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf } return {}; })(), - ...(d.warrant_expiration_date !== null && d.warrant_expiration_date !== undefined - ? { - warrant_expiration_date: damlTimeToDateString( - d.warrant_expiration_date, - 'warrantIssuance.warrant_expiration_date' - ), - } - : {}), + ...(warrantExpirationDate !== undefined ? { warrant_expiration_date: warrantExpirationDate } : {}), ...(d.vesting_terms_id && typeof d.vesting_terms_id === 'string' ? { vesting_terms_id: d.vesting_terms_id } : {}), - ...(d.board_approval_date !== null && d.board_approval_date !== undefined - ? { - board_approval_date: damlTimeToDateString(d.board_approval_date, 'warrantIssuance.board_approval_date'), - } - : {}), - ...(d.stockholder_approval_date !== null && d.stockholder_approval_date !== undefined - ? { - stockholder_approval_date: damlTimeToDateString( - d.stockholder_approval_date, - 'warrantIssuance.stockholder_approval_date' - ), - } - : {}), + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), ...(typeof d.consideration_text === 'string' && d.consideration_text.length > 0 ? { consideration_text: d.consideration_text } : {}), diff --git a/src/functions/OpenCapTable/warrantRetraction/damlToOcf.ts b/src/functions/OpenCapTable/warrantRetraction/damlToOcf.ts index d0726b08..e58e2004 100644 --- a/src/functions/OpenCapTable/warrantRetraction/damlToOcf.ts +++ b/src/functions/OpenCapTable/warrantRetraction/damlToOcf.ts @@ -27,7 +27,7 @@ export function damlWarrantRetractionToNative(d: DamlWarrantRetractionData): Ocf return { object_type: 'TX_WARRANT_RETRACTION', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'warrantRetraction.date'), security_id: d.security_id, reason_text: d.reason_text, ...(d.comments.length > 0 && { comments: d.comments }), diff --git a/src/functions/OpenCapTable/warrantRetraction/warrantRetractionDataToDaml.ts b/src/functions/OpenCapTable/warrantRetraction/warrantRetractionDataToDaml.ts index 1b2dad1e..f953a02b 100644 --- a/src/functions/OpenCapTable/warrantRetraction/warrantRetractionDataToDaml.ts +++ b/src/functions/OpenCapTable/warrantRetraction/warrantRetractionDataToDaml.ts @@ -22,7 +22,7 @@ export function warrantRetractionDataToDaml(d: OcfWarrantRetraction): Record) { + return { + ...BASE_INPUT, + convertible_type: 'NOTE', + conversion_triggers: [ + { + ...SAFE_TRIGGER_BASE, + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [interestRate], + day_count_convention: 'ACTUAL_365', + interest_payout: 'CASH', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + }, + }, + }, + ], + } as unknown as Parameters[0]; +} + describe('SAFE conversion_timing DAML constructor names', () => { it('emits OcfConvTimingPostMoney for POST_MONEY (not OcfConversionTimingPostMoney)', () => { const input = { @@ -522,7 +548,7 @@ describe('convertible issuance approval-date read boundaries', () => { convertible_type: 'OcfConvertibleNote', conversion_triggers: [trigger], }), - 'convertibleIssuance.interest_rates[].accrual_end_date', + `${NOTE_INTEREST_RATE_PATH}.accrual_end_date`, invalidDate, code ); @@ -579,6 +605,33 @@ describe('convertible issuance write date boundaries', () => { } ); + test.each(['board_approval_date', 'stockholder_approval_date'] as const)( + 'rejects a present non-string %s and accepts null/undefined as absent', + (field) => { + const invalidDate = { seconds: 1 }; + expectInvalidDate( + () => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE], + [field]: invalidDate, + }), + `convertibleIssuance.${field}`, + invalidDate, + OcpErrorCodes.INVALID_TYPE + ); + + for (const value of [null, undefined]) { + const result = convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE], + [field]: value, + }); + expect(result[field]).toBeNull(); + } + } + ); + test.each(['trigger_date', 'start_date', 'end_date'] as const)( 'validates a present conversion trigger %s instead of treating a falsy value as absent', (field) => { @@ -594,36 +647,71 @@ describe('convertible issuance write date boundaries', () => { } ); - test.each(['accrual_start_date', 'accrual_end_date'] as const)( - 'validates a present note %s instead of treating a falsy value as absent', + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'rejects a present non-string conversion trigger %s and accepts null/undefined as absent', (field) => { - const noteTrigger = { - ...SAFE_TRIGGER_BASE, - conversion_right: { - type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, - conversion_mechanism: { - type: 'CONVERTIBLE_NOTE_CONVERSION' as const, - interest_rates: [{ rate: '0.05', accrual_start_date: '2024-01-15', [field]: '' }], - day_count_convention: 'ACTUAL_365', - interest_payout: 'CASH', - interest_accrual_period: 'ANNUAL', - compounding_type: 'SIMPLE', - }, - }, - }; - + const invalidDate = { seconds: 1 }; expectInvalidDate( () => convertibleIssuanceDataToDaml({ ...BASE_INPUT, - convertible_type: 'NOTE', - conversion_triggers: [noteTrigger], - } as unknown as Parameters[0]), - `convertibleIssuance.interest_rates[].${field}`, - '' + conversion_triggers: [{ ...SAFE_TRIGGER_BASE, [field]: invalidDate }], + }), + `convertibleIssuance.conversion_triggers[].${field}`, + invalidDate, + OcpErrorCodes.INVALID_TYPE ); + + for (const value of [null, undefined]) { + const result = convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [{ ...SAFE_TRIGGER_BASE, [field]: value }], + }); + expect(result.conversion_triggers[0]?.[field]).toBeNull(); + } } ); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['empty', '', OcpErrorCodes.INVALID_FORMAT], + ['non-string', { seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a required note accrual_start_date when %s', (_case, value, code) => { + expectInvalidDate( + () => convertibleIssuanceDataToDaml(buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: value })), + `${NOTE_INTEREST_RATE_PATH}.accrual_start_date`, + value, + code + ); + }); + + test.each([ + ['empty', '', OcpErrorCodes.INVALID_FORMAT], + ['non-string', { seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a present optional note accrual_end_date when %s', (_case, value, code) => { + expectInvalidDate( + () => + convertibleIssuanceDataToDaml( + buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15', accrual_end_date: value }) + ), + `${NOTE_INTEREST_RATE_PATH}.accrual_end_date`, + value, + code + ); + }); + + test.each([null, undefined])('accepts optional note accrual_end_date %p as absent', (value) => { + const result = convertibleIssuanceDataToDaml( + buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15', accrual_end_date: value }) + ); + const trigger = result.conversion_triggers[0]; + const right = trigger.conversion_right as { + conversion_mechanism?: { value?: { interest_rates?: Array<{ accrual_end_date: string | null }> } }; + }; + + expect(right.conversion_mechanism?.value?.interest_rates?.[0]?.accrual_end_date).toBeNull(); + }); }); /** diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index 0d5a7cf1..077abcb9 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -1,7 +1,10 @@ import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; import { damlEquityCompensationIssuanceDataToNative } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import { issuerAuthorizedSharesAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment'; import { damlIssuerAuthorizedSharesAdjustmentDataToNative } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf'; import { damlStockClassSplitToNative } from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; +import { stockPlanPoolAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment'; import { damlStockPlanPoolAdjustmentDataToNative } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf'; function expectInvalidDate( @@ -56,6 +59,20 @@ const EQUITY_COMPENSATION_ISSUANCE_BASE = { comments: [], }; +const EQUITY_COMPENSATION_WRITE_BASE = { + object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE' as const, + id: 'equity-compensation-1', + date: '2024-01-15', + security_id: 'security-1', + custom_id: 'OPTION-1', + stakeholder_id: 'stakeholder-1', + compensation_type: 'OPTION' as const, + quantity: '1000', + expiration_date: null, + termination_exercise_windows: [], + security_law_exemptions: [], +}; + const OPTIONAL_READ_DATE_CASES: Array<{ name: string; fieldPath: string; @@ -90,6 +107,46 @@ const OPTIONAL_READ_DATE_CASES: Array<{ })), ]; +const OPTIONAL_WRITE_DATE_CASES: Array<{ + name: string; + field: 'board_approval_date' | 'stockholder_approval_date'; + fieldPath: string; + convert: (value: unknown) => Record; +}> = [ + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `issuer adjustment ${field}`, + field, + fieldPath: `issuerAuthorizedSharesAdjustment.${field}`, + convert: (value: unknown) => + issuerAuthorizedSharesAdjustmentDataToDaml({ + ...ISSUER_ADJUSTMENT_BASE, + object_type: 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT', + [field]: value, + } as unknown as Parameters[0]), + })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `stock plan pool adjustment ${field}`, + field, + fieldPath: `stockPlanPoolAdjustment.${field}`, + convert: (value: unknown) => + stockPlanPoolAdjustmentDataToDaml({ + ...STOCK_PLAN_POOL_ADJUSTMENT_BASE, + object_type: 'TX_STOCK_PLAN_POOL_ADJUSTMENT', + [field]: value, + } as unknown as Parameters[0]), + })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `equity compensation issuance ${field}`, + field, + fieldPath: `equityCompensationIssuance.${field}`, + convert: (value: unknown) => + equityCompensationIssuanceDataToDaml({ + ...EQUITY_COMPENSATION_WRITE_BASE, + [field]: value, + }), + })), +]; + describe('DAML read converter date boundaries', () => { test('preserves the lexical date when an offset crosses the UTC day', () => { const result = damlStockClassSplitToNative({ @@ -168,4 +225,77 @@ describe('DAML read converter date boundaries', () => { test.each(OPTIONAL_READ_DATE_CASES)('accepts a null $name as absent', ({ convert }) => { expect(() => convert(null)).not.toThrow(); }); + + test('rejects an undefined required-nullable equity expiration on readback', () => { + expectInvalidDate( + () => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + expiration_date: undefined, + }), + 'equityCompensationIssuance.expiration_date', + undefined, + OcpErrorCodes.INVALID_TYPE + ); + }); +}); + +describe('OCF write converter optional date boundaries', () => { + test.each(OPTIONAL_WRITE_DATE_CASES)('rejects a present empty $name', ({ convert, fieldPath }) => { + expectInvalidDate(() => convert(''), fieldPath, ''); + }); + + test.each(OPTIONAL_WRITE_DATE_CASES)('rejects a present non-string $name', ({ convert, fieldPath }) => { + const invalidDate = { seconds: 1 }; + expectInvalidDate(() => convert(invalidDate), fieldPath, invalidDate, OcpErrorCodes.INVALID_TYPE); + }); + + test.each(OPTIONAL_WRITE_DATE_CASES)('encodes a null or undefined $name as absent', ({ convert, field }) => { + expect(convert(null)[field]).toBeNull(); + expect(convert(undefined)[field]).toBeNull(); + }); + + test('reports contextual paths for required and nested write dates', () => { + expectInvalidDate( + () => + issuerAuthorizedSharesAdjustmentDataToDaml({ + ...ISSUER_ADJUSTMENT_BASE, + object_type: 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT', + date: '', + }), + 'issuerAuthorizedSharesAdjustment.date', + '' + ); + + expectInvalidDate( + () => + equityCompensationIssuanceDataToDaml({ + ...EQUITY_COMPENSATION_WRITE_BASE, + vestings: [{ date: '', amount: '1' }], + }), + 'equityCompensationIssuance.vestings[].date', + '' + ); + }); + + test.each([ + ['undefined', undefined, OcpErrorCodes.INVALID_TYPE], + ['empty', '', OcpErrorCodes.INVALID_FORMAT], + ['non-string', { seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a required-nullable equity expiration when %s', (_case, value, code) => { + expectInvalidDate( + () => + equityCompensationIssuanceDataToDaml({ + ...EQUITY_COMPENSATION_WRITE_BASE, + expiration_date: value, + } as unknown as Parameters[0]), + 'equityCompensationIssuance.expiration_date', + value, + code + ); + }); + + test('accepts explicit null for a required-nullable equity expiration', () => { + expect(equityCompensationIssuanceDataToDaml(EQUITY_COMPENSATION_WRITE_BASE).expiration_date).toBeNull(); + }); }); diff --git a/test/converters/planSecurityConverters.test.ts b/test/converters/planSecurityConverters.test.ts index 2b41b5bf..a7f9cdf9 100644 --- a/test/converters/planSecurityConverters.test.ts +++ b/test/converters/planSecurityConverters.test.ts @@ -13,7 +13,7 @@ * 4. Fields are properly transformed to DAML format */ -import { OcpParseError, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { planSecurityExerciseDataToDaml } from '../../src/functions/OpenCapTable/planSecurityExercise'; import { planSecurityIssuanceDataToDaml } from '../../src/functions/OpenCapTable/planSecurityIssuance'; import type { OcfPlanSecurityExercise, OcfPlanSecurityIssuance } from '../../src/types/native'; @@ -116,6 +116,38 @@ describe('PlanSecurity Type Converters', () => { ]); }); + it.each([ + ['undefined', undefined, OcpErrorCodes.INVALID_TYPE], + ['empty', '', OcpErrorCodes.INVALID_FORMAT], + ['non-string', { seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a required-nullable expiration_date when %s', (_case, expirationDate, code) => { + const input = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', + id: 'psi-expiration-boundary', + date: '2025-01-15', + security_id: 'sec-expiration-boundary', + custom_id: 'custom-expiration-boundary', + stakeholder_id: 'stakeholder-expiration-boundary', + compensation_type: 'OPTION', + quantity: '100', + expiration_date: expirationDate, + termination_exercise_windows: [], + security_law_exemptions: [], + } as unknown as OcfPlanSecurityIssuance; + + try { + planSecurityIssuanceDataToDaml(input); + throw new Error('Expected expiration date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath: 'planSecurityIssuance.expiration_date', + receivedValue: expirationDate, + }); + } + }); + it('converts RSU plan security type to OcfCompensationTypeRSU', () => { const input: OcfPlanSecurityIssuance = { object_type: 'TX_PLAN_SECURITY_ISSUANCE', diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 9749c8ed..5be8683a 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -6,7 +6,7 @@ * infinite edit loops in the replication script. */ -import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError, type OcpErrorCode } from '../../src/errors'; import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import { ocfDeepEqual } from '../../src/utils/ocfComparison'; @@ -19,6 +19,21 @@ function roundTrip(ocfInput: Parameters[0]): R return { ...native, object_type: 'TX_WARRANT_ISSUANCE' }; } +function expectInvalidWarrantDate( + action: () => unknown, + fieldPath: string, + receivedValue: unknown, + code: OcpErrorCode = OcpErrorCodes.INVALID_FORMAT +): void { + try { + action(); + throw new Error('Expected warrant date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ code, fieldPath, receivedValue }); + } +} + describe('WarrantIssuance round-trip equivalence', () => { const baseWarrantIssuance = { id: '4afe6226-a717-4596-8bcc-fa3c22b154de', @@ -49,6 +64,25 @@ describe('WarrantIssuance round-trip equivalence', () => { object_type: 'TX_WARRANT_ISSUANCE' as const, }; + function stockClassTrigger(overrides: Record = {}) { + return { + type: 'AUTOMATIC_ON_CONDITION' as const, + trigger_id: 'w_stock_ratio', + trigger_condition: 'X', + conversion_right: { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + converts_to_stock_class_id: '16faa6e5-b13a-4dda-bad2-885fccd2975a', + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }, + }, + ...overrides, + }; + } + test('basic warrant issuance survives round-trip', () => { const dbData = { ...baseWarrantIssuance, object_type: 'TX_WARRANT_ISSUANCE' } as Record; const cantonData = roundTrip(baseWarrantIssuance); @@ -273,6 +307,116 @@ describe('WarrantIssuance round-trip equivalence', () => { } ); + test.each(['board_approval_date', 'stockholder_approval_date', 'warrant_expiration_date'] as const)( + 'enforces optional write boundary semantics for %s', + (field) => { + const fieldPath = `warrantIssuance.${field}`; + const invalidDate = { seconds: 1 }; + + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + [field]: '', + }), + fieldPath, + '' + ); + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + [field]: invalidDate, + }), + fieldPath, + invalidDate, + OcpErrorCodes.INVALID_TYPE + ); + + for (const value of [null, undefined]) { + const result = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + [field]: value, + }); + expect(result[field]).toBeNull(); + } + } + ); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'enforces optional outer trigger write boundary semantics for %s', + (field) => { + const fieldPath = `warrantIssuance.exercise_triggers[].${field}`; + const invalidDate = { seconds: 1 }; + + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], [field]: '' }], + }), + fieldPath, + '' + ); + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], [field]: invalidDate }], + }), + fieldPath, + invalidDate, + OcpErrorCodes.INVALID_TYPE + ); + + for (const value of [null, undefined]) { + const result = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], [field]: value }], + }); + expect(result.exercise_triggers[0]?.[field]).toBeNull(); + } + } + ); + + test('uses identical canonical date semantics for outer and nested stock-class triggers', () => { + const result = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [ + stockClassTrigger({ + trigger_date: '2024-01-15T23:30:00-05:00', + start_date: '2024-01-15T00:30:00+14:00', + end_date: '2024-01-15T12:00:00Z', + }), + ], + }); + const outer = result.exercise_triggers[0] as unknown as Record; + const right = outer.conversion_right as { value: { conversion_trigger: Record } }; + const nested = right.value.conversion_trigger; + + for (const field of ['trigger_date', 'start_date', 'end_date'] as const) { + expect(outer[field]).toBe('2024-01-15T00:00:00.000Z'); + expect(nested[field]).toBe('2024-01-15T00:00:00.000Z'); + } + }); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'validates nested stock-class trigger %s before serialization', + (field) => { + const invalidDate = { seconds: 1 }; + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [stockClassTrigger({ [field]: invalidDate })], + }), + `warrantIssuance.exercise_triggers[].${field}`, + invalidDate, + OcpErrorCodes.INVALID_TYPE + ); + } + ); + test('STOCK_CLASS_CONVERSION_RIGHT rejects non-NORMAL rounding_type (not persisted in DAML)', () => { const input = { ...baseWarrantIssuance, diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index ff6979f4..3f69660e 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -18,7 +18,15 @@ import { type OcfVestingStart, type OcfWarrantAcceptance, } from '../../dist'; -import { dateStringToDAMLTime, isOcfEntityType as isOcfEntityTypeFromUtils } from '../../dist/utils'; +import { + damlTimeToDateString, + dateStringToDAMLTime, + isOcfEntityType as isOcfEntityTypeFromUtils, + nullableDamlTimeToDateString, + nullableDateStringToDAMLTime, + optionalDamlTimeToDateString, + optionalDateStringToDAMLTime, +} from '../../dist/utils'; type Assert = T; type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; @@ -38,10 +46,23 @@ const publishedOcfObjectExcludesLegacyPlanSecurity: Assert< > = true; declare const unknownDateInput: unknown; const validatedDamlTime: string = dateStringToDAMLTime(unknownDateInput, 'transaction.date'); +const validatedOcfDate: string = damlTimeToDateString(unknownDateInput, 'transaction.date'); +const optionalDamlTime: string | null = optionalDateStringToDAMLTime(unknownDateInput, 'transaction.date'); +const nullableDamlTime: string | null = nullableDateStringToDAMLTime(unknownDateInput, 'transaction.date'); +const optionalOcfDate: string | undefined = optionalDamlTimeToDateString(unknownDateInput, 'transaction.date'); +const nullableOcfDate: string | null = nullableDamlTimeToDateString(unknownDateInput, 'transaction.date'); + +// @ts-expect-error every public date conversion requires an entity-specific field path +dateStringToDAMLTime(unknownDateInput); void publishedOcfObjectIsExact; void publishedOcfObjectExcludesLegacyPlanSecurity; void validatedDamlTime; +void validatedOcfDate; +void optionalDamlTime; +void nullableDamlTime; +void optionalOcfDate; +void nullableOcfDate; function verifyPublishedBatchApi( batch: CapTableBatch, diff --git a/test/property/typeConversions.property.test.ts b/test/property/typeConversions.property.test.ts index a9ce1bfc..74a66277 100644 --- a/test/property/typeConversions.property.test.ts +++ b/test/property/typeConversions.property.test.ts @@ -187,8 +187,8 @@ describe('Property-based tests: Type Conversions', () => { const day = String(date.getDate()).padStart(2, '0'); const dateStr = `${year}-${month}-${day}`; - const damlTime = dateStringToDAMLTime(dateStr); - const backToDate = damlTimeToDateString(damlTime); + const damlTime = dateStringToDAMLTime(dateStr, 'test.date'); + const backToDate = damlTimeToDateString(damlTime, 'test.date'); expect(backToDate).toBe(dateStr); } @@ -215,7 +215,7 @@ describe('Property-based tests: Type Conversions', () => { return `${year}-${monthStr}-${dayStr}`; }), (dateStr) => { - const damlTime = dateStringToDAMLTime(dateStr); + const damlTime = dateStringToDAMLTime(dateStr, 'test.date'); expect(damlTime).toBe(`${dateStr}T00:00:00.000Z`); } ), @@ -224,9 +224,9 @@ describe('Property-based tests: Type Conversions', () => { }); /** - * dateStringToDAMLTime passes through strings with time portion. + * dateStringToDAMLTime canonicalizes strings with a time portion. */ - test('passes through datetime strings unchanged', () => { + test('canonicalizes datetime strings to lexical-date UTC midnight', () => { fc.assert( fc.property( fc @@ -247,15 +247,15 @@ describe('Property-based tests: Type Conversions', () => { return `${year}-${monthStr}-${dayStr}T${hourStr}:${minStr}:${secStr}.000Z`; }), (datetimeStr) => { - const result = dateStringToDAMLTime(datetimeStr); - expect(result).toBe(datetimeStr); + const result = dateStringToDAMLTime(datetimeStr, 'test.date'); + expect(result).toBe(`${datetimeStr.slice(0, 10)}T00:00:00.000Z`); } ), { numRuns: 200 } ); }); - test('valid RFC 3339 date-times preserve both their source and lexical date prefix', () => { + test('valid RFC 3339 date-times preserve their lexical date through canonical DAML encoding', () => { const dateArbitrary = fc .date({ min: new Date('1900-01-01T00:00:00.000Z'), @@ -285,8 +285,9 @@ describe('Property-based tests: Type Conversions', () => { offsetArbitrary, (date, time, fraction, offset) => { const value = `${date}T${time}${fraction}${offset}`; - expect(dateStringToDAMLTime(value)).toBe(value); - expect(damlTimeToDateString(value)).toBe(date); + const damlTime = dateStringToDAMLTime(value, 'test.date'); + expect(damlTime).toBe(`${date}T00:00:00.000Z`); + expect(damlTimeToDateString(damlTime, 'test.date')).toBe(date); expect(tryIsoDateToDateString(value)).toBe(date); } ), @@ -299,7 +300,7 @@ describe('Property-based tests: Type Conversions', () => { fc.property(fc.integer({ min: 1, max: 9999 }), fc.constantFrom(4, 6, 9, 11), (year, month) => { const value = `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-31`; expect(tryIsoDateToDateString(value)).toBeNull(); - expect(() => damlTimeToDateString(value)).toThrow(); + expect(() => damlTimeToDateString(value, 'test.date')).toThrow(); }), { numRuns: 200 } ); @@ -318,7 +319,7 @@ describe('Property-based tests: Type Conversions', () => { ), (value) => { expect(tryIsoDateToDateString(value)).toBeNull(); - expect(() => dateStringToDAMLTime(value)).toThrow(); + expect(() => dateStringToDAMLTime(value, 'test.date')).toThrow(); } ), { numRuns: 300 } diff --git a/test/schemaAlignment/dateBoundaryInvariants.test.ts b/test/schemaAlignment/dateBoundaryInvariants.test.ts new file mode 100644 index 00000000..ec6ae1c1 --- /dev/null +++ b/test/schemaAlignment/dateBoundaryInvariants.test.ts @@ -0,0 +1,143 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const SRC_ROOT = path.resolve(__dirname, '../../src'); +const OPEN_CAP_TABLE_SEGMENT = `${path.sep}functions${path.sep}OpenCapTable${path.sep}`; + +const DATE_CONVERTERS = new Set([ + 'dateStringToDAMLTime', + 'damlTimeToDateString', + 'optionalDateStringToDAMLTime', + 'nullableDateStringToDAMLTime', + 'optionalDamlTimeToDateString', + 'nullableDamlTimeToDateString', +]); + +const REQUIRED_DATE_CONVERTERS = new Set(['dateStringToDAMLTime', 'damlTimeToDateString']); +const OPTIONAL_DATE_FIELDS = new Set([ + 'accrual_end_date', + 'board_approval_date', + 'end_date', + 'expires_at', + 'start_date', + 'stockholder_approval_date', + 'trigger_date', + 'warrant_expiration_date', +]); + +function sourceFiles(root: string): string[] { + return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const absolutePath = path.join(root, entry.name); + if (entry.isDirectory()) return sourceFiles(absolutePath); + return entry.isFile() && entry.name.endsWith('.ts') ? [absolutePath] : []; + }); +} + +function location(sourceFile: ts.SourceFile, node: ts.Node): string { + const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + return `${path.relative(process.cwd(), sourceFile.fileName)}:${line + 1}`; +} + +function rawDateProperties(node: ts.Node): string[] { + const properties = new Set(); + + function visit(current: ts.Node): void { + if (ts.isPropertyAccessExpression(current)) { + const name = current.name.text; + if (name === 'date' || name.endsWith('_date') || name === 'expires_at') { + properties.add(current.getText()); + } + } + ts.forEachChild(current, visit); + } + + visit(node); + return [...properties]; +} + +describe('date boundary source invariants', () => { + test('requires contextual paths and forbids raw date presence guards', () => { + const violations: string[] = []; + + for (const file of sourceFiles(SRC_ROOT)) { + const sourceFile = ts.createSourceFile( + file, + fs.readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + + function visit(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + DATE_CONVERTERS.has(node.expression.text) + ) { + if (node.arguments.length !== 2) { + violations.push(`${location(sourceFile, node)} ${node.expression.text} must receive value and fieldPath`); + } else { + const fieldPath = node.arguments[1]; + const literalPath = ts.isStringLiteralLike(fieldPath) ? fieldPath.text : undefined; + const templatePath = ts.isTemplateExpression(fieldPath) && fieldPath.getText(sourceFile).includes('.'); + const forwardedPath = ts.isIdentifier(fieldPath) && ['fieldPath', 'dateFieldPath'].includes(fieldPath.text); + + if (literalPath !== undefined && (!literalPath.includes('.') || literalPath === 'date')) { + violations.push(`${location(sourceFile, fieldPath)} date fieldPath must be entity-specific`); + } else if (literalPath === undefined && !templatePath && !forwardedPath) { + violations.push( + `${location(sourceFile, fieldPath)} date fieldPath must be literal or explicitly forwarded` + ); + } + } + + const value = node.arguments[0]; + if (ts.isPropertyAccessExpression(value)) { + const field = value.name.text; + if (OPTIONAL_DATE_FIELDS.has(field) && REQUIRED_DATE_CONVERTERS.has(node.expression.text)) { + violations.push(`${location(sourceFile, node)} optional ${field} must use an optional date converter`); + } + if ( + field === 'expiration_date' && + !['nullableDateStringToDAMLTime', 'nullableDamlTimeToDateString'].includes(node.expression.text) + ) { + violations.push( + `${location(sourceFile, node)} required-nullable expiration_date must use a nullable date converter` + ); + } + } + } + + if (file.includes(OPEN_CAP_TABLE_SEGMENT)) { + let condition: ts.Expression | undefined; + if (ts.isIfStatement(node)) { + condition = node.expression; + } else if (ts.isConditionalExpression(node)) { + const { condition: conditionalCondition } = node; + condition = conditionalCondition; + } else if ( + ts.isBinaryExpression(node) && + [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(node.operatorToken.kind) + ) { + condition = node.left; + } + + if (condition !== undefined) { + for (const property of rawDateProperties(condition)) { + violations.push( + `${location(sourceFile, condition)} raw date guard ${property}; use a required/optional date converter` + ); + } + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + } + + expect([...new Set(violations)]).toEqual([]); + }); +}); diff --git a/test/utils/typeGuards.test.ts b/test/utils/typeGuards.test.ts index f92007ab..273f228b 100644 --- a/test/utils/typeGuards.test.ts +++ b/test/utils/typeGuards.test.ts @@ -121,13 +121,14 @@ describe('Primitive Type Guards', () => { expect(isIsoDateString('24-01-15')).toBe(false); }); - it('returns false for dates with invalid month', () => { + it('returns false for impossible calendar dates', () => { expect(isIsoDateString('2024-13-01')).toBe(false); + expect(isIsoDateString('2024-02-30')).toBe(false); + expect(isIsoDateString('2023-02-29')).toBe(false); + expect(isIsoDateString('2100-02-29')).toBe(false); + expect(isIsoDateString('2000-02-29')).toBe(true); }); - // Note: JavaScript Date is permissive with day overflow (2024-02-30 becomes 2024-03-01) - // So we only test clearly invalid month values - it('returns false for non-strings', () => { expect(isIsoDateString(null)).toBe(false); expect(isIsoDateString(123)).toBe(false); diff --git a/test/utils/validation.test.ts b/test/utils/validation.test.ts index f319f98d..d1c19910 100644 --- a/test/utils/validation.test.ts +++ b/test/utils/validation.test.ts @@ -188,13 +188,14 @@ describe('Date Validators', () => { expect(() => validateRequiredDate('2024-1-15', 'field')).toThrow(OcpValidationError); }); - it('throws for dates with invalid month', () => { + it('throws for impossible calendar dates', () => { expect(() => validateRequiredDate('2024-13-01', 'field')).toThrow(OcpValidationError); + expect(() => validateRequiredDate('2024-02-30', 'field')).toThrow(OcpValidationError); + expect(() => validateRequiredDate('2023-02-29', 'field')).toThrow(OcpValidationError); + expect(() => validateRequiredDate('2100-02-29', 'field')).toThrow(OcpValidationError); + expect(() => validateRequiredDate('2000-02-29', 'field')).not.toThrow(); }); - // Note: JavaScript Date is permissive with day overflow (2024-02-30 becomes 2024-03-01) - // So we only test clearly invalid month values - it('throws for non-strings', () => { expect(() => validateRequiredDate(null, 'field')).toThrow(OcpValidationError); expect(() => validateRequiredDate(123, 'field')).toThrow(OcpValidationError); diff --git a/test/validation/typeCoercion.test.ts b/test/validation/typeCoercion.test.ts index a223b3d6..546414bf 100644 --- a/test/validation/typeCoercion.test.ts +++ b/test/validation/typeCoercion.test.ts @@ -10,6 +10,10 @@ import { damlTimeToDateString, dateStringToDAMLTime, normalizeNumericString, + nullableDamlTimeToDateString, + nullableDateStringToDAMLTime, + optionalDamlTimeToDateString, + optionalDateStringToDAMLTime, optionalNumberToString, optionalString, safeString, @@ -85,7 +89,7 @@ describe('Type Coercion Utilities', () => { describe('dateStringToDAMLTime', () => { test.each(['2024-01-15', '2000-02-29', '1900-12-31'])('converts the valid date %s to UTC midnight', (date) => { - expect(dateStringToDAMLTime(date)).toBe(`${date}T00:00:00.000Z`); + expect(dateStringToDAMLTime(date, 'test.date')).toBe(`${date}T00:00:00.000Z`); }); test.each([ @@ -93,8 +97,43 @@ describe('Type Coercion Utilities', () => { '2024-01-15T14:30:00.000000Z', '2024-01-15T23:59:59+14:00', '2024-01-15T00:00:00-05:30', - ])('preserves the valid RFC 3339 date-time %s exactly', (dateTime) => { - expect(dateStringToDAMLTime(dateTime)).toBe(dateTime); + ])('normalizes the valid RFC 3339 date-time %s to lexical-date UTC midnight', (dateTime) => { + expect(dateStringToDAMLTime(dateTime, 'test.date')).toBe('2024-01-15T00:00:00.000Z'); + }); + + test('prevents an offset timestamp from changing OCF dates after ledger normalization', () => { + const damlTime = dateStringToDAMLTime('2024-01-15T23:30:00-05:00', 'transaction.date'); + + expect(damlTime).toBe('2024-01-15T00:00:00.000Z'); + expect(damlTimeToDateString(damlTime, 'transaction.date')).toBe('2024-01-15'); + }); + }); + + describe('optional and required-nullable date helpers', () => { + test('optional dates treat only null and undefined as absent', () => { + expect(optionalDateStringToDAMLTime(null, 'optional.date')).toBeNull(); + expect(optionalDateStringToDAMLTime(undefined, 'optional.date')).toBeNull(); + expect(() => optionalDateStringToDAMLTime('', 'optional.date')).toThrow(OcpValidationError); + expect(() => optionalDateStringToDAMLTime({ seconds: 1 }, 'optional.date')).toThrow(OcpValidationError); + }); + + test('required-nullable dates accept null but reject undefined and malformed present values', () => { + expect(nullableDateStringToDAMLTime(null, 'nullable.date')).toBeNull(); + expect(() => nullableDateStringToDAMLTime(undefined, 'nullable.date')).toThrow(OcpValidationError); + expect(() => nullableDateStringToDAMLTime('', 'nullable.date')).toThrow(OcpValidationError); + expect(() => nullableDateStringToDAMLTime({ seconds: 1 }, 'nullable.date')).toThrow(OcpValidationError); + }); + + test('read helpers preserve optional and nullable output shapes without dropping present invalid values', () => { + expect(optionalDamlTimeToDateString(null, 'optional.date')).toBeUndefined(); + expect(optionalDamlTimeToDateString(undefined, 'optional.date')).toBeUndefined(); + expect(nullableDamlTimeToDateString(null, 'nullable.date')).toBeNull(); + expect(() => nullableDamlTimeToDateString(undefined, 'nullable.date')).toThrow(OcpValidationError); + + for (const value of ['', 0, false, { seconds: 1 }]) { + expect(() => optionalDamlTimeToDateString(value, 'optional.date')).toThrow(OcpValidationError); + expect(() => nullableDamlTimeToDateString(value, 'nullable.date')).toThrow(OcpValidationError); + } }); }); @@ -105,11 +144,11 @@ describe('Type Coercion Utilities', () => { '2024-01-15T23:30:00-05:00', '2024-01-15T00:30:00+14:00', ])('extracts the lexical date prefix from %s', (dateTime) => { - expect(damlTimeToDateString(dateTime)).toBe('2024-01-15'); + expect(damlTimeToDateString(dateTime, 'test.date')).toBe('2024-01-15'); }); test('returns date-only string as-is', () => { - expect(damlTimeToDateString('2024-01-15')).toBe('2024-01-15'); + expect(damlTimeToDateString('2024-01-15', 'test.date')).toBe('2024-01-15'); }); }); @@ -150,8 +189,8 @@ describe('Type Coercion Utilities', () => { }); test.each(invalidValues)('both conversion boundaries reject invalid input %#', (value) => { - expect(() => dateStringToDAMLTime(value)).toThrow(OcpValidationError); - expect(() => damlTimeToDateString(value)).toThrow(OcpValidationError); + expect(() => dateStringToDAMLTime(value, 'test.date')).toThrow(OcpValidationError); + expect(() => damlTimeToDateString(value, 'test.date')).toThrow(OcpValidationError); }); test('accepts Gregorian leap days only when the year is valid', () => { @@ -165,12 +204,12 @@ describe('Type Coercion Utilities', () => { const tenDigits = '2024-01-15T23:59:59.1234567890Z'; expect(tryIsoDateToDateString(nineDigits)).toBe('2024-01-15'); - expect(dateStringToDAMLTime(nineDigits)).toBe(nineDigits); - expect(damlTimeToDateString(nineDigits)).toBe('2024-01-15'); + expect(dateStringToDAMLTime(nineDigits, 'test.date')).toBe('2024-01-15T00:00:00.000Z'); + expect(damlTimeToDateString(nineDigits, 'test.date')).toBe('2024-01-15'); expect(tryIsoDateToDateString(tenDigits)).toBeNull(); - expect(() => dateStringToDAMLTime(tenDigits)).toThrow(OcpValidationError); - expect(() => damlTimeToDateString(tenDigits)).toThrow(OcpValidationError); + expect(() => dateStringToDAMLTime(tenDigits, 'test.date')).toThrow(OcpValidationError); + expect(() => damlTimeToDateString(tenDigits, 'test.date')).toThrow(OcpValidationError); }); test('reports the caller field path and invalid value', () => { From a0a53988823d52c8670b5aa95d5992c0fd9469d2 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 17:36:12 -0400 Subject: [PATCH 27/47] Enforce OCF trigger discriminators --- .../createConvertibleIssuance.ts | 13 +- .../getConvertibleIssuanceAsOcf.ts | 18 +- .../OpenCapTable/shared/triggerFields.ts | 170 +++++++++++ .../warrantIssuance/createWarrantIssuance.ts | 16 +- .../getWarrantIssuanceAsOcf.ts | 15 +- .../convertibleIssuanceConverters.test.ts | 186 ++++++------ .../warrantIssuanceConverters.test.ts | 220 ++++++-------- test/createOcf/falsyFieldRoundtrip.test.ts | 2 + .../dateBoundaryInvariants.test.ts | 16 +- test/utils/triggerFields.test.ts | 279 ++++++++++++++++++ 10 files changed, 673 insertions(+), 262 deletions(-) create mode 100644 src/functions/OpenCapTable/shared/triggerFields.ts create mode 100644 test/utils/triggerFields.test.ts diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 938658a2..5f2586a8 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -10,6 +10,7 @@ import { optionalString, safeString, } from '../../../utils/typeConversions'; +import { triggerFieldsToDaml } from '../shared/triggerFields'; type ConversionTriggerTypeInput = | 'AUTOMATIC_ON_CONDITION' @@ -430,23 +431,15 @@ function buildTriggerToDaml(t: ConversionTriggerInput, _index: number, _issuance const { trigger_id } = t; const nickname = typeof t === 'object' && t.nickname ? t.nickname : null; const trigger_description = typeof t === 'object' && t.trigger_description ? t.trigger_description : null; - const trigger_condition = typeof t === 'object' && t.trigger_condition ? t.trigger_condition : null; const conversion_right = buildConvertibleRight(t); - const start_date = optionalDateStringToDAMLTime(t.start_date, 'convertibleIssuance.conversion_triggers[].start_date'); - const end_date = optionalDateStringToDAMLTime(t.end_date, 'convertibleIssuance.conversion_triggers[].end_date'); + const triggerFields = triggerFieldsToDaml(t, normalized, 'convertibleIssuance.conversion_triggers[]'); return { type_: typeEnum, trigger_id, nickname, trigger_description, conversion_right, - trigger_date: optionalDateStringToDAMLTime( - t.trigger_date, - 'convertibleIssuance.conversion_triggers[].trigger_date' - ), - trigger_condition, - start_date, - end_date, + ...triggerFields, }; } diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index dd461b0d..57019929 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -15,6 +15,7 @@ import { safeString, } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; +import { triggerFieldsFromDaml } from '../shared/triggerFields'; interface CustomConversionMechanism { type: 'CUSTOM_CONVERSION'; @@ -509,17 +510,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers const nickname: string | undefined = typeof r.nickname === 'string' && r.nickname.length ? r.nickname : undefined; const trigger_description: string | undefined = typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; - const trigger_date = optionalDamlTimeToDateString( - r.trigger_date, - 'convertibleIssuance.conversion_triggers[].trigger_date' - ); - const trigger_condition: string | undefined = - typeof r.trigger_condition === 'string' && r.trigger_condition.length ? r.trigger_condition : undefined; - const start_date = optionalDamlTimeToDateString( - r.start_date, - 'convertibleIssuance.conversion_triggers[].start_date' - ); - const end_date = optionalDamlTimeToDateString(r.end_date, 'convertibleIssuance.conversion_triggers[].end_date'); + const triggerFields = triggerFieldsFromDaml(r, type, 'convertibleIssuance.conversion_triggers[]'); // Parse conversion_right if present and convertible variant is used let conversion_right: ConvertibleConversionRight | undefined; @@ -569,10 +560,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers conversion_right, ...(nickname ? { nickname } : {}), ...(trigger_description ? { trigger_description } : {}), - ...(trigger_date !== undefined ? { trigger_date } : {}), - ...(trigger_condition ? { trigger_condition } : {}), - ...(start_date !== undefined ? { start_date } : {}), - ...(end_date !== undefined ? { end_date } : {}), + ...triggerFields, }; return trigger; }); diff --git a/src/functions/OpenCapTable/shared/triggerFields.ts b/src/functions/OpenCapTable/shared/triggerFields.ts new file mode 100644 index 00000000..5389c445 --- /dev/null +++ b/src/functions/OpenCapTable/shared/triggerFields.ts @@ -0,0 +1,170 @@ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { damlTimeToDateString, dateStringToDAMLTime } from '../../../utils/typeConversions'; + +export type OcfTriggerDiscriminator = + | 'AUTOMATIC_ON_CONDITION' + | 'AUTOMATIC_ON_DATE' + | 'ELECTIVE_AT_WILL' + | 'ELECTIVE_ON_CONDITION' + | 'ELECTIVE_IN_RANGE' + | 'UNSPECIFIED'; + +type TriggerField = 'trigger_date' | 'trigger_condition' | 'start_date' | 'end_date'; + +export interface TriggerFieldInput { + trigger_date?: unknown; + trigger_condition?: unknown; + start_date?: unknown; + end_date?: unknown; +} + +export interface DamlTriggerFields { + trigger_date: string | null; + trigger_condition: string | null; + start_date: string | null; + end_date: string | null; +} + +export interface NativeTriggerFields { + trigger_date?: string; + trigger_condition?: string; + start_date?: string; + end_date?: string; +} + +const TRIGGER_FIELDS: readonly TriggerField[] = ['trigger_date', 'trigger_condition', 'start_date', 'end_date']; + +function fieldPath(basePath: string, field: TriggerField): string { + return `${basePath}.${field}`; +} + +function rejectInputField( + input: TriggerFieldInput, + field: TriggerField, + type: OcfTriggerDiscriminator, + basePath: string +) { + if (!Object.prototype.hasOwnProperty.call(input, field)) return; + + throw new OcpValidationError(fieldPath(basePath, field), `${field} is not allowed for ${type} triggers`, { + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue: input[field], + }); +} + +function rejectDamlField( + input: TriggerFieldInput, + field: TriggerField, + type: OcfTriggerDiscriminator, + basePath: string +) { + if (input[field] === null || input[field] === undefined) return; + + throw new OcpValidationError(fieldPath(basePath, field), `${field} is not allowed for ${type} triggers`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + receivedValue: input[field], + }); +} + +function rejectInputFields( + input: TriggerFieldInput, + fields: readonly TriggerField[], + type: OcfTriggerDiscriminator, + basePath: string +): void { + for (const field of fields) rejectInputField(input, field, type, basePath); +} + +function rejectDamlFields( + input: TriggerFieldInput, + fields: readonly TriggerField[], + type: OcfTriggerDiscriminator, + basePath: string +): void { + for (const field of fields) rejectDamlField(input, field, type, basePath); +} + +function requiredCondition(value: unknown, path: string): string { + if (value === null || value === undefined) { + throw new OcpValidationError(path, 'trigger_condition is required for condition-based triggers', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(path, 'Expected a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string', + receivedValue: value, + }); + } + return value; +} + +/** Validate an OCF trigger's complete discriminator-specific shape and encode it for DAML. */ +export function triggerFieldsToDaml( + input: TriggerFieldInput, + type: OcfTriggerDiscriminator, + basePath: string +): DamlTriggerFields { + switch (type) { + case 'AUTOMATIC_ON_DATE': + rejectInputFields(input, ['trigger_condition', 'start_date', 'end_date'], type, basePath); + return { + trigger_date: dateStringToDAMLTime(input.trigger_date, fieldPath(basePath, 'trigger_date')), + trigger_condition: null, + start_date: null, + end_date: null, + }; + case 'ELECTIVE_IN_RANGE': + rejectInputFields(input, ['trigger_date', 'trigger_condition'], type, basePath); + return { + trigger_date: null, + trigger_condition: null, + start_date: dateStringToDAMLTime(input.start_date, fieldPath(basePath, 'start_date')), + end_date: dateStringToDAMLTime(input.end_date, fieldPath(basePath, 'end_date')), + }; + case 'AUTOMATIC_ON_CONDITION': + case 'ELECTIVE_ON_CONDITION': + rejectInputFields(input, ['trigger_date', 'start_date', 'end_date'], type, basePath); + return { + trigger_date: null, + trigger_condition: requiredCondition(input.trigger_condition, fieldPath(basePath, 'trigger_condition')), + start_date: null, + end_date: null, + }; + case 'ELECTIVE_AT_WILL': + case 'UNSPECIFIED': + rejectInputFields(input, TRIGGER_FIELDS, type, basePath); + return { trigger_date: null, trigger_condition: null, start_date: null, end_date: null }; + } +} + +/** Validate a DAML trigger's complete discriminator-specific shape and decode it as OCF. */ +export function triggerFieldsFromDaml( + input: TriggerFieldInput, + type: OcfTriggerDiscriminator, + basePath: string +): NativeTriggerFields { + switch (type) { + case 'AUTOMATIC_ON_DATE': + rejectDamlFields(input, ['trigger_condition', 'start_date', 'end_date'], type, basePath); + return { trigger_date: damlTimeToDateString(input.trigger_date, fieldPath(basePath, 'trigger_date')) }; + case 'ELECTIVE_IN_RANGE': + rejectDamlFields(input, ['trigger_date', 'trigger_condition'], type, basePath); + return { + start_date: damlTimeToDateString(input.start_date, fieldPath(basePath, 'start_date')), + end_date: damlTimeToDateString(input.end_date, fieldPath(basePath, 'end_date')), + }; + case 'AUTOMATIC_ON_CONDITION': + case 'ELECTIVE_ON_CONDITION': + rejectDamlFields(input, ['trigger_date', 'start_date', 'end_date'], type, basePath); + return { + trigger_condition: requiredCondition(input.trigger_condition, fieldPath(basePath, 'trigger_condition')), + }; + case 'ELECTIVE_AT_WILL': + case 'UNSPECIFIED': + rejectDamlFields(input, TRIGGER_FIELDS, type, basePath); + return {}; + } +} diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 98337fc6..2527304e 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -9,6 +9,7 @@ import { optionalDateStringToDAMLTime, optionalString, } from '../../../utils/typeConversions'; +import { triggerFieldsToDaml } from '../shared/triggerFields'; export interface SimpleVesting { date: string; @@ -209,15 +210,13 @@ function warrantNestedConversionTrigger( ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { const normalized = normalizeTriggerType(t.type); const typeEnum = triggerTypeToDamlEnum(normalized); + const triggerFields = triggerFieldsToDaml(t, normalized, 'warrantIssuance.exercise_triggers[]'); return { type_: typeEnum, trigger_id: t.trigger_id, nickname: typeof t.nickname === 'string' ? t.nickname : null, trigger_description: typeof t.trigger_description === 'string' ? t.trigger_description : null, - trigger_date: optionalDateStringToDAMLTime(t.trigger_date, 'warrantIssuance.exercise_triggers[].trigger_date'), - trigger_condition: typeof t.trigger_condition === 'string' ? t.trigger_condition : null, - start_date: optionalDateStringToDAMLTime(t.start_date, 'warrantIssuance.exercise_triggers[].start_date'), - end_date: optionalDateStringToDAMLTime(t.end_date, 'warrantIssuance.exercise_triggers[].end_date'), + ...triggerFields, conversion_right: { tag: 'OcfRightConvertible', value: { @@ -388,7 +387,8 @@ function quantitySourceToDamlEnum( } function buildWarrantTrigger(t: WarrantExerciseTriggerInput, _index: number, _ocfId: string) { - const typeEnum = triggerTypeToDamlEnum(normalizeTriggerType(t.type)); + const normalized = normalizeTriggerType(t.type); + const typeEnum = triggerTypeToDamlEnum(normalized); if (!t.trigger_id) { throw new OcpValidationError( 'warrantTrigger.trigger_id', @@ -397,16 +397,14 @@ function buildWarrantTrigger(t: WarrantExerciseTriggerInput, _index: number, _oc ); } const conversion_right = buildWarrantRight(t); + const triggerFields = triggerFieldsToDaml(t, normalized, 'warrantIssuance.exercise_triggers[]'); return { type_: typeEnum, trigger_id: t.trigger_id, nickname: typeof t.nickname === 'string' ? t.nickname : null, trigger_description: typeof t.trigger_description === 'string' ? t.trigger_description : null, conversion_right, - trigger_date: optionalDateStringToDAMLTime(t.trigger_date, 'warrantIssuance.exercise_triggers[].trigger_date'), - trigger_condition: typeof t.trigger_condition === 'string' ? t.trigger_condition : null, - start_date: optionalDateStringToDAMLTime(t.start_date, 'warrantIssuance.exercise_triggers[].start_date'), - end_date: optionalDateStringToDAMLTime(t.end_date, 'warrantIssuance.exercise_triggers[].end_date'), + ...triggerFields, }; } diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index b101e61c..ca6737f1 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -21,6 +21,7 @@ import { optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; +import { triggerFieldsFromDaml } from '../shared/triggerFields'; export interface GetWarrantIssuanceAsOcfParams extends GetByContractIdParams {} export interface GetWarrantIssuanceAsOcfResult { @@ -348,14 +349,7 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf typeof r.nickname === 'string' && r.nickname.length ? r.nickname : undefined; const trigger_description: string | undefined = typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; - const trigger_date = optionalDamlTimeToDateString( - r.trigger_date, - 'warrantIssuance.exercise_triggers[].trigger_date' - ); - const trigger_condition: string | undefined = - typeof r.trigger_condition === 'string' && r.trigger_condition.length ? r.trigger_condition : undefined; - const start_date = optionalDamlTimeToDateString(r.start_date, 'warrantIssuance.exercise_triggers[].start_date'); - const end_date = optionalDamlTimeToDateString(r.end_date, 'warrantIssuance.exercise_triggers[].end_date'); + const triggerFields = triggerFieldsFromDaml(r, type, 'warrantIssuance.exercise_triggers[]'); const conversion_right: WarrantTriggerConversionRight = mapAnyConversionRightFromDaml(r.conversion_right); @@ -365,10 +359,7 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf conversion_right, ...(nickname ? { nickname } : {}), ...(trigger_description ? { trigger_description } : {}), - ...(trigger_date !== undefined ? { trigger_date } : {}), - ...(trigger_condition ? { trigger_condition } : {}), - ...(start_date !== undefined ? { start_date } : {}), - ...(end_date !== undefined ? { end_date } : {}), + ...triggerFields, }; return t; }) diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 1518a2dd..4f2670bd 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -471,64 +471,66 @@ describe('convertible issuance approval-date read boundaries', () => { } ); - test.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'rejects a present non-string conversion trigger %s', - (field) => { - const invalidDate = { seconds: 1 }; + it('decodes only the required AUTOMATIC_ON_DATE trigger_date', () => { + const trigger = { + ...buildDamlSafeTrigger(), + type_: 'OcfTriggerTypeTypeAutomaticOnDate', + trigger_date: '2024-01-15T23:30:00-05:00', + start_date: null, + end_date: null, + }; + const result = damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [trigger] }); - try { - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [{ ...buildDamlSafeTrigger(), [field]: invalidDate }], - }); - throw new Error('Expected trigger date validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `convertibleIssuance.conversion_triggers[].${field}`, - receivedValue: invalidDate, - }); - } - } - ); + expect(result.conversion_triggers[0]).toMatchObject({ type: 'AUTOMATIC_ON_DATE', trigger_date: '2024-01-15' }); + expect(result.conversion_triggers[0]).not.toHaveProperty('start_date'); + expect(result.conversion_triggers[0]).not.toHaveProperty('end_date'); + }); - test.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'rejects a present empty conversion trigger %s', - (field) => { - try { + it('rejects a missing required AUTOMATIC_ON_DATE trigger_date on readback', () => { + expectInvalidDate( + () => damlConvertibleIssuanceDataToNative({ ...BASE_DAML, - conversion_triggers: [{ ...buildDamlSafeTrigger(), [field]: '' }], - }); - throw new Error('Expected trigger date validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: `convertibleIssuance.conversion_triggers[].${field}`, - receivedValue: '', - }); - } - } - ); + conversion_triggers: [ + { ...buildDamlSafeTrigger(), type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_date: null }, + ], + }), + 'convertibleIssuance.conversion_triggers[].trigger_date', + null, + OcpErrorCodes.INVALID_TYPE + ); + }); - test.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'omits a null or absent conversion trigger %s', - (field) => { - const withNull = damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [{ ...buildDamlSafeTrigger(), [field]: null }], - }).conversion_triggers[0] as unknown as Record; - const withoutField = damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [buildDamlSafeTrigger()], - }).conversion_triggers[0] as unknown as Record; + it('decodes only the required ELECTIVE_IN_RANGE start and end dates', () => { + const trigger = { + ...buildDamlSafeTrigger(), + type_: 'OcfTriggerTypeTypeElectiveInRange', + trigger_date: null, + start_date: '2024-01-15T00:00:00Z', + end_date: '2024-02-15T00:00:00Z', + }; + const result = damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [trigger] }); - expect(withNull[field]).toBeUndefined(); - expect(withoutField[field]).toBeUndefined(); - } - ); + expect(result.conversion_triggers[0]).toMatchObject({ + type: 'ELECTIVE_IN_RANGE', + start_date: '2024-01-15', + end_date: '2024-02-15', + }); + expect(result.conversion_triggers[0]).not.toHaveProperty('trigger_date'); + }); + + it('rejects date fields forbidden by the trigger discriminator on readback', () => { + expectInvalidDate( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [{ ...buildDamlSafeTrigger(), trigger_date: '2024-01-15T00:00:00Z' }], + }), + 'convertibleIssuance.conversion_triggers[].trigger_date', + '2024-01-15T00:00:00Z', + OcpErrorCodes.SCHEMA_MISMATCH + ); + }); test.each([ ['', OcpErrorCodes.INVALID_FORMAT], @@ -632,45 +634,53 @@ describe('convertible issuance write date boundaries', () => { } ); - test.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'validates a present conversion trigger %s instead of treating a falsy value as absent', - (field) => { - expectInvalidDate( - () => - convertibleIssuanceDataToDaml({ - ...BASE_INPUT, - conversion_triggers: [{ ...SAFE_TRIGGER_BASE, [field]: '' }], - }), - `convertibleIssuance.conversion_triggers[].${field}`, - '' - ); - } - ); + it('encodes the required AUTOMATIC_ON_DATE trigger_date and no range dates', () => { + const result = convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [ + { ...SAFE_TRIGGER_BASE, type: 'AUTOMATIC_ON_DATE', trigger_date: '2024-01-15T23:30:00-05:00' }, + ], + }); - test.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'rejects a present non-string conversion trigger %s and accepts null/undefined as absent', - (field) => { - const invalidDate = { seconds: 1 }; - expectInvalidDate( - () => - convertibleIssuanceDataToDaml({ - ...BASE_INPUT, - conversion_triggers: [{ ...SAFE_TRIGGER_BASE, [field]: invalidDate }], - }), - `convertibleIssuance.conversion_triggers[].${field}`, - invalidDate, - OcpErrorCodes.INVALID_TYPE - ); + expect(result.conversion_triggers[0]).toMatchObject({ + trigger_date: '2024-01-15T00:00:00.000Z', + start_date: null, + end_date: null, + }); + }); - for (const value of [null, undefined]) { - const result = convertibleIssuanceDataToDaml({ + it('encodes the required ELECTIVE_IN_RANGE dates and no trigger_date', () => { + const result = convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [ + { + ...SAFE_TRIGGER_BASE, + type: 'ELECTIVE_IN_RANGE', + start_date: '2024-01-15T00:30:00+14:00', + end_date: '2024-02-15T23:30:00-05:00', + }, + ], + }); + + expect(result.conversion_triggers[0]).toMatchObject({ + trigger_date: null, + start_date: '2024-01-15T00:00:00.000Z', + end_date: '2024-02-15T00:00:00.000Z', + }); + }); + + it('rejects date fields forbidden by the trigger discriminator on write', () => { + expectInvalidDate( + () => + convertibleIssuanceDataToDaml({ ...BASE_INPUT, - conversion_triggers: [{ ...SAFE_TRIGGER_BASE, [field]: value }], - }); - expect(result.conversion_triggers[0]?.[field]).toBeNull(); - } - } - ); + conversion_triggers: [{ ...SAFE_TRIGGER_BASE, trigger_date: '2024-01-15' }], + }), + 'convertibleIssuance.conversion_triggers[].trigger_date', + '2024-01-15', + OcpErrorCodes.INVALID_FORMAT + ); + }); test.each([ ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 5be8683a..4ecdb319 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -7,7 +7,10 @@ */ import { OcpErrorCodes, OcpParseError, OcpValidationError, type OcpErrorCode } from '../../src/errors'; -import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import { + warrantIssuanceDataToDaml, + type WarrantTriggerTypeInput, +} from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import { ocfDeepEqual } from '../../src/utils/ocfComparison'; @@ -65,10 +68,9 @@ describe('WarrantIssuance round-trip equivalence', () => { }; function stockClassTrigger(overrides: Record = {}) { - return { - type: 'AUTOMATIC_ON_CONDITION' as const, + const triggerType = (overrides.type ?? 'AUTOMATIC_ON_CONDITION') as WarrantTriggerTypeInput; + const trigger = { trigger_id: 'w_stock_ratio', - trigger_condition: 'X', conversion_right: { type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, converts_to_stock_class_id: '16faa6e5-b13a-4dda-bad2-885fccd2975a', @@ -80,7 +82,11 @@ describe('WarrantIssuance round-trip equivalence', () => { }, }, ...overrides, + type: triggerType, }; + return triggerType === 'AUTOMATIC_ON_CONDITION' || triggerType === 'ELECTIVE_ON_CONDITION' + ? { trigger_condition: 'X', ...trigger } + : trigger; } test('basic warrant issuance survives round-trip', () => { @@ -238,74 +244,56 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(damlWarrantIssuanceDataToNative(withoutExpiration).warrant_expiration_date).toBeUndefined(); }); - test.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'rejects a present non-string exercise trigger %s on readback', - (field) => { - const invalidDate = { seconds: 1 }; - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const trigger = daml.exercise_triggers[0]; + it('decodes only the required AUTOMATIC_ON_DATE trigger_date', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const trigger = { + ...daml.exercise_triggers[0], + type_: 'OcfTriggerTypeTypeAutomaticOnDate', + trigger_date: '2024-01-15T23:30:00-05:00', + trigger_condition: null, + start_date: null, + end_date: null, + }; + const result = damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [trigger] }); - try { - damlWarrantIssuanceDataToNative({ - ...daml, - exercise_triggers: [{ ...trigger, [field]: invalidDate }], - }); - throw new Error('Expected trigger date validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `warrantIssuance.exercise_triggers[].${field}`, - receivedValue: invalidDate, - }); - } - } - ); + expect(result.exercise_triggers[0]).toMatchObject({ type: 'AUTOMATIC_ON_DATE', trigger_date: '2024-01-15' }); + expect(result.exercise_triggers[0]).not.toHaveProperty('start_date'); + expect(result.exercise_triggers[0]).not.toHaveProperty('end_date'); + }); - test.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'rejects a present empty exercise trigger %s on readback', - (field) => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const trigger = daml.exercise_triggers[0]; + it('decodes only the required ELECTIVE_IN_RANGE dates', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const trigger = { + ...daml.exercise_triggers[0], + type_: 'OcfTriggerTypeTypeElectiveInRange', + trigger_date: null, + trigger_condition: null, + start_date: '2024-01-15T00:00:00Z', + end_date: '2024-02-15T00:00:00Z', + }; + const result = damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [trigger] }); - try { + expect(result.exercise_triggers[0]).toMatchObject({ + type: 'ELECTIVE_IN_RANGE', + start_date: '2024-01-15', + end_date: '2024-02-15', + }); + expect(result.exercise_triggers[0]).not.toHaveProperty('trigger_date'); + }); + + it('rejects date fields forbidden by the trigger discriminator on readback', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + expectInvalidWarrantDate( + () => damlWarrantIssuanceDataToNative({ ...daml, - exercise_triggers: [{ ...trigger, [field]: '' }], - }); - throw new Error('Expected trigger date validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: `warrantIssuance.exercise_triggers[].${field}`, - receivedValue: '', - }); - } - } - ); - - test.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'omits a null or absent exercise trigger %s on readback', - (field) => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const trigger = { ...daml.exercise_triggers[0] } as unknown as Record; - const absentTrigger = { ...trigger }; - delete absentTrigger[field]; - - const withNull = damlWarrantIssuanceDataToNative({ - ...daml, - exercise_triggers: [{ ...trigger, [field]: null }], - }).exercise_triggers[0] as unknown as Record; - const withoutField = damlWarrantIssuanceDataToNative({ - ...daml, - exercise_triggers: [absentTrigger], - }).exercise_triggers[0] as unknown as Record; - - expect(withNull[field]).toBeUndefined(); - expect(withoutField[field]).toBeUndefined(); - } - ); + exercise_triggers: [{ ...daml.exercise_triggers[0], trigger_date: '2024-01-15T00:00:00Z' }], + }), + 'warrantIssuance.exercise_triggers[].trigger_date', + '2024-01-15T00:00:00Z', + OcpErrorCodes.SCHEMA_MISMATCH + ); + }); test.each(['board_approval_date', 'stockholder_approval_date', 'warrant_expiration_date'] as const)( 'enforces optional write boundary semantics for %s', @@ -343,50 +331,26 @@ describe('WarrantIssuance round-trip equivalence', () => { } ); - test.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'enforces optional outer trigger write boundary semantics for %s', - (field) => { - const fieldPath = `warrantIssuance.exercise_triggers[].${field}`; - const invalidDate = { seconds: 1 }; - - expectInvalidWarrantDate( - () => - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], [field]: '' }], - }), - fieldPath, - '' - ); - expectInvalidWarrantDate( - () => - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], [field]: invalidDate }], - }), - fieldPath, - invalidDate, - OcpErrorCodes.INVALID_TYPE - ); - - for (const value of [null, undefined]) { - const result = warrantIssuanceDataToDaml({ + it('rejects date fields forbidden by the trigger discriminator on write', () => { + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ ...baseWarrantIssuance, - exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], [field]: value }], - }); - expect(result.exercise_triggers[0]?.[field]).toBeNull(); - } - } - ); + exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], trigger_date: '2024-01-15' }], + }), + 'warrantIssuance.exercise_triggers[].trigger_date', + '2024-01-15', + OcpErrorCodes.INVALID_FORMAT + ); + }); - test('uses identical canonical date semantics for outer and nested stock-class triggers', () => { + test('uses identical canonical AUTOMATIC_ON_DATE semantics for outer and nested stock-class triggers', () => { const result = warrantIssuanceDataToDaml({ ...baseWarrantIssuance, exercise_triggers: [ stockClassTrigger({ + type: 'AUTOMATIC_ON_DATE', trigger_date: '2024-01-15T23:30:00-05:00', - start_date: '2024-01-15T00:30:00+14:00', - end_date: '2024-01-15T12:00:00Z', }), ], }); @@ -394,28 +358,36 @@ describe('WarrantIssuance round-trip equivalence', () => { const right = outer.conversion_right as { value: { conversion_trigger: Record } }; const nested = right.value.conversion_trigger; - for (const field of ['trigger_date', 'start_date', 'end_date'] as const) { - expect(outer[field]).toBe('2024-01-15T00:00:00.000Z'); - expect(nested[field]).toBe('2024-01-15T00:00:00.000Z'); - } + expect(outer).toMatchObject({ trigger_date: '2024-01-15T00:00:00.000Z', start_date: null, end_date: null }); + expect(nested).toMatchObject({ trigger_date: '2024-01-15T00:00:00.000Z', start_date: null, end_date: null }); }); - test.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'validates nested stock-class trigger %s before serialization', - (field) => { - const invalidDate = { seconds: 1 }; - expectInvalidWarrantDate( - () => - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - exercise_triggers: [stockClassTrigger({ [field]: invalidDate })], - }), - `warrantIssuance.exercise_triggers[].${field}`, - invalidDate, - OcpErrorCodes.INVALID_TYPE - ); - } - ); + test('uses identical canonical ELECTIVE_IN_RANGE semantics for outer and nested stock-class triggers', () => { + const result = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [ + stockClassTrigger({ + type: 'ELECTIVE_IN_RANGE', + start_date: '2024-01-15T00:30:00+14:00', + end_date: '2024-02-15T23:30:00-05:00', + }), + ], + }); + const outer = result.exercise_triggers[0] as unknown as Record; + const right = outer.conversion_right as { value: { conversion_trigger: Record } }; + const nested = right.value.conversion_trigger; + + expect(outer).toMatchObject({ + trigger_date: null, + start_date: '2024-01-15T00:00:00.000Z', + end_date: '2024-02-15T00:00:00.000Z', + }); + expect(nested).toMatchObject({ + trigger_date: null, + start_date: '2024-01-15T00:00:00.000Z', + end_date: '2024-02-15T00:00:00.000Z', + }); + }); test('STOCK_CLASS_CONVERSION_RIGHT rejects non-NORMAL rounding_type (not persisted in DAML)', () => { const input = { diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 4ad2d6ac..5bb51cef 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -24,6 +24,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { { type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_id: 't1', + trigger_date: '2024-01-15T00:00:00Z', conversion_right: { conversion_mechanism: { tag: 'OcfConvMechNote', @@ -58,6 +59,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { { type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_id: 't1', + trigger_date: '2024-01-15T00:00:00Z', conversion_right: { conversion_mechanism: { tag: 'OcfConvMechSAFE', diff --git a/test/schemaAlignment/dateBoundaryInvariants.test.ts b/test/schemaAlignment/dateBoundaryInvariants.test.ts index ec6ae1c1..e1d1ab6f 100644 --- a/test/schemaAlignment/dateBoundaryInvariants.test.ts +++ b/test/schemaAlignment/dateBoundaryInvariants.test.ts @@ -15,14 +15,13 @@ const DATE_CONVERTERS = new Set([ ]); const REQUIRED_DATE_CONVERTERS = new Set(['dateStringToDAMLTime', 'damlTimeToDateString']); +const DISCRIMINATED_TRIGGER_DATE_FIELDS = new Set(['trigger_date', 'start_date', 'end_date']); +const TRIGGER_FIELDS_HELPER = `${path.sep}shared${path.sep}triggerFields.ts`; const OPTIONAL_DATE_FIELDS = new Set([ 'accrual_end_date', 'board_approval_date', - 'end_date', 'expires_at', - 'start_date', 'stockholder_approval_date', - 'trigger_date', 'warrant_expiration_date', ]); @@ -82,10 +81,14 @@ describe('date boundary source invariants', () => { const literalPath = ts.isStringLiteralLike(fieldPath) ? fieldPath.text : undefined; const templatePath = ts.isTemplateExpression(fieldPath) && fieldPath.getText(sourceFile).includes('.'); const forwardedPath = ts.isIdentifier(fieldPath) && ['fieldPath', 'dateFieldPath'].includes(fieldPath.text); + const constructedPath = + ts.isCallExpression(fieldPath) && + ts.isIdentifier(fieldPath.expression) && + fieldPath.expression.text === 'fieldPath'; if (literalPath !== undefined && (!literalPath.includes('.') || literalPath === 'date')) { violations.push(`${location(sourceFile, fieldPath)} date fieldPath must be entity-specific`); - } else if (literalPath === undefined && !templatePath && !forwardedPath) { + } else if (literalPath === undefined && !templatePath && !forwardedPath && !constructedPath) { violations.push( `${location(sourceFile, fieldPath)} date fieldPath must be literal or explicitly forwarded` ); @@ -95,6 +98,11 @@ describe('date boundary source invariants', () => { const value = node.arguments[0]; if (ts.isPropertyAccessExpression(value)) { const field = value.name.text; + if (DISCRIMINATED_TRIGGER_DATE_FIELDS.has(field) && !file.endsWith(TRIGGER_FIELDS_HELPER)) { + violations.push( + `${location(sourceFile, node)} discriminated ${field} must use the shared trigger-fields boundary` + ); + } if (OPTIONAL_DATE_FIELDS.has(field) && REQUIRED_DATE_CONVERTERS.has(node.expression.text)) { violations.push(`${location(sourceFile, node)} optional ${field} must use an optional date converter`); } diff --git a/test/utils/triggerFields.test.ts b/test/utils/triggerFields.test.ts new file mode 100644 index 00000000..f7e405da --- /dev/null +++ b/test/utils/triggerFields.test.ts @@ -0,0 +1,279 @@ +import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { + triggerFieldsFromDaml, + triggerFieldsToDaml, + type OcfTriggerDiscriminator, +} from '../../src/functions/OpenCapTable/shared/triggerFields'; + +const PATH = 'issuance.triggers[]'; + +function expectTriggerFieldError( + action: () => unknown, + field: 'trigger_date' | 'trigger_condition' | 'start_date' | 'end_date', + receivedValue: unknown, + code: OcpErrorCode +): void { + try { + action(); + throw new Error('Expected trigger field validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ code, fieldPath: `${PATH}.${field}`, receivedValue }); + } +} + +describe('trigger discriminator boundaries', () => { + test('AUTOMATIC_ON_DATE requires and canonicalizes only trigger_date on write', () => { + expect(triggerFieldsToDaml({ trigger_date: '2024-01-15T23:30:00-05:00' }, 'AUTOMATIC_ON_DATE', PATH)).toEqual({ + trigger_date: '2024-01-15T00:00:00.000Z', + trigger_condition: null, + start_date: null, + end_date: null, + }); + }); + + test.each([ + [null, OcpErrorCodes.INVALID_TYPE], + [undefined, OcpErrorCodes.INVALID_TYPE], + ['', OcpErrorCodes.INVALID_FORMAT], + [{ seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const)('AUTOMATIC_ON_DATE rejects required trigger_date %p on write', (value, code) => { + expectTriggerFieldError( + () => triggerFieldsToDaml({ trigger_date: value }, 'AUTOMATIC_ON_DATE', PATH), + 'trigger_date', + value, + code + ); + }); + + test('ELECTIVE_IN_RANGE requires and canonicalizes start_date and end_date on write', () => { + expect( + triggerFieldsToDaml( + { start_date: '2024-01-15T00:30:00+14:00', end_date: '2024-02-15T23:30:00-05:00' }, + 'ELECTIVE_IN_RANGE', + PATH + ) + ).toEqual({ + trigger_date: null, + trigger_condition: null, + start_date: '2024-01-15T00:00:00.000Z', + end_date: '2024-02-15T00:00:00.000Z', + }); + }); + + test.each(['start_date', 'end_date'] as const)( + 'ELECTIVE_IN_RANGE rejects missing or malformed required %s on write', + (field) => { + for (const [value, code] of [ + [null, OcpErrorCodes.INVALID_TYPE], + [undefined, OcpErrorCodes.INVALID_TYPE], + ['', OcpErrorCodes.INVALID_FORMAT], + [{ seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const) { + expectTriggerFieldError( + () => + triggerFieldsToDaml( + { start_date: '2024-01-15', end_date: '2024-02-15', [field]: value }, + 'ELECTIVE_IN_RANGE', + PATH + ), + field, + value, + code + ); + } + } + ); + + test.each([ + ['AUTOMATIC_ON_DATE', 'start_date'], + ['AUTOMATIC_ON_DATE', 'end_date'], + ['ELECTIVE_IN_RANGE', 'trigger_date'], + ['AUTOMATIC_ON_CONDITION', 'trigger_date'], + ['ELECTIVE_AT_WILL', 'start_date'], + ['ELECTIVE_ON_CONDITION', 'end_date'], + ['UNSPECIFIED', 'trigger_date'], + ] as const)('%s rejects forbidden %s on write, including explicit null', (type, field) => { + for (const value of ['2024-01-15', null, undefined]) { + const input = + type === 'AUTOMATIC_ON_DATE' + ? { trigger_date: '2024-01-15', [field]: value } + : type === 'ELECTIVE_IN_RANGE' + ? { start_date: '2024-01-15', end_date: '2024-02-15', [field]: value } + : { [field]: value }; + expectTriggerFieldError(() => triggerFieldsToDaml(input, type, PATH), field, value, OcpErrorCodes.INVALID_FORMAT); + } + }); + + test.each(['AUTOMATIC_ON_CONDITION', 'ELECTIVE_ON_CONDITION'] as const)( + '%s requires a string trigger_condition on write', + (type) => { + expect(triggerFieldsToDaml({ trigger_condition: '' }, type, PATH)).toEqual({ + trigger_date: null, + trigger_condition: '', + start_date: null, + end_date: null, + }); + for (const [value, code] of [ + [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + [{ condition: true }, OcpErrorCodes.INVALID_TYPE], + ] as const) { + expectTriggerFieldError( + () => triggerFieldsToDaml({ trigger_condition: value }, type, PATH), + 'trigger_condition', + value, + code + ); + } + } + ); + + test.each(['AUTOMATIC_ON_DATE', 'ELECTIVE_IN_RANGE', 'ELECTIVE_AT_WILL', 'UNSPECIFIED'] as const)( + '%s rejects a forbidden trigger_condition on write', + (type) => { + const input = + type === 'AUTOMATIC_ON_DATE' + ? { trigger_date: '2024-01-15', trigger_condition: 'forbidden' } + : type === 'ELECTIVE_IN_RANGE' + ? { start_date: '2024-01-15', end_date: '2024-02-15', trigger_condition: 'forbidden' } + : { trigger_condition: 'forbidden' }; + expectTriggerFieldError( + () => triggerFieldsToDaml(input, type, PATH), + 'trigger_condition', + 'forbidden', + OcpErrorCodes.INVALID_FORMAT + ); + } + ); + + test('field-free variants encode all discriminator optionals as null', () => { + expect(triggerFieldsToDaml({}, 'ELECTIVE_AT_WILL', PATH)).toEqual({ + trigger_date: null, + trigger_condition: null, + start_date: null, + end_date: null, + }); + }); + + test('read-side AUTOMATIC_ON_DATE and ELECTIVE_IN_RANGE return only their schema fields', () => { + expect( + triggerFieldsFromDaml( + { trigger_date: '2024-01-15T23:30:00-05:00', trigger_condition: null, start_date: null, end_date: null }, + 'AUTOMATIC_ON_DATE', + PATH + ) + ).toEqual({ trigger_date: '2024-01-15' }); + expect( + triggerFieldsFromDaml( + { + trigger_date: null, + trigger_condition: null, + start_date: '2024-01-15T00:00:00Z', + end_date: '2024-02-15T00:00:00Z', + }, + 'ELECTIVE_IN_RANGE', + PATH + ) + ).toEqual({ start_date: '2024-01-15', end_date: '2024-02-15' }); + }); + + test.each([ + ['AUTOMATIC_ON_DATE', 'trigger_date'], + ['ELECTIVE_IN_RANGE', 'start_date'], + ['ELECTIVE_IN_RANGE', 'end_date'], + ] as const)('%s rejects missing required %s on read', (type, field) => { + const input = + type === 'AUTOMATIC_ON_DATE' + ? { trigger_date: null, start_date: null, end_date: null } + : { trigger_date: null, start_date: '2024-01-15', end_date: '2024-02-15', [field]: null }; + expectTriggerFieldError(() => triggerFieldsFromDaml(input, type, PATH), field, null, OcpErrorCodes.INVALID_TYPE); + }); + + test.each([ + ['AUTOMATIC_ON_DATE', 'start_date'], + ['AUTOMATIC_ON_DATE', 'end_date'], + ['ELECTIVE_IN_RANGE', 'trigger_date'], + ['AUTOMATIC_ON_CONDITION', 'trigger_date'], + ['ELECTIVE_AT_WILL', 'start_date'], + ['ELECTIVE_ON_CONDITION', 'end_date'], + ['UNSPECIFIED', 'trigger_date'], + ] as const)('%s rejects a populated forbidden %s on read', (type, field) => { + const input = + type === 'AUTOMATIC_ON_DATE' + ? { trigger_date: '2024-01-15', start_date: null, end_date: null, [field]: '2024-02-15' } + : type === 'ELECTIVE_IN_RANGE' + ? { + trigger_date: '2024-03-15', + start_date: '2024-01-15', + end_date: '2024-02-15', + } + : { trigger_date: null, start_date: null, end_date: null, [field]: '2024-01-15' }; + expectTriggerFieldError( + () => triggerFieldsFromDaml(input, type, PATH), + field, + input[field], + OcpErrorCodes.SCHEMA_MISMATCH + ); + }); + + test.each(['AUTOMATIC_ON_CONDITION', 'ELECTIVE_ON_CONDITION'] as const)( + '%s requires and emits trigger_condition on read', + (type) => { + expect( + triggerFieldsFromDaml( + { trigger_date: null, trigger_condition: '', start_date: null, end_date: null }, + type, + PATH + ) + ).toEqual({ trigger_condition: '' }); + expectTriggerFieldError( + () => + triggerFieldsFromDaml( + { trigger_date: null, trigger_condition: null, start_date: null, end_date: null }, + type, + PATH + ), + 'trigger_condition', + null, + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); + } + ); + + test.each(['AUTOMATIC_ON_DATE', 'ELECTIVE_IN_RANGE', 'ELECTIVE_AT_WILL', 'UNSPECIFIED'] as const)( + '%s rejects a populated forbidden trigger_condition on read', + (type) => { + const input = + type === 'AUTOMATIC_ON_DATE' + ? { trigger_date: '2024-01-15', trigger_condition: 'forbidden', start_date: null, end_date: null } + : type === 'ELECTIVE_IN_RANGE' + ? { + trigger_date: null, + trigger_condition: 'forbidden', + start_date: '2024-01-15', + end_date: '2024-02-15', + } + : { trigger_date: null, trigger_condition: 'forbidden', start_date: null, end_date: null }; + expectTriggerFieldError( + () => triggerFieldsFromDaml(input, type, PATH), + 'trigger_condition', + 'forbidden', + OcpErrorCodes.SCHEMA_MISMATCH + ); + } + ); + + test.each(['ELECTIVE_AT_WILL', 'UNSPECIFIED'] as const)( + '%s accepts null DAML optionals and emits no discriminator properties', + (type: OcfTriggerDiscriminator) => { + expect( + triggerFieldsFromDaml( + { trigger_date: null, trigger_condition: null, start_date: null, end_date: null }, + type, + PATH + ) + ).toEqual({}); + } + ); +}); From e9dc120521dd96e112c8a32eb9f0900fcfb0fb01 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 19:41:20 -0400 Subject: [PATCH 28/47] fix: address typed OCF review findings --- src/functions/OpenCapTable/capTable/index.ts | 2 +- .../stockIssuance/getStockIssuanceAsOcf.ts | 43 +++++++---- .../stockPlan/getStockPlanAsOcf.ts | 10 +-- .../convertibleCancellationConverters.test.ts | 72 ++++++------------- .../converters/planSecurityConverters.test.ts | 1 + .../stockIssuanceReadConversions.test.ts | 41 +++++++++-- test/declarations/publicApi.types.ts | 10 +++ test/types/capTableBatch.types.ts | 5 ++ test/utils/ocfZodSchemas.test.ts | 55 +++++--------- test/validation/damlToOcfValidation.test.ts | 64 ++++++++++++++++- 10 files changed, 190 insertions(+), 113 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/index.ts b/src/functions/OpenCapTable/capTable/index.ts index 2b45d7f8..748107d7 100644 --- a/src/functions/OpenCapTable/capTable/index.ts +++ b/src/functions/OpenCapTable/capTable/index.ts @@ -25,7 +25,7 @@ export { type BatchItemMeta, type CapTableBatchParams, } from './CapTableBatch'; -export { convertToDaml } from './ocfToDaml'; +export { convertOperationToDaml, convertToDaml } from './ocfToDaml'; // DAML to OCF conversion (read operations) export { diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index f9a7c28d..ab0cfe1a 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -32,17 +32,36 @@ function damlStockIssuanceTypeToNative(t: string | null): StockIssuanceType | un } } +type RequiredStockIssuanceStringField = + | 'id' + | 'date' + | 'security_id' + | 'custom_id' + | 'stakeholder_id' + | 'stock_class_id'; + +function requireStockIssuanceString(data: Record, field: RequiredStockIssuanceStringField): string { + const value = data[field]; + if (typeof value !== 'string' || value.length === 0) { + throw new OcpValidationError(`stockIssuance.${field}`, 'Required field is missing or invalid', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + return value; +} + export function damlStockIssuanceDataToNative( d: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuanceOcfData ): OcfStockIssuance { const anyD = d as unknown as Record; - const { id } = anyD; - if (typeof id !== 'string' || id.length === 0) { - throw new OcpValidationError('stockIssuance.id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: id, - }); - } + const id = requireStockIssuanceString(anyD, 'id'); + const date = requireStockIssuanceString(anyD, 'date'); + const securityId = requireStockIssuanceString(anyD, 'security_id'); + const customId = requireStockIssuanceString(anyD, 'custom_id'); + const stakeholderId = requireStockIssuanceString(anyD, 'stakeholder_id'); + const stockClassId = requireStockIssuanceString(anyD, 'stock_class_id'); const vestings = Array.isArray((anyD as { vestings?: unknown }).vestings) ? (anyD as { vestings: Array<{ date: string; amount: string }> }).vestings.map((vesting) => ({ date: damlTimeToDateString(vesting.date), @@ -53,10 +72,10 @@ export function damlStockIssuanceDataToNative( return { object_type: 'TX_STOCK_ISSUANCE', id, - date: damlTimeToDateString(d.date), - security_id: d.security_id, - custom_id: d.custom_id, - stakeholder_id: d.stakeholder_id, + date: damlTimeToDateString(date), + security_id: securityId, + custom_id: customId, + stakeholder_id: stakeholderId, ...(d.board_approval_date && { board_approval_date: damlTimeToDateString(d.board_approval_date), }), @@ -69,7 +88,7 @@ export function damlStockIssuanceDataToNative( .security_law_exemptions : [] ).map(damlSecurityExemptionToNative), - stock_class_id: d.stock_class_id, + stock_class_id: stockClassId, ...(d.stock_plan_id && { stock_plan_id: d.stock_plan_id }), share_numbers_issued: Array.isArray((anyD as { share_numbers_issued?: unknown }).share_numbers_issued) ? ( diff --git a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts index 83028d3e..aa872d82 100644 --- a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts @@ -101,16 +101,16 @@ export async function getStockPlanAsOcf( expectedTemplateId: Fairmint.OpenCapTable.OCF.StockPlan.StockPlan.templateId, }); - if (!('plan_data' in createArgument)) { - throw new OcpParseError('plan_data not found in contract create argument', { + const planData = createArgument.plan_data; + if (typeof planData !== 'object' || planData === null || Array.isArray(planData)) { + throw new OcpParseError('plan_data must be a non-null object in contract create argument', { source: 'StockPlan.createArgument', code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { receivedValue: planData }, }); } - const stockPlan = damlStockPlanDataToNative( - createArgument.plan_data as Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData - ); + const stockPlan = damlStockPlanDataToNative(planData as Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData); return { stockPlan, contractId: params.contractId }; } diff --git a/test/converters/convertibleCancellationConverters.test.ts b/test/converters/convertibleCancellationConverters.test.ts index de5c4213..2e65af52 100644 --- a/test/converters/convertibleCancellationConverters.test.ts +++ b/test/converters/convertibleCancellationConverters.test.ts @@ -1,65 +1,39 @@ -import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpValidationError } from '../../src/errors'; -import { getConvertibleCancellationAsOcf } from '../../src/functions/OpenCapTable/convertibleCancellation'; - -function createMockClient(createArgument: Record): LedgerJsonApiClient { - return { - getEventsByContractId: jest.fn().mockResolvedValue({ - created: { - createdEvent: { - createArgument, - }, - }, - }), - } as unknown as LedgerJsonApiClient; -} +import { damlConvertibleCancellationToNative } from '../../src/functions/OpenCapTable/convertibleCancellation'; describe('Convertible cancellation converters', () => { - test('the dedicated getter returns the canonical monetary amount', async () => { - const client = createMockClient({ - cancellation_data: { - id: 'convertible-cancellation-1', - date: '2026-07-09T00:00:00.000Z', - security_id: 'convertible-security-1', - amount: { amount: '1250.5000000000', currency: 'USD' }, - balance_security_id: 'convertible-security-balance-1', - reason_text: 'Partial repayment', - comments: ['Board approved'], - }, - }); - - const result = await getConvertibleCancellationAsOcf(client, { - contractId: 'convertible-cancellation-contract-1', + test('converts DAML data to a canonical cancellation event', () => { + const result = damlConvertibleCancellationToNative({ + id: 'convertible-cancellation-1', + date: '2026-07-09T00:00:00.000Z', + security_id: 'convertible-security-1', + amount: { amount: '1250.5000000000', currency: 'USD' }, + balance_security_id: 'convertible-security-balance-1', + reason_text: 'Partial repayment', + comments: ['Board approved'], }); expect(result).toEqual({ - contractId: 'convertible-cancellation-contract-1', - event: { - object_type: 'TX_CONVERTIBLE_CANCELLATION', - id: 'convertible-cancellation-1', - date: '2026-07-09', - security_id: 'convertible-security-1', - amount: { amount: '1250.5', currency: 'USD' }, - balance_security_id: 'convertible-security-balance-1', - reason_text: 'Partial repayment', - comments: ['Board approved'], - }, + object_type: 'TX_CONVERTIBLE_CANCELLATION', + id: 'convertible-cancellation-1', + date: '2026-07-09', + security_id: 'convertible-security-1', + amount: { amount: '1250.5', currency: 'USD' }, + balance_security_id: 'convertible-security-balance-1', + reason_text: 'Partial repayment', + comments: ['Board approved'], }); }); - test('the dedicated getter rejects a cancellation without an amount', async () => { - const client = createMockClient({ - cancellation_data: { + test('rejects DAML data without the required amount', () => { + expect(() => + damlConvertibleCancellationToNative({ id: 'convertible-cancellation-2', date: '2026-07-09T00:00:00.000Z', security_id: 'convertible-security-2', reason_text: 'Missing amount', comments: [], - }, - }); - - await expect( - getConvertibleCancellationAsOcf(client, { contractId: 'convertible-cancellation-contract-2' }) - ).rejects.toThrow(OcpValidationError); + }) + ).toThrow(OcpValidationError); }); }); diff --git a/test/converters/planSecurityConverters.test.ts b/test/converters/planSecurityConverters.test.ts index 2b41b5bf..8f451a24 100644 --- a/test/converters/planSecurityConverters.test.ts +++ b/test/converters/planSecurityConverters.test.ts @@ -180,6 +180,7 @@ describe('PlanSecurity Type Converters', () => { custom_id: 'custom-001', stakeholder_id: 'stakeholder-001', stock_plan_id: 'plan-001', + compensation_type: 'OPTION', quantity: '10000', expiration_date: null, termination_exercise_windows: [], diff --git a/test/createOcf/stockIssuanceReadConversions.test.ts b/test/createOcf/stockIssuanceReadConversions.test.ts index bb474faa..a4c96d7f 100644 --- a/test/createOcf/stockIssuanceReadConversions.test.ts +++ b/test/createOcf/stockIssuanceReadConversions.test.ts @@ -1,8 +1,22 @@ /** Unit tests for stock issuance DAML→OCF read conversions. */ +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import { parseOcfEntityInput } from '../../src/utils/ocfZodSchemas'; +const REQUIRED_STRING_FIELDS = ['id', 'date', 'security_id', 'custom_id', 'stakeholder_id', 'stock_class_id'] as const; + +const INVALID_REQUIRED_STRING_VALUES = [ + { description: 'undefined', value: undefined }, + { description: 'null', value: null }, + { description: 'empty', value: '' }, + { description: 'non-string', value: 42 }, +] as const; + +const requiredStringValidationCases = REQUIRED_STRING_FIELDS.flatMap((field) => + INVALID_REQUIRED_STRING_VALUES.map(({ description, value }) => ({ field, description, value })) +); + function makeMinimalDamlStockIssuance(overrides: Record = {}): Record { return { id: 'test-id', @@ -56,18 +70,31 @@ describe('damlStockIssuanceDataToNative', () => { }); describe('required field extraction', () => { - test.each([undefined, null, ''])('rejects missing or invalid id %p', (id) => { - const daml = makeMinimalDamlStockIssuance({ id }); - - expect(() => damlStockIssuanceDataToNative(daml as Parameters[0])).toThrow( - 'stockIssuance.id' - ); - }); + test.each(requiredStringValidationCases)( + 'rejects $description $field values with structured validation details', + ({ field, value }) => { + const daml = makeMinimalDamlStockIssuance({ [field]: value }); + + try { + damlStockIssuanceDataToNative(daml as Parameters[0]); + throw new Error('Expected stock issuance conversion to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: `stockIssuance.${field}`, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + } + ); test('extracts all required fields correctly', () => { const daml = makeMinimalDamlStockIssuance(); const result = damlStockIssuanceDataToNative(daml as Parameters[0]); expect(result.id).toBe('test-id'); + expect(result.date).toBe('2024-01-15'); expect(result.security_id).toBe('sec-1'); expect(result.custom_id).toBe('CS-1'); expect(result.stakeholder_id).toBe('sh-1'); diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 91232e39..e2ffdeb0 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -2,6 +2,7 @@ /** Compile-time smoke tests for declarations exported by the built SDK. */ import { + convertOperationToDaml, convertToDaml, type CapTableBatch, type CapTableBatchOperations, @@ -111,6 +112,15 @@ function verifyPublishedBatchApi( }; void operations; + const createOperation: OcfCreateOperation = { + type: 'stakeholder', + data: stakeholder, + }; + convertOperationToDaml(createOperation); + + // @ts-expect-error published operation converter preserves exact kind/payload correlation + convertOperationToDaml({ type: 'stockClass', data: stakeholder }); + // @ts-expect-error published operation declarations preserve exact payload identity const invalidIdentityOperation: OcfCreateOperation = { type: 'warrantAcceptance', diff --git a/test/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index ed172fea..50db7029 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -9,6 +9,7 @@ import { type CapTableBatch, type CapTableBatchOperations, + convertOperationToDaml, convertToDaml, type OcfCreateOperation, type OcfEntityDataMap, @@ -115,8 +116,12 @@ function verifyCapTableBatchContract( type: 'stakeholder', data: stakeholder, }; + convertOperationToDaml(createOperation); void createOperation; + // @ts-expect-error operation conversion preserves the entity kind/payload correlation + convertOperationToDaml({ type: 'stockClass', data: stakeholder }); + // @ts-expect-error operation objects preserve identity for structurally identical payloads const invalidIdentityOperation: OcfCreateOperation = { type: 'warrantAcceptance', diff --git a/test/utils/ocfZodSchemas.test.ts b/test/utils/ocfZodSchemas.test.ts index 17ef3017..4c86d395 100644 --- a/test/utils/ocfZodSchemas.test.ts +++ b/test/utils/ocfZodSchemas.test.ts @@ -5,6 +5,7 @@ import { type OcfEntityType, } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { parseOcfEntityInput, parseOcfObject, resolveOcfSchemaDir } from '../../src/utils/ocfZodSchemas'; +import { PLAN_SECURITY_OBJECT_TYPE_MAP, type PlanSecurityObjectType } from '../../src/utils/planSecurityAliases'; import { loadProductionFixture, loadSyntheticFixture, stripSourceMetadata } from './productionFixtures'; const schemaAvailabilityError = (() => { @@ -41,43 +42,23 @@ const entityDiscriminatorCases = entityTypes.map((entityType, index) => { }; }); -const planSecurityDiscriminatorCases = [ - { - sourceObjectType: 'TX_PLAN_SECURITY_ACCEPTANCE', - canonicalObjectType: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', - fixture: () => loadSyntheticFixture>('equityCompensationAcceptance'), - }, - { - sourceObjectType: 'TX_PLAN_SECURITY_CANCELLATION', - canonicalObjectType: 'TX_EQUITY_COMPENSATION_CANCELLATION', - fixture: () => loadProductionFixture>('equityCompensationCancellation'), - }, - { - sourceObjectType: 'TX_PLAN_SECURITY_EXERCISE', - canonicalObjectType: 'TX_EQUITY_COMPENSATION_EXERCISE', - fixture: () => loadProductionFixture>('equityCompensationExercise'), - }, - { - sourceObjectType: 'TX_PLAN_SECURITY_ISSUANCE', - canonicalObjectType: 'TX_EQUITY_COMPENSATION_ISSUANCE', - fixture: () => loadProductionFixture>('equityCompensationIssuance', 'option-iso'), - }, - { - sourceObjectType: 'TX_PLAN_SECURITY_RELEASE', - canonicalObjectType: 'TX_EQUITY_COMPENSATION_RELEASE', - fixture: () => loadSyntheticFixture>('equityCompensationRelease'), - }, - { - sourceObjectType: 'TX_PLAN_SECURITY_RETRACTION', - canonicalObjectType: 'TX_EQUITY_COMPENSATION_RETRACTION', - fixture: () => loadSyntheticFixture>('equityCompensationRetraction'), - }, - { - sourceObjectType: 'TX_PLAN_SECURITY_TRANSFER', - canonicalObjectType: 'TX_EQUITY_COMPENSATION_TRANSFER', - fixture: () => loadSyntheticFixture>('equityCompensationTransfer'), - }, -] as const; +const PLAN_SECURITY_FIXTURE_LOADERS = { + TX_PLAN_SECURITY_ACCEPTANCE: () => loadSyntheticFixture>('equityCompensationAcceptance'), + TX_PLAN_SECURITY_CANCELLATION: () => loadProductionFixture>('equityCompensationCancellation'), + TX_PLAN_SECURITY_EXERCISE: () => loadProductionFixture>('equityCompensationExercise'), + TX_PLAN_SECURITY_ISSUANCE: () => + loadProductionFixture>('equityCompensationIssuance', 'option-iso'), + TX_PLAN_SECURITY_RELEASE: () => loadSyntheticFixture>('equityCompensationRelease'), + TX_PLAN_SECURITY_RETRACTION: () => loadSyntheticFixture>('equityCompensationRetraction'), + TX_PLAN_SECURITY_TRANSFER: () => loadSyntheticFixture>('equityCompensationTransfer'), +} satisfies Record Record>; + +const planSecurityObjectTypes = Object.keys(PLAN_SECURITY_OBJECT_TYPE_MAP) as PlanSecurityObjectType[]; +const planSecurityDiscriminatorCases = planSecurityObjectTypes.map((sourceObjectType) => ({ + sourceObjectType, + canonicalObjectType: PLAN_SECURITY_OBJECT_TYPE_MAP[sourceObjectType], + fixture: PLAN_SECURITY_FIXTURE_LOADERS[sourceObjectType], +})); describe('ocfZodSchemas', () => { beforeAll(() => { diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 07df8ac9..53a5a282 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -7,7 +7,8 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { getConvertibleCancellationAsOcf } from '../../src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf'; import { getEquityCompensationIssuanceAsOcf } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; import { getStakeholderRelationshipChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf'; import { getStakeholderStatusChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf'; @@ -32,7 +33,7 @@ const MOCK_LEDGER_TEMPLATE_IDS = { */ function createMockClient( dataKey: string, - data: Record, + data: unknown, ledgerMeta?: { templateId?: string; packageName?: string } ): LedgerJsonApiClient { const createdEvent: Record = { @@ -145,6 +146,42 @@ describe('DAML to OCF Validation', () => { }); }); + describe('getConvertibleCancellationAsOcf', () => { + const validCancellationData = { + id: 'convertible-cancellation-1', + date: '2026-07-09T00:00:00.000Z', + security_id: 'convertible-security-1', + amount: { amount: '1250.5000000000', currency: 'USD' }, + balance_security_id: 'convertible-security-balance-1', + reason_text: 'Partial repayment', + comments: ['Board approved'], + }; + + test('reads the contract and returns the canonical monetary amount', async () => { + const client = createMockClient('cancellation_data', validCancellationData); + + const result = await getConvertibleCancellationAsOcf(client, { + contractId: 'convertible-cancellation-contract-1', + }); + + expect(result.contractId).toBe('convertible-cancellation-contract-1'); + expect(result.event.amount).toEqual({ amount: '1250.5', currency: 'USD' }); + }); + + test('rejects a fetched cancellation without an amount', async () => { + const { amount: _, ...invalidData } = validCancellationData; + const client = createMockClient('cancellation_data', invalidData); + + await expect( + getConvertibleCancellationAsOcf(client, { contractId: 'convertible-cancellation-contract-2' }) + ).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'convertibleCancellation.amount', + }); + }); + }); + describe('getWarrantIssuanceAsOcf', () => { const validWarrantData = { id: 'wi-001', @@ -319,6 +356,29 @@ describe('DAML to OCF Validation', () => { stock_class_ids: ['sc-001'], }; + test.each([ + { description: 'missing', value: undefined }, + { description: 'null', value: null }, + { description: 'a string', value: 'invalid' }, + { description: 'a number', value: 42 }, + { description: 'an array', value: [] }, + ])('throws OcpParseError when plan_data is $description', async ({ value }) => { + const client = createMockClient('plan_data', value, { + templateId: MOCK_LEDGER_TEMPLATE_IDS.stockPlan, + }); + + try { + await getStockPlanAsOcf(client, { contractId: 'test-contract' }); + throw new Error('Expected StockPlan read to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StockPlan.createArgument', + }); + } + }); + test('throws OcpValidationError when id is missing', async () => { const { id: _, ...invalidData } = validStockPlanData; const client = createMockClient('plan_data', invalidData, { From 0cc6a29940f810eeea8b25789cf80d4fab14cb9d Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 19:43:19 -0400 Subject: [PATCH 29/47] fix: keep operation converter internal --- src/functions/OpenCapTable/capTable/index.ts | 2 +- test/declarations/publicApi.types.ts | 10 ---------- test/types/capTableBatch.types.ts | 5 ----- 3 files changed, 1 insertion(+), 16 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/index.ts b/src/functions/OpenCapTable/capTable/index.ts index 748107d7..2b45d7f8 100644 --- a/src/functions/OpenCapTable/capTable/index.ts +++ b/src/functions/OpenCapTable/capTable/index.ts @@ -25,7 +25,7 @@ export { type BatchItemMeta, type CapTableBatchParams, } from './CapTableBatch'; -export { convertOperationToDaml, convertToDaml } from './ocfToDaml'; +export { convertToDaml } from './ocfToDaml'; // DAML to OCF conversion (read operations) export { diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index e2ffdeb0..91232e39 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -2,7 +2,6 @@ /** Compile-time smoke tests for declarations exported by the built SDK. */ import { - convertOperationToDaml, convertToDaml, type CapTableBatch, type CapTableBatchOperations, @@ -112,15 +111,6 @@ function verifyPublishedBatchApi( }; void operations; - const createOperation: OcfCreateOperation = { - type: 'stakeholder', - data: stakeholder, - }; - convertOperationToDaml(createOperation); - - // @ts-expect-error published operation converter preserves exact kind/payload correlation - convertOperationToDaml({ type: 'stockClass', data: stakeholder }); - // @ts-expect-error published operation declarations preserve exact payload identity const invalidIdentityOperation: OcfCreateOperation = { type: 'warrantAcceptance', diff --git a/test/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index 50db7029..ed172fea 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -9,7 +9,6 @@ import { type CapTableBatch, type CapTableBatchOperations, - convertOperationToDaml, convertToDaml, type OcfCreateOperation, type OcfEntityDataMap, @@ -116,12 +115,8 @@ function verifyCapTableBatchContract( type: 'stakeholder', data: stakeholder, }; - convertOperationToDaml(createOperation); void createOperation; - // @ts-expect-error operation conversion preserves the entity kind/payload correlation - convertOperationToDaml({ type: 'stockClass', data: stakeholder }); - // @ts-expect-error operation objects preserve identity for structurally identical payloads const invalidIdentityOperation: OcfCreateOperation = { type: 'warrantAcceptance', From be846500fe3ebb04426d5431861e8957676cf7a0 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 21:26:37 -0400 Subject: [PATCH 30/47] fix: validate ledger read payloads --- .../stockIssuance/getStockIssuanceAsOcf.ts | 53 ++++++++++++++++--- .../stockPlan/getStockPlanAsOcf.ts | 38 +++++++++---- .../converters/planSecurityConverters.test.ts | 6 +-- .../stockIssuanceReadConversions.test.ts | 44 ++++++++++++++- test/validation/damlToOcfValidation.test.ts | 34 +++++++++--- 5 files changed, 147 insertions(+), 28 deletions(-) diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index ab0cfe1a..ee2d464a 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -3,7 +3,12 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockIssuance, SecurityExemption, ShareNumberRange, StockIssuanceType } from '../../../types/native'; -import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { + damlMonetaryToNative, + damlTimeToDateString, + isRecord, + normalizeNumericString, +} from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; function damlSecurityExemptionToNative(e: Fairmint.OpenCapTable.Types.Stock.OcfSecurityExemption): SecurityExemption { @@ -52,9 +57,33 @@ function requireStockIssuanceString(data: Record, field: Requir return value; } +function decodeStockIssuanceVesting(input: unknown, index: number): Fairmint.OpenCapTable.Types.Vesting.OcfVesting { + try { + return Fairmint.OpenCapTable.Types.Vesting.OcfVesting.decoder.runWithException(input); + } catch (error) { + const cause = error instanceof Error ? error : undefined; + const detail = cause?.message ?? String(error); + throw new OcpParseError(`Invalid DAML vesting at index ${index}: ${detail}`, { + source: `stockIssuance.vestings[${index}]`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_stock_issuance_vesting', + context: { index }, + ...(cause ? { cause } : {}), + }); + } +} + export function damlStockIssuanceDataToNative( d: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuanceOcfData ): OcfStockIssuance { + if (!isRecord(d)) { + throw new OcpParseError('StockIssuance data must be a non-null object', { + source: 'stockIssuance.issuance_data', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_stock_issuance_data_shape', + }); + } + const anyD = d as unknown as Record; const id = requireStockIssuanceString(anyD, 'id'); const date = requireStockIssuanceString(anyD, 'date'); @@ -62,11 +91,23 @@ export function damlStockIssuanceDataToNative( const customId = requireStockIssuanceString(anyD, 'custom_id'); const stakeholderId = requireStockIssuanceString(anyD, 'stakeholder_id'); const stockClassId = requireStockIssuanceString(anyD, 'stock_class_id'); - const vestings = Array.isArray((anyD as { vestings?: unknown }).vestings) - ? (anyD as { vestings: Array<{ date: string; amount: string }> }).vestings.map((vesting) => ({ - date: damlTimeToDateString(vesting.date), - amount: normalizeNumericString(vesting.amount), - })) + const vestingInputs = anyD.vestings; + if (vestingInputs !== undefined && !Array.isArray(vestingInputs)) { + throw new OcpParseError('StockIssuance vestings must be an array', { + source: 'stockIssuance.vestings', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_stock_issuance_vestings_shape', + context: { receivedType: vestingInputs === null ? 'null' : typeof vestingInputs }, + }); + } + const vestings = Array.isArray(vestingInputs) + ? vestingInputs.map((input, index) => { + const vesting = decodeStockIssuanceVesting(input, index); + return { + date: damlTimeToDateString(vesting.date), + amount: normalizeNumericString(vesting.amount), + }; + }) : []; return { diff --git a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts index aa872d82..170b2582 100644 --- a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts @@ -3,9 +3,26 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockPlan, StockPlanCancellationBehavior } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, isRecord, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; +type StockPlanOcfData = Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData; + +function decodeStockPlanData(input: unknown): StockPlanOcfData { + try { + return Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData.decoder.runWithException(input); + } catch (error) { + const cause = error instanceof Error ? error : undefined; + const detail = cause?.message ?? String(error); + throw new OcpParseError(`Invalid plan_data in StockPlan create argument: ${detail}`, { + source: 'StockPlan.createArgument', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_stock_plan_data', + ...(cause ? { cause } : {}), + }); + } +} + function damlCancellationBehaviorToNative(b: string | null): StockPlanCancellationBehavior | undefined { if (b === null) return undefined; switch (b) { @@ -26,6 +43,14 @@ function damlCancellationBehaviorToNative(b: string | null): StockPlanCancellati } export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData): OcfStockPlan { + if (!isRecord(d)) { + throw new OcpParseError('StockPlan data must be a non-null object', { + source: 'stockPlan.plan_data', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_stock_plan_data_shape', + }); + } + // Access fields via Record type to handle DAML types that may vary from the SDK definition const damlRecord = d as Record; const dataWithId = damlRecord as { id?: string }; @@ -101,16 +126,7 @@ export async function getStockPlanAsOcf( expectedTemplateId: Fairmint.OpenCapTable.OCF.StockPlan.StockPlan.templateId, }); - const planData = createArgument.plan_data; - if (typeof planData !== 'object' || planData === null || Array.isArray(planData)) { - throw new OcpParseError('plan_data must be a non-null object in contract create argument', { - source: 'StockPlan.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { receivedValue: planData }, - }); - } - - const stockPlan = damlStockPlanDataToNative(planData as Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData); + const stockPlan = damlStockPlanDataToNative(decodeStockPlanData(createArgument.plan_data)); return { stockPlan, contractId: params.contractId }; } diff --git a/test/converters/planSecurityConverters.test.ts b/test/converters/planSecurityConverters.test.ts index 8f451a24..4df2292e 100644 --- a/test/converters/planSecurityConverters.test.ts +++ b/test/converters/planSecurityConverters.test.ts @@ -320,14 +320,14 @@ describe('PlanSecurity Type Converters', () => { }); it('handles numeric quantity values', () => { - const input: OcfPlanSecurityExercise = { + const input = { object_type: 'TX_PLAN_SECURITY_EXERCISE', id: 'pse-numeric', date: '2026-03-01', security_id: 'sec-numeric', - quantity: '5000', + quantity: 5000, resulting_security_ids: ['result-004'], - }; + } as unknown as OcfPlanSecurityExercise; const result = planSecurityExerciseDataToDaml(input); diff --git a/test/createOcf/stockIssuanceReadConversions.test.ts b/test/createOcf/stockIssuanceReadConversions.test.ts index a4c96d7f..3a3206ba 100644 --- a/test/createOcf/stockIssuanceReadConversions.test.ts +++ b/test/createOcf/stockIssuanceReadConversions.test.ts @@ -1,6 +1,6 @@ /** Unit tests for stock issuance DAML→OCF read conversions. */ -import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import { parseOcfEntityInput } from '../../src/utils/ocfZodSchemas'; @@ -35,6 +35,14 @@ function makeMinimalDamlStockIssuance(overrides: Record = {}): } describe('damlStockIssuanceDataToNative', () => { + test('rejects a non-object payload with a controlled schema mismatch', () => { + const convert = () => + damlStockIssuanceDataToNative(null as unknown as Parameters[0]); + + expect(convert).toThrow(OcpParseError); + expect(convert).toThrow('StockIssuance data must be a non-null object'); + }); + describe('issuance_type handling (DAML Optional enum)', () => { test('returns RSA when issuance_type is OcfStockIssuanceRSA', () => { const daml = makeMinimalDamlStockIssuance({ issuance_type: 'OcfStockIssuanceRSA' }); @@ -161,6 +169,40 @@ describe('damlStockIssuanceDataToNative', () => { expect(() => parseOcfEntityInput('stockIssuance', result)).not.toThrow(); }); + test('rejects a present non-array vestings value', () => { + const daml = makeMinimalDamlStockIssuance({ vestings: 'not-an-array' }); + + try { + damlStockIssuanceDataToNative(daml as Parameters[0]); + throw new Error('Expected stock issuance vestings container validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'stockIssuance.vestings', + }); + } + }); + + test.each([ + { description: 'a null entry', vestings: [null] }, + { description: 'a non-string date', vestings: [{ date: 1, amount: '10' }] }, + { description: 'a non-numeric amount', vestings: [{ date: '2025-06-01T00:00:00Z', amount: {} }] }, + ])('rejects $description with an indexed schema mismatch', ({ vestings }) => { + const daml = makeMinimalDamlStockIssuance({ vestings }); + + try { + damlStockIssuanceDataToNative(daml as Parameters[0]); + throw new Error('Expected stock issuance vesting conversion to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'stockIssuance.vestings[0]', + }); + } + }); + test('handles security_law_exemptions array', () => { const daml = makeMinimalDamlStockIssuance({ security_law_exemptions: [{ description: 'SEC Rule 701', jurisdiction: 'US' }], diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 53a5a282..1d5143d8 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -13,7 +13,10 @@ import { getEquityCompensationIssuanceAsOcf } from '../../src/functions/OpenCapT import { getStakeholderRelationshipChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf'; import { getStakeholderStatusChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf'; import { getStockClassAsOcf } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; -import { getStockPlanAsOcf } from '../../src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf'; +import { + damlStockPlanDataToNative, + getStockPlanAsOcf, +} from '../../src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf'; import { getVestingTermsAsOcf } from '../../src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf'; import { getWarrantIssuanceAsOcf } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import { validateOcfObject } from '../utils/ocfSchemaValidator'; @@ -354,6 +357,7 @@ describe('DAML to OCF Validation', () => { plan_name: '2024 Equity Incentive Plan', initial_shares_reserved: '1000000', stock_class_ids: ['sc-001'], + comments: [], }; test.each([ @@ -362,6 +366,8 @@ describe('DAML to OCF Validation', () => { { description: 'a string', value: 'invalid' }, { description: 'a number', value: 42 }, { description: 'an array', value: [] }, + { description: 'an empty object', value: {} }, + { description: 'an object with the wrong fields', value: { id: 'sp-invalid', unexpected: true } }, ])('throws OcpParseError when plan_data is $description', async ({ value }) => { const client = createMockClient('plan_data', value, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockPlan, @@ -379,24 +385,38 @@ describe('DAML to OCF Validation', () => { } }); - test('throws OcpValidationError when id is missing', async () => { + test('throws a schema mismatch when id is missing from ledger data', async () => { const { id: _, ...invalidData } = validStockPlanData; const client = createMockClient('plan_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockPlan, }); - await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpValidationError); - await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow('stockPlan.id'); + await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StockPlan.createArgument', + }); }); - test('throws OcpValidationError when plan_name is missing', async () => { + test('throws a schema mismatch when plan_name is missing from ledger data', async () => { const { plan_name: _, ...invalidData } = validStockPlanData; const client = createMockClient('plan_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockPlan, }); - await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpValidationError); - await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow('stockPlan.plan_name'); + await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StockPlan.createArgument', + }); + }); + + test('the exported converter rejects non-object runtime input without a TypeError', () => { + const convert = () => + damlStockPlanDataToNative(null as unknown as Parameters[0]); + + expect(convert).toThrow(OcpParseError); + expect(convert).toThrow('StockPlan data must be a non-null object'); }); test('handles zero values for initial_shares_reserved correctly', async () => { From 16a32324c4c66d3ea860823309b1021d8e268454 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 01:59:46 -0400 Subject: [PATCH 31/47] fix: index nested date validation paths --- .../createConvertibleIssuance.ts | 21 ++-- .../getConvertibleIssuanceAsOcf.ts | 14 +-- .../createEquityCompensationIssuance.ts | 18 +-- .../getEquityCompensationIssuanceAsOcf.ts | 4 +- .../planSecurityIssuanceDataToDaml.ts | 16 +-- .../stockClass/stockClassDataToDaml.ts | 2 +- .../stockIssuance/createStockIssuance.ts | 11 +- .../vestingTerms/createVestingTerms.ts | 14 ++- .../vestingTerms/getVestingTermsAsOcf.ts | 14 ++- .../warrantIssuance/createWarrantIssuance.ts | 33 ++--- .../getWarrantIssuanceAsOcf.ts | 8 +- .../convertibleIssuanceConverters.test.ts | 117 +++++++++++------- .../converters/dateBoundaryValidation.test.ts | 108 ++++++++++++---- .../converters/planSecurityConverters.test.ts | 28 +++++ .../valuationVestingConverters.test.ts | 40 ++++++ .../warrantIssuanceConverters.test.ts | 77 ++++++++---- .../dateBoundaryInvariants.test.ts | 21 +++- test/utils/dateValidationAssertions.ts | 21 ++++ 18 files changed, 412 insertions(+), 155 deletions(-) create mode 100644 test/utils/dateValidationAssertions.ts diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 5f2586a8..1a5b0f6f 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -92,7 +92,8 @@ function triggerTypeToDamlEnum( } function mechanismInputToDamlEnum( - m: ConvertibleConversionMechanismInput | (Record & { type?: string }) | undefined + m: ConvertibleConversionMechanismInput | (Record & { type?: string }) | undefined, + mechanismPath: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism { // Normalize bare string shorthand (e.g. 'SAFE_CONVERSION') to object form if (typeof m === 'string') { @@ -179,8 +180,6 @@ function mechanismInputToDamlEnum( } case 'CONVERTIBLE_NOTE_CONVERSION': { const anyM = m as Record; - const interestRatePath = - 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[]'; const mapIR = ( arr: unknown ): Array<{ @@ -189,7 +188,8 @@ function mechanismInputToDamlEnum( accrual_end_date: string | null; }> => Array.isArray(arr) - ? arr.map((ir) => { + ? arr.map((ir, interestRateIndex) => { + const interestRatePath = `${mechanismPath}.interest_rates[${interestRateIndex}]`; const accrualStartDate: unknown = ir?.accrual_start_date; if (accrualStartDate === null || accrualStartDate === undefined) { throw new OcpValidationError( @@ -402,9 +402,12 @@ function mechanismInputToDamlEnum( ); } -function buildConvertibleRight(input: ConversionTriggerInput | undefined) { +function buildConvertibleRight(input: ConversionTriggerInput | undefined, index: number) { const details = typeof input === 'object' && 'conversion_right' in input ? input.conversion_right : undefined; - const mechanism = mechanismInputToDamlEnum(details?.conversion_mechanism); + const mechanism = mechanismInputToDamlEnum( + details?.conversion_mechanism, + `convertibleIssuance.conversion_triggers[${index}].conversion_right.conversion_mechanism` + ); const convertsToFutureRound = details && typeof details.converts_to_future_round === 'boolean' ? details.converts_to_future_round : null; const convertsToStockClassId = optionalString(details?.converts_to_stock_class_id); @@ -416,7 +419,7 @@ function buildConvertibleRight(input: ConversionTriggerInput | undefined) { }; } -function buildTriggerToDaml(t: ConversionTriggerInput, _index: number, _issuanceId: string) { +function buildTriggerToDaml(t: ConversionTriggerInput, index: number, _issuanceId: string) { const normalized = typeof t === 'string' ? normalizeTriggerType(t) : normalizeTriggerType(t.type); const typeEnum = triggerTypeToDamlEnum(normalized); if (typeof t !== 'object' || !t.trigger_id) { @@ -431,8 +434,8 @@ function buildTriggerToDaml(t: ConversionTriggerInput, _index: number, _issuance const { trigger_id } = t; const nickname = typeof t === 'object' && t.nickname ? t.nickname : null; const trigger_description = typeof t === 'object' && t.trigger_description ? t.trigger_description : null; - const conversion_right = buildConvertibleRight(t); - const triggerFields = triggerFieldsToDaml(t, normalized, 'convertibleIssuance.conversion_triggers[]'); + const conversion_right = buildConvertibleRight(t, index); + const triggerFields = triggerFieldsToDaml(t, normalized, `convertibleIssuance.conversion_triggers[${index}]`); return { type_: typeEnum, trigger_id, diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 57019929..186cb694 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -125,7 +125,7 @@ const typeMap: Partial> const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): ConversionTrigger[] => { if (!Array.isArray(ts)) return []; - const mapMechanism = (m: unknown): ConvertibleConversionRight['conversion_mechanism'] => { + const mapMechanism = (m: unknown, mechanismPath: string): ConvertibleConversionRight['conversion_mechanism'] => { // Handle both string enum and DAML variant { tag, value } const mapTiming = (t: unknown): 'PRE_MONEY' | 'POST_MONEY' | undefined => { const s = safeString(t); @@ -336,10 +336,9 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers return mech; } case 'OcfConvMechNote': { - const interestRatePath = - 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[]'; const interest_rates = Array.isArray(value.interest_rates) - ? value.interest_rates.map((ir: unknown) => { + ? value.interest_rates.map((ir: unknown, interestRateIndex: number) => { + const interestRatePath = `${mechanismPath}.interest_rates[${interestRateIndex}]`; const irObj = ir as Record; // Validate interest rate if (irObj.rate === undefined || irObj.rate === null) { @@ -510,7 +509,8 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers const nickname: string | undefined = typeof r.nickname === 'string' && r.nickname.length ? r.nickname : undefined; const trigger_description: string | undefined = typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; - const triggerFields = triggerFieldsFromDaml(r, type, 'convertibleIssuance.conversion_triggers[]'); + const triggerFields = triggerFieldsFromDaml(r, type, `convertibleIssuance.conversion_triggers[${idx}]`); + const mechanismPath = `convertibleIssuance.conversion_triggers[${idx}].conversion_right.conversion_mechanism`; // Parse conversion_right if present and convertible variant is used let conversion_right: ConvertibleConversionRight | undefined; @@ -518,7 +518,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers const right = (r.conversion_right as Record).OcfRightConvertible as Record; conversion_right = { type: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: mapMechanism(right.conversion_mechanism), + conversion_mechanism: mapMechanism(right.conversion_mechanism, mechanismPath), ...(typeof right.converts_to_future_round === 'boolean' ? { converts_to_future_round: right.converts_to_future_round } : {}), @@ -539,7 +539,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers }; conversion_right = { type: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: mapMechanism(right.conversion_mechanism), + conversion_mechanism: mapMechanism(right.conversion_mechanism, mechanismPath), ...(typeof right.converts_to_future_round === 'boolean' ? { converts_to_future_round: right.converts_to_future_round } : {}), diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts index 2ec243d4..136c031e 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts @@ -72,11 +72,13 @@ export function equityCompensationIssuanceDataToDaml( vesting_terms_id?: string; } ): Record { - const filteredVestings = (d.vestings ?? []).filter((v) => { - // normalizeNumericString validates strict decimal format and rejects scientific notation - const normalized = normalizeNumericString(v.amount); - return parseFloat(normalized) > 0; - }); + const filteredVestings = (d.vestings ?? []) + .map((vesting, index) => ({ index, vesting })) + .filter(({ vesting }) => { + // normalizeNumericString validates strict decimal format and rejects scientific notation + const normalized = normalizeNumericString(vesting.amount); + return parseFloat(normalized) > 0; + }); return { id: d.id, @@ -105,9 +107,9 @@ export function equityCompensationIssuanceDataToDaml( exercise_price: d.exercise_price ? monetaryToDaml(d.exercise_price) : null, base_price: d.base_price ? monetaryToDaml(d.base_price) : null, early_exercisable: d.early_exercisable ?? null, - vestings: filteredVestings.map((v) => ({ - date: dateStringToDAMLTime(v.date, 'equityCompensationIssuance.vestings[].date'), - amount: normalizeNumericString(v.amount), + vestings: filteredVestings.map(({ index, vesting }) => ({ + date: dateStringToDAMLTime(vesting.date, `equityCompensationIssuance.vestings[${index}].date`), + amount: normalizeNumericString(vesting.amount), })), expiration_date: nullableDateStringToDAMLTime(d.expiration_date, 'equityCompensationIssuance.expiration_date'), termination_exercise_windows: d.termination_exercise_windows.map((w) => ({ diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 65996b53..4aeacc81 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -63,7 +63,7 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record 0 - ? ((d.vestings as Array<{ date: string; amount?: unknown }>).map((v) => { + ? ((d.vestings as Array<{ date: string; amount?: unknown }>).map((v, index) => { // Validate vesting amount if (typeof v.amount !== 'string' && typeof v.amount !== 'number') { throw new OcpValidationError('vesting.amount', `Must be string or number, got ${typeof v.amount}`, { @@ -75,7 +75,7 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record { - const normalized = normalizeNumericString(v.amount); - return parseFloat(normalized) > 0; - }); + const filteredVestings = (d.vestings ?? []) + .map((vesting, index) => ({ index, vesting })) + .filter(({ vesting }) => { + const normalized = normalizeNumericString(vesting.amount); + return parseFloat(normalized) > 0; + }); return { id: d.id, @@ -72,9 +74,9 @@ export function planSecurityIssuanceDataToDaml(d: OcfPlanSecurityIssuance): Reco exercise_price: d.exercise_price ? monetaryToDaml(d.exercise_price) : null, base_price: d.base_price ? monetaryToDaml(d.base_price) : null, early_exercisable: d.early_exercisable ?? null, - vestings: filteredVestings.map((v) => ({ - date: dateStringToDAMLTime(v.date, 'planSecurityIssuance.vestings[].date'), - amount: normalizeNumericString(v.amount), + vestings: filteredVestings.map(({ index, vesting }) => ({ + date: dateStringToDAMLTime(vesting.date, `planSecurityIssuance.vestings[${index}].date`), + amount: normalizeNumericString(vesting.amount), })), expiration_date: nullableDateStringToDAMLTime(d.expiration_date, 'planSecurityIssuance.expiration_date'), termination_exercise_windows: d.termination_exercise_windows.map((w) => ({ diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index ba914d62..5d7ee10c 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -210,7 +210,7 @@ export function stockClassDataToDaml(stockClassData: OcfStockClass): Record { + .map((vesting, index) => ({ index, vesting })) + .filter(({ vesting }) => { // normalizeNumericString validates strict decimal format and rejects scientific notation - const normalized = normalizeNumericString(v.amount); + const normalized = normalizeNumericString(vesting.amount); return parseFloat(normalized) > 0; }) - .map((v) => ({ - date: dateStringToDAMLTime(v.date, 'stockIssuance.vestings[].date'), - amount: normalizeNumericString(v.amount), + .map(({ index, vesting }) => ({ + date: dateStringToDAMLTime(vesting.date, `stockIssuance.vestings[${index}].date`), + amount: normalizeNumericString(vesting.amount), })), cost_basis: d.cost_basis ? monetaryToDaml(d.cost_basis) : null, stock_legend_ids: d.stock_legend_ids, diff --git a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts index c2c03595..e96bc8c0 100644 --- a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts +++ b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts @@ -120,7 +120,10 @@ function mapOcfDayOfMonthToDaml(day: string): OcfVestingDay { return mapped; } -function vestingTriggerToDaml(t: VestingTrigger): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingTrigger { +function vestingTriggerToDaml( + t: VestingTrigger, + fieldPath: string +): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingTrigger { switch (t.type) { case 'VESTING_START_DATE': return { @@ -138,7 +141,7 @@ function vestingTriggerToDaml(t: VestingTrigger): Fairmint.OpenCapTable.OCF.Vest return { tag: 'OcfVestingScheduleAbsoluteTrigger', value: { - date: dateStringToDAMLTime(t.date, 'vestingTerms.vesting_conditions[].trigger.date'), + date: dateStringToDAMLTime(t.date, `${fieldPath}.date`), }, }; @@ -248,7 +251,10 @@ function vestingConditionPortionToDaml( }; } -function vestingConditionToDaml(c: VestingCondition): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition { +function vestingConditionToDaml( + c: VestingCondition, + index: number +): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition { return { id: c.id, description: optionalString(c.description), @@ -259,7 +265,7 @@ function vestingConditionToDaml(c: VestingCondition): Fairmint.OpenCapTable.OCF. } as unknown as Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition['portion']) : null, quantity: c.quantity != null ? normalizeNumericString(c.quantity) : null, - trigger: vestingTriggerToDaml(c.trigger), + trigger: vestingTriggerToDaml(c.trigger, `vestingTerms.vesting_conditions[${index}].trigger`), next_condition_ids: c.next_condition_ids, }; } diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index 2a5a59b2..02a0c793 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -190,7 +190,10 @@ function damlVestingPeriodToNative(p: { tag: string; value?: Record }): VestingTrigger { +function damlVestingTriggerToNative( + t: string | { tag?: string; value?: Record }, + fieldPath: string +): VestingTrigger { const tag: string | undefined = typeof t === 'string' ? t : t.tag; if (tag === 'OcfVestingStartTrigger') { @@ -210,7 +213,7 @@ function damlVestingTriggerToNative(t: string | { tag?: string; value?: Record { + .map((vesting, index) => ({ index, vesting })) + .filter(({ vesting }) => { // normalizeNumericString validates strict decimal format and rejects scientific notation - const normalized = normalizeNumericString(v.amount); + const normalized = normalizeNumericString(vesting.amount); return parseFloat(normalized) > 0; }) - .map((v) => ({ - date: dateStringToDAMLTime(v.date, 'warrantIssuance.vestings[].date'), - amount: normalizeNumericString(v.amount), + .map(({ index, vesting }) => ({ + date: dateStringToDAMLTime(vesting.date, `warrantIssuance.vestings[${index}].date`), + amount: normalizeNumericString(vesting.amount), })), comments: cleanComments(d.comments), }; diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index ca6737f1..5b8c31b0 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -349,7 +349,7 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf typeof r.nickname === 'string' && r.nickname.length ? r.nickname : undefined; const trigger_description: string | undefined = typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; - const triggerFields = triggerFieldsFromDaml(r, type, 'warrantIssuance.exercise_triggers[]'); + const triggerFields = triggerFieldsFromDaml(r, type, `warrantIssuance.exercise_triggers[${idx}]`); const conversion_right: WarrantTriggerConversionRight = mapAnyConversionRightFromDaml(r.conversion_right); @@ -387,10 +387,10 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf const vestings: VestingSimple[] | undefined = Array.isArray(d.vestings) && d.vestings.length > 0 - ? (d.vestings as Array<{ date: string; amount?: unknown }>).map((v) => { + ? (d.vestings as Array<{ date: string; amount?: unknown }>).map((v, index) => { if (typeof v.amount !== 'string' && typeof v.amount !== 'number') { throw new OcpValidationError( - 'warrantIssuance.vestings.amount', + `warrantIssuance.vestings[${index}].amount`, `Must be string or number, got ${typeof v.amount}`, { code: OcpErrorCodes.INVALID_TYPE, @@ -401,7 +401,7 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf } const amountStr = typeof v.amount === 'number' ? v.amount.toString() : v.amount; return { - date: damlTimeToDateString(v.date, 'warrantIssuance.vestings[].date'), + date: damlTimeToDateString(v.date, `warrantIssuance.vestings[${index}].date`), amount: normalizeNumericString(amountStr), }; }) diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 4f2670bd..2dd539d2 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -11,9 +11,10 @@ * - OcfInterestPayoutDeferred / OcfInterestPayoutCash */ -import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { expectInvalidDate } from '../utils/dateValidationAssertions'; import { loadProductionFixture } from '../utils/productionFixtures'; const BASE_INPUT = { @@ -42,44 +43,36 @@ const SAFE_TRIGGER_BASE = { }, }; -function expectInvalidDate( - action: () => unknown, - fieldPath: string, - receivedValue: unknown, - code: OcpErrorCode = OcpErrorCodes.INVALID_FORMAT -): void { - try { - action(); - throw new Error('Expected date validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ code, fieldPath, receivedValue }); - } +function noteInterestRatePath(triggerIndex = 0, interestRateIndex = 0): string { + return ( + `convertibleIssuance.conversion_triggers[${triggerIndex}].conversion_right.` + + `conversion_mechanism.interest_rates[${interestRateIndex}]` + ); } -const NOTE_INTEREST_RATE_PATH = - 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[]'; +function buildConvertibleNoteTrigger(triggerId: string, interestRates: Array>) { + return { + ...SAFE_TRIGGER_BASE, + trigger_id: triggerId, + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: interestRates, + day_count_convention: 'ACTUAL_365', + interest_payout: 'CASH', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + }, + }, + }; +} function buildConvertibleNoteInput(interestRate: Record) { return { ...BASE_INPUT, convertible_type: 'NOTE', - conversion_triggers: [ - { - ...SAFE_TRIGGER_BASE, - conversion_right: { - type: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: { - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [interestRate], - day_count_convention: 'ACTUAL_365', - interest_payout: 'CASH', - interest_accrual_period: 'ANNUAL', - compounding_type: 'SIMPLE', - }, - }, - }, - ], + conversion_triggers: [buildConvertibleNoteTrigger('trigger-001', [interestRate])], } as unknown as Parameters[0]; } @@ -235,10 +228,10 @@ function buildDamlSafeTrigger(conversionTiming?: string) { }; } -function buildDamlNoteTrigger(dayCount: string, interestPayout: string) { +function buildDamlNoteTrigger(dayCount: string, interestPayout: string, triggerId = 'trigger-001') { return { type_: 'OcfTriggerTypeTypeElectiveAtWill', - trigger_id: 'trigger-001', + trigger_id: triggerId, conversion_right: { OcfRightConvertible: { conversion_mechanism: { @@ -495,7 +488,7 @@ describe('convertible issuance approval-date read boundaries', () => { { ...buildDamlSafeTrigger(), type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_date: null }, ], }), - 'convertibleIssuance.conversion_triggers[].trigger_date', + 'convertibleIssuance.conversion_triggers[0].trigger_date', null, OcpErrorCodes.INVALID_TYPE ); @@ -526,16 +519,16 @@ describe('convertible issuance approval-date read boundaries', () => { ...BASE_DAML, conversion_triggers: [{ ...buildDamlSafeTrigger(), trigger_date: '2024-01-15T00:00:00Z' }], }), - 'convertibleIssuance.conversion_triggers[].trigger_date', + 'convertibleIssuance.conversion_triggers[0].trigger_date', '2024-01-15T00:00:00Z', OcpErrorCodes.SCHEMA_MISMATCH ); }); test.each([ - ['', OcpErrorCodes.INVALID_FORMAT], - [{ seconds: 1 }, OcpErrorCodes.INVALID_TYPE], - ] as const)('rejects a present invalid note accrual_end_date on readback', (invalidDate, code) => { + ['empty', '', OcpErrorCodes.INVALID_FORMAT], + ['non-string', { seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a present invalid note accrual_end_date on readback when %s', (_case, invalidDate, code) => { const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); const mechanism = trigger.conversion_right.OcfRightConvertible.conversion_mechanism; mechanism.value.interest_rates[0] = { @@ -550,12 +543,30 @@ describe('convertible issuance approval-date read boundaries', () => { convertible_type: 'OcfConvertibleNote', conversion_triggers: [trigger], }), - `${NOTE_INTEREST_RATE_PATH}.accrual_end_date`, + `${noteInterestRatePath()}.accrual_end_date`, invalidDate, code ); }); + test('reports the exact trigger and interest-rate indexes on readback', () => { + const firstTrigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); + const secondTrigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash', 'trigger-002'); + const mechanism = secondTrigger.conversion_right.OcfRightConvertible.conversion_mechanism; + mechanism.value.interest_rates.push({ rate: '0.06', accrual_start_date: '' }); + + expectInvalidDate( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [firstTrigger, secondTrigger], + }), + `${noteInterestRatePath(1, 1)}.accrual_start_date`, + '' + ); + }); + test('omits a null note accrual_end_date on readback', () => { const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); const mechanism = trigger.conversion_right.OcfRightConvertible.conversion_mechanism; @@ -676,7 +687,7 @@ describe('convertible issuance write date boundaries', () => { ...BASE_INPUT, conversion_triggers: [{ ...SAFE_TRIGGER_BASE, trigger_date: '2024-01-15' }], }), - 'convertibleIssuance.conversion_triggers[].trigger_date', + 'convertibleIssuance.conversion_triggers[0].trigger_date', '2024-01-15', OcpErrorCodes.INVALID_FORMAT ); @@ -690,12 +701,32 @@ describe('convertible issuance write date boundaries', () => { ] as const)('rejects a required note accrual_start_date when %s', (_case, value, code) => { expectInvalidDate( () => convertibleIssuanceDataToDaml(buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: value })), - `${NOTE_INTEREST_RATE_PATH}.accrual_start_date`, + `${noteInterestRatePath()}.accrual_start_date`, value, code ); }); + test('reports the exact trigger and interest-rate indexes on write', () => { + const input = { + ...BASE_INPUT, + convertible_type: 'NOTE', + conversion_triggers: [ + buildConvertibleNoteTrigger('trigger-001', [{ rate: '0.05', accrual_start_date: '2024-01-15' }]), + buildConvertibleNoteTrigger('trigger-002', [ + { rate: '0.05', accrual_start_date: '2024-01-15' }, + { rate: '0.06', accrual_start_date: '' }, + ]), + ], + } as unknown as Parameters[0]; + + expectInvalidDate( + () => convertibleIssuanceDataToDaml(input), + `${noteInterestRatePath(1, 1)}.accrual_start_date`, + '' + ); + }); + test.each([ ['empty', '', OcpErrorCodes.INVALID_FORMAT], ['non-string', { seconds: 1 }, OcpErrorCodes.INVALID_TYPE], @@ -705,7 +736,7 @@ describe('convertible issuance write date boundaries', () => { convertibleIssuanceDataToDaml( buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15', accrual_end_date: value }) ), - `${NOTE_INTEREST_RATE_PATH}.accrual_end_date`, + `${noteInterestRatePath()}.accrual_end_date`, value, code ); diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index 077abcb9..47340e89 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -1,30 +1,14 @@ -import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { OcpErrorCodes } from '../../src/errors'; import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; import { damlEquityCompensationIssuanceDataToNative } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; import { issuerAuthorizedSharesAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment'; import { damlIssuerAuthorizedSharesAdjustmentDataToNative } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; import { damlStockClassSplitToNative } from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; +import { stockIssuanceDataToDaml } from '../../src/functions/OpenCapTable/stockIssuance/createStockIssuance'; import { stockPlanPoolAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment'; import { damlStockPlanPoolAdjustmentDataToNative } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf'; - -function expectInvalidDate( - action: () => unknown, - fieldPath: string, - receivedValue: unknown, - code: OcpErrorCode = OcpErrorCodes.INVALID_FORMAT -): void { - try { - action(); - throw new Error('Expected converter date validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - fieldPath, - receivedValue, - }); - } -} +import { expectInvalidDate } from '../utils/dateValidationAssertions'; const ISSUER_ADJUSTMENT_BASE = { id: 'adjustment-1', @@ -73,6 +57,42 @@ const EQUITY_COMPENSATION_WRITE_BASE = { security_law_exemptions: [], }; +const STOCK_ISSUANCE_WRITE_BASE: Parameters[0] = { + object_type: 'TX_STOCK_ISSUANCE', + id: 'stock-issuance-1', + date: '2024-01-15', + security_id: 'security-1', + custom_id: 'CS-1', + stakeholder_id: 'stakeholder-1', + stock_class_id: 'stock-class-1', + share_price: { amount: '1', currency: 'USD' }, + quantity: '100', + security_law_exemptions: [], + stock_legend_ids: [], +}; + +const STOCK_CLASS_WRITE_BASE: Parameters[0] = { + object_type: 'STOCK_CLASS', + id: 'preferred-1', + name: 'Preferred', + class_type: 'PREFERRED', + default_id_prefix: 'P-', + initial_shares_authorized: '1000', + votes_per_share: '1', + seniority: '1', + conversion_rights: [], +}; + +const STOCK_CLASS_CONVERSION_RIGHT = { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + }, + converts_to_stock_class_id: 'common-1', +}; + const OPTIONAL_READ_DATE_CASES: Array<{ name: string; fieldPath: string; @@ -271,9 +291,53 @@ describe('OCF write converter optional date boundaries', () => { () => equityCompensationIssuanceDataToDaml({ ...EQUITY_COMPENSATION_WRITE_BASE, - vestings: [{ date: '', amount: '1' }], + vestings: [ + { date: '2024-01-15', amount: '1' }, + { date: '', amount: '1' }, + ], + }), + 'equityCompensationIssuance.vestings[1].date', + '' + ); + }); + + test('reports original array indexes for nested issuance and stock-class dates', () => { + expectInvalidDate( + () => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + vestings: [ + { date: '2024-01-15T00:00:00Z', amount: '1' }, + { date: '', amount: '1' }, + ], + }), + 'equityCompensationIssuance.vestings[1].date', + '' + ); + + expectInvalidDate( + () => + stockIssuanceDataToDaml({ + ...STOCK_ISSUANCE_WRITE_BASE, + vestings: [ + { date: '2024-01-15', amount: '0' }, + { date: '', amount: '1' }, + ], + }), + 'stockIssuance.vestings[1].date', + '' + ); + + expectInvalidDate( + () => + stockClassDataToDaml({ + ...STOCK_CLASS_WRITE_BASE, + conversion_rights: [ + { ...STOCK_CLASS_CONVERSION_RIGHT, expires_at: '2025-01-15' }, + { ...STOCK_CLASS_CONVERSION_RIGHT, expires_at: '' }, + ], }), - 'equityCompensationIssuance.vestings[].date', + 'stockClass.conversion_rights[1].expires_at', '' ); }); diff --git a/test/converters/planSecurityConverters.test.ts b/test/converters/planSecurityConverters.test.ts index ad170fa2..d26f119d 100644 --- a/test/converters/planSecurityConverters.test.ts +++ b/test/converters/planSecurityConverters.test.ts @@ -116,6 +116,34 @@ describe('PlanSecurity Type Converters', () => { ]); }); + it('reports the original vesting index after zero-amount entries are filtered', () => { + const input: OcfPlanSecurityIssuance = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', + id: 'psi-indexed-date', + date: '2025-01-15', + security_id: 'sec-indexed-date', + custom_id: 'custom-indexed-date', + stakeholder_id: 'stakeholder-indexed-date', + compensation_type: 'OPTION', + quantity: '100', + vestings: [ + { date: '', amount: '0' }, + { date: '', amount: '1' }, + ], + expiration_date: null, + termination_exercise_windows: [], + security_law_exemptions: [], + }; + + expect(() => planSecurityIssuanceDataToDaml(input)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'planSecurityIssuance.vestings[1].date', + receivedValue: '', + }) + ); + }); + it.each([ ['undefined', undefined, OcpErrorCodes.INVALID_TYPE], ['empty', '', OcpErrorCodes.INVALID_FORMAT], diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index bccab1ab..fc5b8f16 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -31,6 +31,7 @@ import { type DamlVestingStartData, } from '../../src/functions/OpenCapTable/vestingStart/damlToOcf'; import { getVestingStartAsOcf } from '../../src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf'; +import { vestingTermsDataToDaml } from '../../src/functions/OpenCapTable/vestingTerms/createVestingTerms'; import { damlVestingTermsDataToNative } from '../../src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf'; import type { OcfValuation, @@ -39,6 +40,7 @@ import type { OcfVestingStart, OcfVestingTerms, } from '../../src/types'; +import { expectInvalidDate } from '../utils/dateValidationAssertions'; describe('Valuation Converters', () => { describe('OCF → DAML (valuationDataToDaml)', () => { @@ -374,6 +376,30 @@ describe('VestingTerms Converters', () => { remainder: true, }); }); + + test('reports the exact vesting-condition index for an invalid absolute date', () => { + const ocfData: OcfVestingTerms = { + object_type: 'VESTING_TERMS', + id: 'vt-indexed-write', + name: 'Indexed write path', + description: 'Tests indexed validation paths', + allocation_type: 'CUMULATIVE_ROUNDING', + vesting_conditions: [ + { + id: 'start', + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: ['bad-date'], + }, + { + id: 'bad-date', + trigger: { type: 'VESTING_SCHEDULE_ABSOLUTE', date: '' }, + next_condition_ids: [], + }, + ], + }; + + expectInvalidDate(() => vestingTermsDataToDaml(ocfData), 'vestingTerms.vesting_conditions[1].trigger.date', ''); + }); }); }); @@ -648,6 +674,20 @@ describe('VestingTerms drift regression', () => { expect(result.vesting_conditions[0].portion!.remainder).toBe(false); }); + test('reports the exact vesting-condition index for an invalid absolute date on readback', () => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'bad-date', + description: null, + quantity: null, + portion: null, + trigger: { tag: 'OcfVestingScheduleAbsoluteTrigger', value: { date: '' } }, + next_condition_ids: [], + }); + + expectInvalidDate(() => damlVestingTermsDataToNative(daml), 'vestingTerms.vesting_conditions[1].trigger.date', ''); + }); + test('preserves remainder: true', () => { const daml = makeDamlVestingTerms(); ( diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 4ecdb319..6e673e5d 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -6,13 +6,14 @@ * infinite edit loops in the replication script. */ -import { OcpErrorCodes, OcpParseError, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { warrantIssuanceDataToDaml, type WarrantTriggerTypeInput, } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import { ocfDeepEqual } from '../../src/utils/ocfComparison'; +import { expectInvalidDate } from '../utils/dateValidationAssertions'; /** Helper: round-trip OCF data through DAML and back to OCF */ function roundTrip(ocfInput: Parameters[0]): Record { @@ -22,21 +23,6 @@ function roundTrip(ocfInput: Parameters[0]): R return { ...native, object_type: 'TX_WARRANT_ISSUANCE' }; } -function expectInvalidWarrantDate( - action: () => unknown, - fieldPath: string, - receivedValue: unknown, - code: OcpErrorCode = OcpErrorCodes.INVALID_FORMAT -): void { - try { - action(); - throw new Error('Expected warrant date validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ code, fieldPath, receivedValue }); - } -} - describe('WarrantIssuance round-trip equivalence', () => { const baseWarrantIssuance = { id: '4afe6226-a717-4596-8bcc-fa3c22b154de', @@ -283,13 +269,20 @@ describe('WarrantIssuance round-trip equivalence', () => { it('rejects date fields forbidden by the trigger discriminator on readback', () => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - expectInvalidWarrantDate( + expectInvalidDate( () => damlWarrantIssuanceDataToNative({ ...daml, - exercise_triggers: [{ ...daml.exercise_triggers[0], trigger_date: '2024-01-15T00:00:00Z' }], + exercise_triggers: [ + daml.exercise_triggers[0], + { + ...daml.exercise_triggers[0], + trigger_id: 'warrant2_trigger_invalid', + trigger_date: '2024-01-15T00:00:00Z', + }, + ], }), - 'warrantIssuance.exercise_triggers[].trigger_date', + 'warrantIssuance.exercise_triggers[1].trigger_date', '2024-01-15T00:00:00Z', OcpErrorCodes.SCHEMA_MISMATCH ); @@ -301,7 +294,7 @@ describe('WarrantIssuance round-trip equivalence', () => { const fieldPath = `warrantIssuance.${field}`; const invalidDate = { seconds: 1 }; - expectInvalidWarrantDate( + expectInvalidDate( () => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, @@ -310,7 +303,7 @@ describe('WarrantIssuance round-trip equivalence', () => { fieldPath, '' ); - expectInvalidWarrantDate( + expectInvalidDate( () => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, @@ -332,18 +325,54 @@ describe('WarrantIssuance round-trip equivalence', () => { ); it('rejects date fields forbidden by the trigger discriminator on write', () => { - expectInvalidWarrantDate( + expectInvalidDate( () => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, - exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], trigger_date: '2024-01-15' }], + exercise_triggers: [ + baseWarrantIssuance.exercise_triggers[0], + { + ...baseWarrantIssuance.exercise_triggers[0], + trigger_id: 'warrant2_trigger_invalid', + trigger_date: '2024-01-15', + }, + ], }), - 'warrantIssuance.exercise_triggers[].trigger_date', + 'warrantIssuance.exercise_triggers[1].trigger_date', '2024-01-15', OcpErrorCodes.INVALID_FORMAT ); }); + it('reports original vesting indexes on write and readback', () => { + expectInvalidDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [ + { date: '', amount: '0' }, + { date: '', amount: '1' }, + ], + }), + 'warrantIssuance.vestings[1].date', + '' + ); + + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + expectInvalidDate( + () => + damlWarrantIssuanceDataToNative({ + ...daml, + vestings: [ + { date: '2024-01-15T00:00:00Z', amount: '1' }, + { date: '', amount: '1' }, + ], + }), + 'warrantIssuance.vestings[1].date', + '' + ); + }); + test('uses identical canonical AUTOMATIC_ON_DATE semantics for outer and nested stock-class triggers', () => { const result = warrantIssuanceDataToDaml({ ...baseWarrantIssuance, diff --git a/test/schemaAlignment/dateBoundaryInvariants.test.ts b/test/schemaAlignment/dateBoundaryInvariants.test.ts index e1d1ab6f..311a2f94 100644 --- a/test/schemaAlignment/dateBoundaryInvariants.test.ts +++ b/test/schemaAlignment/dateBoundaryInvariants.test.ts @@ -15,6 +15,7 @@ const DATE_CONVERTERS = new Set([ ]); const REQUIRED_DATE_CONVERTERS = new Set(['dateStringToDAMLTime', 'damlTimeToDateString']); +const TRIGGER_FIELD_CONVERTERS = new Set(['triggerFieldsToDaml', 'triggerFieldsFromDaml']); const DISCRIMINATED_TRIGGER_DATE_FIELDS = new Set(['trigger_date', 'start_date', 'end_date']); const TRIGGER_FIELDS_HELPER = `${path.sep}shared${path.sep}triggerFields.ts`; const OPTIONAL_DATE_FIELDS = new Set([ @@ -69,6 +70,10 @@ describe('date boundary source invariants', () => { ); function visit(node: ts.Node): void { + if (ts.isStringLiteralLike(node) && node.text.includes('.') && node.text.includes('[]')) { + violations.push(`${location(sourceFile, node)} array diagnostic path must include its index`); + } + if ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && @@ -86,7 +91,9 @@ describe('date boundary source invariants', () => { ts.isIdentifier(fieldPath.expression) && fieldPath.expression.text === 'fieldPath'; - if (literalPath !== undefined && (!literalPath.includes('.') || literalPath === 'date')) { + if (fieldPath.getText(sourceFile).includes('[]')) { + violations.push(`${location(sourceFile, fieldPath)} array date fieldPath must include its index`); + } else if (literalPath !== undefined && (!literalPath.includes('.') || literalPath === 'date')) { violations.push(`${location(sourceFile, fieldPath)} date fieldPath must be entity-specific`); } else if (literalPath === undefined && !templatePath && !forwardedPath && !constructedPath) { violations.push( @@ -117,6 +124,18 @@ describe('date boundary source invariants', () => { } } + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + TRIGGER_FIELD_CONVERTERS.has(node.expression.text) && + node.arguments.length > 2 + ) { + const fieldPath = node.arguments[2]; + if (fieldPath.getText(sourceFile).includes('[]')) { + violations.push(`${location(sourceFile, fieldPath)} trigger array fieldPath must include its index`); + } + } + if (file.includes(OPEN_CAP_TABLE_SEGMENT)) { let condition: ts.Expression | undefined; if (ts.isIfStatement(node)) { diff --git a/test/utils/dateValidationAssertions.ts b/test/utils/dateValidationAssertions.ts new file mode 100644 index 00000000..23de361f --- /dev/null +++ b/test/utils/dateValidationAssertions.ts @@ -0,0 +1,21 @@ +import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; + +export function expectInvalidDate( + action: () => unknown, + fieldPath: string, + receivedValue: unknown, + code: OcpErrorCode = OcpErrorCodes.INVALID_FORMAT +): void { + let thrown: unknown; + + expect(() => { + try { + action(); + } catch (error) { + thrown = error; + throw error; + } + }).toThrow(OcpValidationError); + + expect(thrown).toMatchObject({ code, fieldPath, receivedValue }); +} From 5453feae5cc56d5282f5eaf55117688b5465bea1 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 03:19:11 -0400 Subject: [PATCH 32/47] fix: address exact date review feedback --- .../createConvertibleIssuance.ts | 116 +++++++++------- .../getConvertibleIssuanceAsOcf.ts | 111 +++++++++------ .../createEquityCompensationIssuance.ts | 14 +- .../getEquityCompensationIssuanceAsOcf.ts | 6 +- .../planSecurityIssuanceDataToDaml.ts | 13 +- src/functions/OpenCapTable/shared/vesting.ts | 30 ++++ .../stockIssuance/createStockIssuance.ts | 13 +- .../vestingTerms/createVestingTerms.ts | 2 +- .../vestingTerms/getVestingTermsAsOcf.ts | 2 +- .../getWarrantIssuanceAsOcf.ts | 8 +- src/utils/typeConversions.ts | 64 +++++---- .../convertibleIssuanceConverters.test.ts | 131 ++++++++++++++++-- .../converters/dateBoundaryValidation.test.ts | 75 ++++++++++ .../valuationVestingConverters.test.ts | 21 ++- test/declarations/publicApi.types.ts | 10 ++ test/utils/triggerFields.test.ts | 19 ++- 16 files changed, 460 insertions(+), 175 deletions(-) create mode 100644 src/functions/OpenCapTable/shared/vesting.ts diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 1a5b0f6f..92e1d9ab 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -99,21 +99,24 @@ function mechanismInputToDamlEnum( if (typeof m === 'string') { m = { type: m }; } + const mechanismField = (field: string): string => `${mechanismPath}.${field}`; const dayCountToDaml = (v: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfDayCountType => { - const s = safeString(v).toUpperCase(); + const fieldPath = mechanismField('day_count_convention'); + const s = safeString(v, fieldPath).toUpperCase(); if (s === 'ACTUAL_365') return 'OcfDayCountActual365'; if (s === '30_360') return 'OcfDayCount30_360'; - throw new OcpParseError(`Unknown day_count_convention: ${safeString(v)}`, { - source: 'conversion_mechanism.day_count_convention', + throw new OcpParseError(`Unknown day_count_convention: ${s}`, { + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); }; const payoutToDaml = (v: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfInterestPayoutType => { - const s = safeString(v).toUpperCase(); + const fieldPath = mechanismField('interest_payout'); + const s = safeString(v, fieldPath).toUpperCase(); if (s === 'DEFERRED') return 'OcfInterestPayoutDeferred'; if (s === 'CASH') return 'OcfInterestPayoutCash'; - throw new OcpParseError(`Unknown interest_payout: ${safeString(v)}`, { - source: 'conversion_mechanism.interest_payout', + throw new OcpParseError(`Unknown interest_payout: ${s}`, { + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); }; @@ -139,12 +142,13 @@ function mechanismInputToDamlEnum( }; const safeTiming = (v: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTimingType | null => { - const s = safeString(v).toUpperCase(); + const fieldPath = mechanismField('conversion_timing'); + const s = safeString(v, fieldPath).toUpperCase(); if (s === '') return null; if (s === 'PRE_MONEY') return 'OcfConvTimingPreMoney'; if (s === 'POST_MONEY') return 'OcfConvTimingPostMoney'; - throw new OcpParseError(`Unknown conversion_timing: ${safeString(v)}`, { - source: 'conversion_mechanism.conversion_timing', + throw new OcpParseError(`Unknown conversion_timing: ${s}`, { + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); }; @@ -157,8 +161,14 @@ function mechanismInputToDamlEnum( | { numerator?: string; denominator?: string } | undefined; if (!r) return null; - const num = r.numerator !== undefined ? normalizeNumericString(String(r.numerator)) : undefined; - const den = r.denominator !== undefined ? normalizeNumericString(String(r.denominator)) : undefined; + const num = + r.numerator !== undefined + ? normalizeNumericString(String(r.numerator), mechanismField('exit_multiple.numerator')) + : undefined; + const den = + r.denominator !== undefined + ? normalizeNumericString(String(r.denominator), mechanismField('exit_multiple.denominator')) + : undefined; if (!num || !den) return null; return { numerator: num, denominator: den }; })(); @@ -166,9 +176,11 @@ function mechanismInputToDamlEnum( tag: 'OcfConvMechSAFE', value: { conversion_discount: - anyM.conversion_discount != null ? normalizeNumericString(anyM.conversion_discount as string) : null, + anyM.conversion_discount != null + ? normalizeNumericString(anyM.conversion_discount as string, mechanismField('conversion_discount')) + : null, conversion_valuation_cap: anyM.conversion_valuation_cap - ? monetaryToDaml(anyM.conversion_valuation_cap as Monetary) + ? monetaryToDaml(anyM.conversion_valuation_cap as Monetary, mechanismField('conversion_valuation_cap')) : null, exit_multiple: exitMultipleValue, conversion_mfn: (anyM.conversion_mfn as boolean | null) ?? null, @@ -203,7 +215,7 @@ function mechanismInputToDamlEnum( } return { - rate: ir?.rate != null ? normalizeNumericString(String(ir.rate)) : null, + rate: ir?.rate != null ? normalizeNumericString(String(ir.rate), `${interestRatePath}.rate`) : null, accrual_start_date: dateStringToDAMLTime(accrualStartDate, `${interestRatePath}.accrual_start_date`), accrual_end_date: optionalDateStringToDAMLTime( ir?.accrual_end_date, @@ -213,7 +225,8 @@ function mechanismInputToDamlEnum( }) : []; const accrualToDaml = (v: unknown): string => { - const s = safeString(v).toUpperCase(); + const fieldPath = mechanismField('interest_accrual_period'); + const s = safeString(v, fieldPath).toUpperCase(); switch (s) { case 'DAILY': return 'OcfAccrualDaily'; @@ -226,52 +239,53 @@ function mechanismInputToDamlEnum( case 'ANNUAL': return 'OcfAccrualAnnual'; default: - throw new OcpParseError(`Unknown interest_accrual_period: ${safeString(v)}`, { - source: 'conversion_mechanism.interest_accrual_period', + throw new OcpParseError(`Unknown interest_accrual_period: ${s}`, { + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } }; const compoundingToDaml = (v: unknown): string => { // Pass-through if already DAML tag; otherwise map common strings - const s = safeString(v); + const fieldPath = mechanismField('compounding_type'); + const s = safeString(v, fieldPath); if (s.startsWith('Ocf')) return s; const u = s.toUpperCase(); if (u === 'SIMPLE') return 'OcfSimple'; if (u === 'COMPOUNDING') return 'OcfCompounding'; - throw new OcpParseError(`Unknown compounding_type: ${safeString(v)}`, { - source: 'conversion_mechanism.compounding_type', + throw new OcpParseError(`Unknown compounding_type: ${s}`, { + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); }; if (!Array.isArray(anyM.interest_rates)) throw new OcpValidationError( - 'conversion_mechanism.interest_rates', + mechanismField('interest_rates'), 'CONVERTIBLE_NOTE_CONVERSION requires interest_rates', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); if (!anyM.day_count_convention) throw new OcpValidationError( - 'conversion_mechanism.day_count_convention', + mechanismField('day_count_convention'), 'CONVERTIBLE_NOTE_CONVERSION requires day_count_convention', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); if (!anyM.interest_payout) throw new OcpValidationError( - 'conversion_mechanism.interest_payout', + mechanismField('interest_payout'), 'CONVERTIBLE_NOTE_CONVERSION requires interest_payout', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); if (!anyM.interest_accrual_period) { throw new OcpValidationError( - 'conversion_mechanism.interest_accrual_period', + mechanismField('interest_accrual_period'), 'CONVERTIBLE_NOTE_CONVERSION requires interest_accrual_period', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); } if (!anyM.compounding_type) throw new OcpValidationError( - 'conversion_mechanism.compounding_type', + mechanismField('compounding_type'), 'CONVERTIBLE_NOTE_CONVERSION requires compounding_type', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); @@ -284,9 +298,11 @@ function mechanismInputToDamlEnum( interest_accrual_period: accrualToDaml(anyM.interest_accrual_period), compounding_type: compoundingToDaml(anyM.compounding_type), conversion_discount: - anyM.conversion_discount != null ? normalizeNumericString(anyM.conversion_discount as string) : null, + anyM.conversion_discount != null + ? normalizeNumericString(anyM.conversion_discount as string, mechanismField('conversion_discount')) + : null, conversion_valuation_cap: anyM.conversion_valuation_cap - ? monetaryToDaml(anyM.conversion_valuation_cap as Monetary) + ? monetaryToDaml(anyM.conversion_valuation_cap as Monetary, mechanismField('conversion_valuation_cap')) : null, capitalization_definition: optionalString(anyM.capitalization_definition as string | undefined), capitalization_definition_rules: mapCapRules(anyM.capitalization_definition_rules), @@ -299,7 +315,7 @@ function mechanismInputToDamlEnum( const anyM = m as Record; if (anyM.converts_to_percent === undefined || typeof anyM.converts_to_percent !== 'string') { throw new OcpValidationError( - 'conversion_mechanism.converts_to_percent', + mechanismField('converts_to_percent'), 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION requires converts_to_percent as string', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); @@ -307,7 +323,10 @@ function mechanismInputToDamlEnum( return { tag: 'OcfConvMechPercentCapitalization', value: { - converts_to_percent: normalizeNumericString(anyM.converts_to_percent), + converts_to_percent: normalizeNumericString( + anyM.converts_to_percent, + mechanismField('converts_to_percent') + ), capitalization_definition: optionalString(anyM.capitalization_definition as string | undefined), capitalization_definition_rules: mapCapRules(anyM.capitalization_definition_rules), }, @@ -317,7 +336,7 @@ function mechanismInputToDamlEnum( const anyM = m as Record; if (anyM.converts_to_quantity === undefined || typeof anyM.converts_to_quantity !== 'string') { throw new OcpValidationError( - 'conversion_mechanism.converts_to_quantity', + mechanismField('converts_to_quantity'), 'FIXED_AMOUNT_CONVERSION requires converts_to_quantity as string', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); @@ -325,7 +344,10 @@ function mechanismInputToDamlEnum( return { tag: 'OcfConvMechFixedAmount', value: { - converts_to_quantity: normalizeNumericString(anyM.converts_to_quantity), + converts_to_quantity: normalizeNumericString( + anyM.converts_to_quantity, + mechanismField('converts_to_quantity') + ), }, }; } @@ -333,7 +355,7 @@ function mechanismInputToDamlEnum( const anyM = m as Record; if (!anyM.valuation_type) throw new OcpValidationError( - 'conversion_mechanism.valuation_type', + mechanismField('valuation_type'), 'VALUATION_BASED_CONVERSION requires valuation_type', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); @@ -341,7 +363,9 @@ function mechanismInputToDamlEnum( tag: 'OcfConvMechValuationBased', value: { valuation_type: anyM.valuation_type as string, - valuation_amount: anyM.valuation_amount ? monetaryToDaml(anyM.valuation_amount as Monetary) : null, + valuation_amount: anyM.valuation_amount + ? monetaryToDaml(anyM.valuation_amount as Monetary, mechanismField('valuation_amount')) + : null, capitalization_definition: optionalString(anyM.capitalization_definition as string | undefined), capitalization_definition_rules: mapCapRules(anyM.capitalization_definition_rules), }, @@ -350,11 +374,9 @@ function mechanismInputToDamlEnum( case 'PPS_BASED_CONVERSION': { const anyM = m as Record; if (!anyM.description || typeof anyM.description !== 'string') { - throw new OcpValidationError( - 'conversion_mechanism.description', - 'PPS_BASED_CONVERSION requires description', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); + throw new OcpValidationError(mechanismField('description'), 'PPS_BASED_CONVERSION requires description', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }); } return { tag: 'OcfConvMechPpsBased', @@ -364,8 +386,10 @@ function mechanismInputToDamlEnum( discount_percentage: anyM.discount_percentage === '' || anyM.discount_percentage == null ? null - : normalizeNumericString(anyM.discount_percentage as string), - discount_amount: anyM.discount_amount ? monetaryToDaml(anyM.discount_amount as Monetary) : null, + : normalizeNumericString(anyM.discount_percentage as string, mechanismField('discount_percentage')), + discount_amount: anyM.discount_amount + ? monetaryToDaml(anyM.discount_amount as Monetary, mechanismField('discount_amount')) + : null, }, }; } @@ -377,7 +401,7 @@ function mechanismInputToDamlEnum( (anyM.description as string); if (!desc) throw new OcpValidationError( - 'conversion_mechanism.custom_conversion_description', + mechanismField('custom_conversion_description'), 'CUSTOM_CONVERSION requires custom_conversion_description', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); @@ -388,18 +412,16 @@ function mechanismInputToDamlEnum( } default: { throw new OcpParseError(`Unknown conversion mechanism: ${typeStr}`, { - source: 'conversion_mechanism.type', + source: mechanismField('type'), code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } } } // No mechanism provided -> error (strict) - throw new OcpValidationError( - 'conversion_right.conversion_mechanism', - 'conversion_right.conversion_mechanism is required', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); + throw new OcpValidationError(mechanismPath, 'conversion_right.conversion_mechanism is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }); } function buildConvertibleRight(input: ConversionTriggerInput | undefined, index: number) { diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 186cb694..69f02e89 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -126,9 +126,12 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers if (!Array.isArray(ts)) return []; const mapMechanism = (m: unknown, mechanismPath: string): ConvertibleConversionRight['conversion_mechanism'] => { + const mechanismField = (field: string): string => `${mechanismPath}.${field}`; + // Handle both string enum and DAML variant { tag, value } const mapTiming = (t: unknown): 'PRE_MONEY' | 'POST_MONEY' | undefined => { - const s = safeString(t); + const fieldPath = mechanismField('conversion_timing'); + const s = safeString(t, fieldPath); if (!s) return undefined; // Canonical DAML constructors (current schema) if (s === 'OcfConvTimingPreMoney') return 'PRE_MONEY'; @@ -138,26 +141,37 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers if (s === 'OcfConversionTimingPreMoney') return 'PRE_MONEY'; if (s === 'OcfConversionTimingPostMoney') return 'POST_MONEY'; throw new OcpParseError(`Unknown conversion_timing: ${s}`, { - source: 'conversionMechanism.conversion_timing', + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); }; if (typeof m === 'string') { throw new OcpParseError(`conversion_mechanism missing variant value (got tag '${m}')`, { - source: 'conversionRight.conversion_mechanism', + source: mechanismPath, code: OcpErrorCodes.SCHEMA_MISMATCH, }); } if (m && typeof m === 'object') { - const tag = (m as Record).tag as string | undefined; - const value = (m as Record).value as Record | undefined; - if (!tag || !value) { - throw new OcpValidationError('conversion_mechanism', 'Tag and value are required', { + const mechanism = m as Record; + const { tag } = mechanism; + if (typeof tag !== 'string' || tag.length === 0) { + throw new OcpValidationError(mechanismField('tag'), 'A non-empty mechanism tag is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: tag, + }); + } + const rawValue = mechanism.value; + if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) { + throw new OcpValidationError(mechanismField('value'), 'A mechanism value object is required', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-null object', + receivedValue: rawValue, }); } + const value = rawValue as Record; switch (tag) { case 'OcfConvMechSAFE': { const mech: SafeConversionMechanism = { @@ -168,7 +182,8 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers conversion_discount: normalizeNumericString( typeof value.conversion_discount === 'number' ? value.conversion_discount.toString() - : value.conversion_discount + : value.conversion_discount, + mechanismField('conversion_discount') ), } : {}), @@ -176,11 +191,12 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ? { conversion_valuation_cap: (() => { const monetary = damlMonetaryToNativeWithValidation( - value.conversion_valuation_cap as Record + value.conversion_valuation_cap, + mechanismField('conversion_valuation_cap') ); if (!monetary) { throw new OcpValidationError( - 'convertibleIssuance.conversion_valuation_cap', + mechanismField('conversion_valuation_cap'), 'Invalid monetary value for conversion_valuation_cap', { code: OcpErrorCodes.INVALID_TYPE, receivedValue: value.conversion_valuation_cap } ); @@ -198,10 +214,12 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ? { exit_multiple: { numerator: normalizeNumericString( - String((value.exit_multiple as Record).numerator) + String((value.exit_multiple as Record).numerator), + mechanismField('exit_multiple.numerator') ), denominator: normalizeNumericString( - String((value.exit_multiple as Record).denominator) + String((value.exit_multiple as Record).denominator), + mechanismField('exit_multiple.denominator') ), }, } @@ -218,7 +236,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers : (() => { if (typeof value.converts_to_percent !== 'string') { throw new OcpValidationError( - 'conversion_mechanism.converts_to_percent', + mechanismField('converts_to_percent'), `Must be string or number, got ${typeof value.converts_to_percent}`, { code: OcpErrorCodes.INVALID_TYPE, @@ -228,7 +246,8 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ); } return value.converts_to_percent; - })() + })(), + mechanismField('converts_to_percent') ), ...(value.capitalization_definition ? { capitalization_definition: value.capitalization_definition } : {}), ...(value.capitalization_definition_rules @@ -246,7 +265,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers : (() => { if (typeof value.converts_to_quantity !== 'string') { throw new OcpValidationError( - 'conversion_mechanism.converts_to_quantity', + mechanismField('converts_to_quantity'), `Must be string or number, got ${typeof value.converts_to_quantity}`, { code: OcpErrorCodes.INVALID_TYPE, @@ -256,7 +275,8 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ); } return value.converts_to_quantity; - })() + })(), + mechanismField('converts_to_quantity') ), }; return mech; @@ -269,11 +289,12 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ? { valuation_amount: (() => { const monetary = damlMonetaryToNativeWithValidation( - value.valuation_amount as Record + value.valuation_amount, + mechanismField('valuation_amount') ); if (!monetary) { throw new OcpValidationError( - 'convertibleIssuance.valuation_amount', + mechanismField('valuation_amount'), 'Invalid monetary value for valuation_amount', { code: OcpErrorCodes.INVALID_TYPE, receivedValue: value.valuation_amount } ); @@ -303,7 +324,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ? value.discount_percentage : (() => { throw new OcpValidationError( - 'conversion_mechanism.discount_percentage', + mechanismField('discount_percentage'), `Must be string or number, got ${typeof value.discount_percentage}`, { code: OcpErrorCodes.INVALID_TYPE, @@ -311,7 +332,8 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers receivedValue: value.discount_percentage, } ); - })() + })(), + mechanismField('discount_percentage') ), } : {}), @@ -319,11 +341,12 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ? { discount_amount: (() => { const monetary = damlMonetaryToNativeWithValidation( - value.discount_amount as Record + value.discount_amount, + mechanismField('discount_amount') ); if (!monetary) { throw new OcpValidationError( - 'convertibleIssuance.discount_amount', + mechanismField('discount_amount'), 'Invalid monetary value for discount_amount', { code: OcpErrorCodes.INVALID_TYPE, receivedValue: value.discount_amount } ); @@ -362,7 +385,10 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers `${interestRatePath}.accrual_end_date` ); return { - rate: normalizeNumericString(typeof irObj.rate === 'number' ? irObj.rate.toString() : irObj.rate), + rate: normalizeNumericString( + typeof irObj.rate === 'number' ? irObj.rate.toString() : irObj.rate, + `${interestRatePath}.rate` + ), accrual_start_date: damlTimeToDateString( irObj.accrual_start_date, `${interestRatePath}.accrual_start_date` @@ -374,7 +400,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers const accrualFromDaml = ( v: unknown ): 'DAILY' | 'MONTHLY' | 'QUARTERLY' | 'SEMI_ANNUAL' | 'ANNUAL' | undefined => { - const s = safeString(v); + const s = safeString(v, mechanismField('interest_accrual_period')); if (s.endsWith('OcfAccrualDaily') || s === 'OcfAccrualDaily') return 'DAILY'; if (s.endsWith('OcfAccrualMonthly') || s === 'OcfAccrualMonthly') return 'MONTHLY'; if (s.endsWith('OcfAccrualQuarterly') || s === 'OcfAccrualQuarterly') return 'QUARTERLY'; @@ -383,12 +409,13 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers return undefined; }; const compoundingFromDaml = (v: unknown): 'SIMPLE' | 'COMPOUNDING' | undefined => { - const s = safeString(v); + const fieldPath = mechanismField('compounding_type'); + const s = safeString(v, fieldPath); if (!s) return undefined; if (s === 'OcfSimple') return 'SIMPLE'; if (s === 'OcfCompounding') return 'COMPOUNDING'; - throw new OcpParseError(`Unknown compounding_type: ${safeString(v)}`, { - source: 'conversion_mechanism.compounding_type', + throw new OcpParseError(`Unknown compounding_type: ${s}`, { + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); }; @@ -398,11 +425,12 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ...(value.day_count_convention ? { day_count_convention: (() => { - const s = safeString(value.day_count_convention); + const fieldPath = mechanismField('day_count_convention'); + const s = safeString(value.day_count_convention, fieldPath); if (s === 'OcfDayCountActual365') return 'ACTUAL_365' as const; if (s === 'OcfDayCount30_360') return '30_360' as const; throw new OcpParseError(`Unknown day_count_convention: ${s}`, { - source: 'conversionMechanism.day_count_convention', + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); })(), @@ -411,11 +439,12 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ...(value.interest_payout ? { interest_payout: (() => { - const s = safeString(value.interest_payout); + const fieldPath = mechanismField('interest_payout'); + const s = safeString(value.interest_payout, fieldPath); if (s === 'OcfInterestPayoutDeferred') return 'DEFERRED' as const; if (s === 'OcfInterestPayoutCash') return 'CASH' as const; throw new OcpParseError(`Unknown interest_payout: ${s}`, { - source: 'conversionMechanism.interest_payout', + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); })(), @@ -430,7 +459,8 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers conversion_discount: normalizeNumericString( typeof value.conversion_discount === 'number' ? value.conversion_discount.toString() - : value.conversion_discount + : value.conversion_discount, + mechanismField('conversion_discount') ), } : {}), @@ -438,11 +468,12 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ? { conversion_valuation_cap: (() => { const monetary = damlMonetaryToNativeWithValidation( - value.conversion_valuation_cap as Record + value.conversion_valuation_cap, + mechanismField('conversion_valuation_cap') ); if (!monetary) { throw new OcpValidationError( - 'convertibleIssuance.conversion_valuation_cap', + mechanismField('conversion_valuation_cap'), 'Invalid monetary value for conversion_valuation_cap', { code: OcpErrorCodes.INVALID_TYPE, receivedValue: value.conversion_valuation_cap } ); @@ -459,10 +490,12 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ? { exit_multiple: { numerator: normalizeNumericString( - String((value.exit_multiple as Record).numerator) + String((value.exit_multiple as Record).numerator), + mechanismField('exit_multiple.numerator') ), denominator: normalizeNumericString( - String((value.exit_multiple as Record).denominator) + String((value.exit_multiple as Record).denominator), + mechanismField('exit_multiple.denominator') ), }, } @@ -474,7 +507,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers case 'OcfConvMechCustom': { if (!value.custom_conversion_description) { throw new OcpValidationError( - 'conversion_mechanism.custom_conversion_description', + mechanismField('custom_conversion_description'), 'Required for CUSTOM_CONVERSION', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); @@ -487,14 +520,14 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers } default: throw new OcpParseError(`Unknown convertible conversion mechanism tag: ${String(tag)}`, { - source: 'conversion_mechanism.tag', + source: mechanismField('tag'), code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } } throw new OcpParseError('Unknown conversion_mechanism shape', { - source: 'conversionRight.conversion_mechanism', + source: mechanismPath, code: OcpErrorCodes.SCHEMA_MISMATCH, }); }; diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts index 136c031e..9b7cc985 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts @@ -10,6 +10,7 @@ import { optionalDateStringToDAMLTime, optionalString, } from '../../../utils/typeConversions'; +import { filterAndMapVestingsToDaml } from '../shared/vesting'; export function compensationTypeToDaml(t: CompensationType): Fairmint.OpenCapTable.Types.Vesting.OcfCompensationType { switch (t) { @@ -72,14 +73,6 @@ export function equityCompensationIssuanceDataToDaml( vesting_terms_id?: string; } ): Record { - const filteredVestings = (d.vestings ?? []) - .map((vesting, index) => ({ index, vesting })) - .filter(({ vesting }) => { - // normalizeNumericString validates strict decimal format and rejects scientific notation - const normalized = normalizeNumericString(vesting.amount); - return parseFloat(normalized) > 0; - }); - return { id: d.id, security_id: d.security_id, @@ -107,10 +100,7 @@ export function equityCompensationIssuanceDataToDaml( exercise_price: d.exercise_price ? monetaryToDaml(d.exercise_price) : null, base_price: d.base_price ? monetaryToDaml(d.base_price) : null, early_exercisable: d.early_exercisable ?? null, - vestings: filteredVestings.map(({ index, vesting }) => ({ - date: dateStringToDAMLTime(vesting.date, `equityCompensationIssuance.vestings[${index}].date`), - amount: normalizeNumericString(vesting.amount), - })), + vestings: filterAndMapVestingsToDaml(d.vestings, 'equityCompensationIssuance.vestings'), expiration_date: nullableDateStringToDAMLTime(d.expiration_date, 'equityCompensationIssuance.expiration_date'), termination_exercise_windows: d.termination_exercise_windows.map((w) => ({ reason: terminationWindowReasonMap[w.reason], diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 4aeacc81..52410791 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -56,10 +56,8 @@ const twMapPeriodType: Partial> = { * Used by both getEquityCompensationIssuanceAsOcf and the damlToOcf dispatcher. */ export function damlEquityCompensationIssuanceDataToNative(d: Record): OcfEquityCompensationIssuance { - const exercise_price = damlMonetaryToNativeWithValidation( - d.exercise_price as Record | null | undefined - ); - const base_price = damlMonetaryToNativeWithValidation(d.base_price as Record | null | undefined); + const exercise_price = damlMonetaryToNativeWithValidation(d.exercise_price); + const base_price = damlMonetaryToNativeWithValidation(d.base_price); const vestings = Array.isArray(d.vestings) && d.vestings.length > 0 diff --git a/src/functions/OpenCapTable/planSecurityIssuance/planSecurityIssuanceDataToDaml.ts b/src/functions/OpenCapTable/planSecurityIssuance/planSecurityIssuanceDataToDaml.ts index 032b218c..9a3fae04 100644 --- a/src/functions/OpenCapTable/planSecurityIssuance/planSecurityIssuanceDataToDaml.ts +++ b/src/functions/OpenCapTable/planSecurityIssuance/planSecurityIssuanceDataToDaml.ts @@ -21,6 +21,7 @@ import { terminationWindowPeriodTypeMap, terminationWindowReasonMap, } from '../equityCompensationIssuance/createEquityCompensationIssuance'; +import { filterAndMapVestingsToDaml } from '../shared/vesting'; /** * Convert native OCF PlanSecurityIssuance data to DAML format. @@ -40,13 +41,6 @@ export function planSecurityIssuanceDataToDaml(d: OcfPlanSecurityIssuance): Reco const compensationType = d.compensation_type; - const filteredVestings = (d.vestings ?? []) - .map((vesting, index) => ({ index, vesting })) - .filter(({ vesting }) => { - const normalized = normalizeNumericString(vesting.amount); - return parseFloat(normalized) > 0; - }); - return { id: d.id, security_id: d.security_id, @@ -74,10 +68,7 @@ export function planSecurityIssuanceDataToDaml(d: OcfPlanSecurityIssuance): Reco exercise_price: d.exercise_price ? monetaryToDaml(d.exercise_price) : null, base_price: d.base_price ? monetaryToDaml(d.base_price) : null, early_exercisable: d.early_exercisable ?? null, - vestings: filteredVestings.map(({ index, vesting }) => ({ - date: dateStringToDAMLTime(vesting.date, `planSecurityIssuance.vestings[${index}].date`), - amount: normalizeNumericString(vesting.amount), - })), + vestings: filterAndMapVestingsToDaml(d.vestings, 'planSecurityIssuance.vestings'), expiration_date: nullableDateStringToDAMLTime(d.expiration_date, 'planSecurityIssuance.expiration_date'), termination_exercise_windows: d.termination_exercise_windows.map((w) => ({ reason: terminationWindowReasonMap[w.reason], diff --git a/src/functions/OpenCapTable/shared/vesting.ts b/src/functions/OpenCapTable/shared/vesting.ts new file mode 100644 index 00000000..0700ba27 --- /dev/null +++ b/src/functions/OpenCapTable/shared/vesting.ts @@ -0,0 +1,30 @@ +import { dateStringToDAMLTime, normalizeNumericString } from '../../../utils/typeConversions'; + +interface VestingInput { + date: string; + amount: string | number; +} + +interface DamlVesting { + date: string; + amount: string; +} + +/** Filter zero-value vestings while retaining original indexes for validation paths. */ +export function filterAndMapVestingsToDaml( + vestings: readonly VestingInput[] | null | undefined, + basePath: string +): DamlVesting[] { + const filteredVestings = (vestings ?? []) + .map((vesting, index) => ({ index, vesting })) + .filter(({ vesting }) => { + // Preserve the converter boundary: malformed amounts fail before filtering. + const normalized = normalizeNumericString(vesting.amount); + return parseFloat(normalized) > 0; + }); + + return filteredVestings.map(({ index, vesting }) => ({ + date: dateStringToDAMLTime(vesting.date, `${basePath}[${index}].date`), + amount: normalizeNumericString(vesting.amount), + })); +} diff --git a/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts b/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts index 87bc9a54..05f4f3ef 100644 --- a/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts +++ b/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts @@ -14,6 +14,7 @@ import { optionalDateStringToDAMLTime, optionalString, } from '../../../utils/typeConversions'; +import { filterAndMapVestingsToDaml } from '../shared/vesting'; /** * Convert native StockIssuanceType to DAML enum value. @@ -74,17 +75,7 @@ export function stockIssuanceDataToDaml(d: OcfStockIssuance): PkgStockIssuanceOc share_price: monetaryToDaml(d.share_price), quantity: normalizeNumericString(d.quantity), vesting_terms_id: optionalString(d.vesting_terms_id), - vestings: (d.vestings ?? []) - .map((vesting, index) => ({ index, vesting })) - .filter(({ vesting }) => { - // normalizeNumericString validates strict decimal format and rejects scientific notation - const normalized = normalizeNumericString(vesting.amount); - return parseFloat(normalized) > 0; - }) - .map(({ index, vesting }) => ({ - date: dateStringToDAMLTime(vesting.date, `stockIssuance.vestings[${index}].date`), - amount: normalizeNumericString(vesting.amount), - })), + vestings: filterAndMapVestingsToDaml(d.vestings, 'stockIssuance.vestings'), cost_basis: d.cost_basis ? monetaryToDaml(d.cost_basis) : null, stock_legend_ids: d.stock_legend_ids, issuance_type: getIssuanceType(d.issuance_type), diff --git a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts index e96bc8c0..80d7482e 100644 --- a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts +++ b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts @@ -281,7 +281,7 @@ export function vestingTermsDataToDaml(d: OcfVestingTerms): Record vestingConditionToDaml(condition, index)), comments: cleanComments(d.comments), }; diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index 02a0c793..1c861026 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -207,7 +207,7 @@ function damlVestingTriggerToNative( if (tag === 'OcfVestingScheduleAbsoluteTrigger') { const value = typeof t === 'string' ? undefined : t.value; if (!value || typeof value !== 'object') - throw new OcpValidationError('vestingTrigger.value', 'Missing value for OcfVestingScheduleAbsoluteTrigger', { + throw new OcpValidationError(`${fieldPath}.value`, 'Missing value for OcfVestingScheduleAbsoluteTrigger', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: value, }); diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 5b8c31b0..14a89ee5 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -96,7 +96,7 @@ function mapWarrantMechanism(m: unknown): WarrantConversionMechanism { ), }; case 'OcfWarrantMechanismValuationBased': { - const valuationAmount = damlMonetaryToNativeWithValidation(value.valuation_amount as Record); + const valuationAmount = damlMonetaryToNativeWithValidation(value.valuation_amount); if (typeof value.valuation_type !== 'string' || !value.valuation_type) { throw new OcpValidationError( 'warrantMechanism.valuation_type', @@ -117,7 +117,7 @@ function mapWarrantMechanism(m: unknown): WarrantConversionMechanism { }; } case 'OcfWarrantMechanismPpsBased': { - const discountAmount = damlMonetaryToNativeWithValidation(value.discount_amount as Record); + const discountAmount = damlMonetaryToNativeWithValidation(value.discount_amount); if (typeof value.description !== 'string' || !value.description) { throw new OcpValidationError( 'warrantMechanism.description', @@ -365,9 +365,7 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf }) : []; - const exercise_price = d.exercise_price - ? damlMonetaryToNativeWithValidation(d.exercise_price as Record) - : undefined; + const exercise_price = d.exercise_price ? damlMonetaryToNativeWithValidation(d.exercise_price) : undefined; const purchase_price_obj = d.purchase_price as Record | null | undefined; if (!purchase_price_obj) { diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 9ec251b5..c03c6733 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -141,17 +141,17 @@ export function nullableDamlTimeToDateString(value: unknown, fieldPath: string): * * @throws OcpValidationError if the string contains scientific notation (e.g., "1.5e10") or is not a valid numeric string */ -export function normalizeNumericString(value: string | number): string { +export function normalizeNumericString(value: string | number, fieldPath = 'numericString'): string { // DAML Numeric values may arrive as JavaScript numbers at runtime // despite being typed as string in the generated package types. // Coerce to string at this boundary. if (typeof value === 'number') { - return normalizeNumericString(value.toString()); + return normalizeNumericString(value.toString(), fieldPath); } // Validate: reject scientific notation if (value.toLowerCase().includes('e')) { - throw new OcpValidationError('numericString', `Scientific notation is not supported`, { + throw new OcpValidationError(fieldPath, `Scientific notation is not supported`, { expectedType: 'string (decimal format)', receivedValue: value, code: OcpErrorCodes.INVALID_FORMAT, @@ -160,7 +160,7 @@ export function normalizeNumericString(value: string | number): string { // Validate: must be a valid numeric string (optional minus, digits, optional decimal and more digits) if (!/^-?\d+(\.\d+)?$/.test(value)) { - throw new OcpValidationError('numericString', `Invalid numeric string format`, { + throw new OcpValidationError(fieldPath, `Invalid numeric string format`, { expectedType: 'string (decimal format)', receivedValue: value, code: OcpErrorCodes.INVALID_FORMAT, @@ -207,10 +207,10 @@ export function optionalString(value: string | null | undefined): string | null * * Used internally for DAML optional enum fields where null means "not set". */ -export function safeString(value: unknown): string { +export function safeString(value: unknown, fieldPath = 'safeString'): string { if (value === null || value === undefined) return ''; if (typeof value === 'string') return value; - throw new OcpValidationError('safeString', `Expected a string value, got ${typeof value}`, { + throw new OcpValidationError(fieldPath, `Expected a string value, got ${typeof value}`, { code: OcpErrorCodes.INVALID_TYPE, expectedType: 'string', receivedValue: value, @@ -242,9 +242,12 @@ export function mapDamlTriggerTypeToOcf(tag: string): ConversionTriggerType { // ===== Monetary Value Conversions ===== -export function monetaryToDaml(monetary: Monetary): Fairmint.OpenCapTable.Types.Monetary.OcfMonetary { +export function monetaryToDaml( + monetary: Monetary, + fieldPath?: string +): Fairmint.OpenCapTable.Types.Monetary.OcfMonetary { return { - amount: normalizeNumericString(monetary.amount), + amount: normalizeNumericString(monetary.amount, fieldPath ? `${fieldPath}.amount` : 'numericString'), currency: monetary.currency, }; } @@ -264,37 +267,50 @@ export function damlMonetaryToNative(damlMonetary: Fairmint.OpenCapTable.Types.M * @returns The validated native monetary object, or undefined if input is null/undefined * @throws OcpValidationError if amount or currency are invalid */ -export function damlMonetaryToNativeWithValidation( - monetary: Record | null | undefined -): Monetary | undefined { - if (!monetary) return undefined; +export function damlMonetaryToNativeWithValidation(monetary: unknown, fieldPath = 'monetary'): Monetary | undefined { + if (monetary === null || monetary === undefined) return undefined; + if (!isRecord(monetary)) { + throw new OcpValidationError(fieldPath, 'Monetary value must be a non-null object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'Monetary object or null/undefined', + receivedValue: monetary, + }); + } // Validate amount exists and is string if (monetary.amount === undefined || monetary.amount === null) { - throw new OcpValidationError('monetary.amount', 'Monetary amount is required but was undefined or null', { + throw new OcpValidationError(`${fieldPath}.amount`, 'Monetary amount is required but was undefined or null', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, expectedType: 'string', receivedValue: monetary.amount, }); } if (typeof monetary.amount !== 'string') { - throw new OcpValidationError('monetary.amount', `Monetary amount must be a string, got ${typeof monetary.amount}`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string', - receivedValue: monetary.amount, - }); + throw new OcpValidationError( + `${fieldPath}.amount`, + `Monetary amount must be a string, got ${typeof monetary.amount}`, + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string', + receivedValue: monetary.amount, + } + ); } // Validate currency exists and is string if (typeof monetary.currency !== 'string' || !monetary.currency) { - throw new OcpValidationError('monetary.currency', 'Monetary currency is required and must be a non-empty string', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'string', - receivedValue: monetary.currency, - }); + throw new OcpValidationError( + `${fieldPath}.currency`, + 'Monetary currency is required and must be a non-empty string', + { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'string', + receivedValue: monetary.currency, + } + ); } - const amount = normalizeNumericString(monetary.amount); + const amount = normalizeNumericString(monetary.amount, `${fieldPath}.amount`); return { amount, currency: monetary.currency }; } diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 2dd539d2..94468cd4 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -11,7 +11,7 @@ * - OcfInterestPayoutDeferred / OcfInterestPayoutCash */ -import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { expectInvalidDate } from '../utils/dateValidationAssertions'; @@ -50,6 +50,19 @@ function noteInterestRatePath(triggerIndex = 0, interestRateIndex = 0): string { ); } +function conversionMechanismPath(triggerIndex = 0): string { + return `convertibleIssuance.conversion_triggers[${triggerIndex}].conversion_right.conversion_mechanism`; +} + +function captureError(action: () => unknown): unknown { + try { + action(); + } catch (error) { + return error; + } + throw new Error('Expected conversion mechanism validation to fail'); +} + function buildConvertibleNoteTrigger(triggerId: string, interestRates: Array>) { return { ...SAFE_TRIGGER_BASE, @@ -193,6 +206,58 @@ describe('ConvertibleConversionMechanismInput bare string handling', () => { }); }); +describe('write-side conversion mechanism paths', () => { + it('reports the exact trigger index for a malformed nested numeric field', () => { + const invalidTrigger = { + ...SAFE_TRIGGER_BASE, + trigger_id: 'trigger-002', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: '1e2', + }, + }, + }; + const error = captureError(() => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE, invalidTrigger], + }) + ); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${conversionMechanismPath(1)}.converts_to_quantity`, + receivedValue: '1e2', + }); + }); + + it('reports the exact trigger index for a malformed mechanism enum', () => { + const invalidTrigger = { + ...SAFE_TRIGGER_BASE, + trigger_id: 'trigger-002', + conversion_right: { + ...SAFE_TRIGGER_BASE.conversion_right, + conversion_mechanism: { + ...SAFE_TRIGGER_BASE.conversion_right.conversion_mechanism, + conversion_timing: 'POSTMONEY', + }, + }, + }; + const error = captureError(() => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE, invalidTrigger], + }) + ); + + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ source: `${conversionMechanismPath(1)}.conversion_timing` }); + }); +}); + // --------------------------------------------------------------------------- // Read-side (DAML → OCF) exactness tests // --------------------------------------------------------------------------- @@ -251,6 +316,53 @@ function buildDamlNoteTrigger(dayCount: string, interestPayout: string, triggerI }; } +describe('read-side conversion mechanism paths', () => { + it('reports the exact trigger index for a malformed nested field', () => { + const invalidTrigger = { + ...buildDamlSafeTrigger(), + trigger_id: 'trigger-002', + conversion_right: { + OcfRightConvertible: { + conversion_mechanism: { + tag: 'OcfConvMechFixedAmount', + value: { converts_to_quantity: { unexpected: true } }, + }, + converts_to_future_round: true, + }, + }, + }; + const error = captureError(() => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [buildDamlSafeTrigger(), invalidTrigger], + }) + ); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `${conversionMechanismPath(1)}.converts_to_quantity`, + receivedValue: { unexpected: true }, + }); + }); + + it('reports the exact trigger index for a malformed mechanism enum', () => { + const invalidTrigger = { + ...buildDamlSafeTrigger('OcfConvTimingInvalidValue'), + trigger_id: 'trigger-002', + }; + const error = captureError(() => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [buildDamlSafeTrigger(), invalidTrigger], + }) + ); + + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ source: `${conversionMechanismPath(1)}.conversion_timing` }); + }); +}); + describe('read-side: conversion_timing exact DAML constructor matching', () => { it('OcfConvTimingPreMoney → PRE_MONEY', () => { const result = damlConvertibleIssuanceDataToNative({ @@ -450,17 +562,12 @@ describe('convertible issuance approval-date read boundaries', () => { conversion_triggers: [SAFE_TRIGGER_BASE], }); - try { - damlConvertibleIssuanceDataToNative({ ...daml, [field]: invalidDate }); - throw new Error('Expected approval date validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `convertibleIssuance.${field}`, - receivedValue: invalidDate, - }); - } + expectInvalidDate( + () => damlConvertibleIssuanceDataToNative({ ...daml, [field]: invalidDate }), + `convertibleIssuance.${field}`, + invalidDate, + OcpErrorCodes.INVALID_TYPE + ); } ); diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index 47340e89..91dcd42f 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -3,9 +3,12 @@ import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCa import { damlEquityCompensationIssuanceDataToNative } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; import { issuerAuthorizedSharesAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment'; import { damlIssuerAuthorizedSharesAdjustmentDataToNative } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf'; +import { planSecurityIssuanceDataToDaml } from '../../src/functions/OpenCapTable/planSecurityIssuance/planSecurityIssuanceDataToDaml'; +import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; import { damlStockClassSplitToNative } from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; import { stockIssuanceDataToDaml } from '../../src/functions/OpenCapTable/stockIssuance/createStockIssuance'; +import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import { stockPlanPoolAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment'; import { damlStockPlanPoolAdjustmentDataToNative } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf'; import { expectInvalidDate } from '../utils/dateValidationAssertions'; @@ -83,6 +86,20 @@ const STOCK_CLASS_WRITE_BASE: Parameters[0] = { conversion_rights: [], }; +const PLAN_SECURITY_ISSUANCE_WRITE_BASE: Parameters[0] = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', + id: 'plan-security-1', + date: '2024-01-15', + security_id: 'security-1', + custom_id: 'OPTION-1', + stakeholder_id: 'stakeholder-1', + compensation_type: 'OPTION', + quantity: '1000', + expiration_date: null, + termination_exercise_windows: [], + security_law_exemptions: [], +}; + const STOCK_CLASS_CONVERSION_RIGHT = { type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, conversion_mechanism: { @@ -125,6 +142,34 @@ const OPTIONAL_READ_DATE_CASES: Array<{ [field]: value, }), })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `stock class ${field}`, + fieldPath: `stockClass.${field}`, + convert: (value: unknown) => + damlStockClassDataToNative({ + ...stockClassDataToDaml(STOCK_CLASS_WRITE_BASE), + [field]: value, + } as unknown as Parameters[0]), + })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `stock issuance ${field}`, + fieldPath: `stockIssuance.${field}`, + convert: (value: unknown) => + damlStockIssuanceDataToNative({ + ...stockIssuanceDataToDaml(STOCK_ISSUANCE_WRITE_BASE), + [field]: value, + }), + })), + // PlanSecurityIssuance and EquityCompensationIssuance share one DAML contract and reader. + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `plan security issuance shared reader ${field}`, + fieldPath: `equityCompensationIssuance.${field}`, + convert: (value: unknown) => + damlEquityCompensationIssuanceDataToNative({ + ...planSecurityIssuanceDataToDaml(PLAN_SECURITY_ISSUANCE_WRITE_BASE), + [field]: value, + }), + })), ]; const OPTIONAL_WRITE_DATE_CASES: Array<{ @@ -165,6 +210,36 @@ const OPTIONAL_WRITE_DATE_CASES: Array<{ [field]: value, }), })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `stock class ${field}`, + field, + fieldPath: `stockClass.${field}`, + convert: (value: unknown) => + stockClassDataToDaml({ + ...STOCK_CLASS_WRITE_BASE, + [field]: value, + }), + })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `stock issuance ${field}`, + field, + fieldPath: `stockIssuance.${field}`, + convert: (value: unknown) => + stockIssuanceDataToDaml({ + ...STOCK_ISSUANCE_WRITE_BASE, + [field]: value, + }), + })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `plan security issuance ${field}`, + field, + fieldPath: `planSecurityIssuance.${field}`, + convert: (value: unknown) => + planSecurityIssuanceDataToDaml({ + ...PLAN_SECURITY_ISSUANCE_WRITE_BASE, + [field]: value, + }), + })), ]; describe('DAML read converter date boundaries', () => { diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index fc5b8f16..edea8306 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -9,7 +9,7 @@ */ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { OcpParseError, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; import { damlValuationToNative, @@ -688,6 +688,25 @@ describe('VestingTerms drift regression', () => { expectInvalidDate(() => damlVestingTermsDataToNative(daml), 'vestingTerms.vesting_conditions[1].trigger.date', ''); }); + test('reports the exact vesting-condition path when an absolute trigger value is missing', () => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'missing-trigger-value', + description: null, + quantity: null, + portion: null, + trigger: { tag: 'OcfVestingScheduleAbsoluteTrigger' }, + next_condition_ids: [], + }); + + expectInvalidDate( + () => damlVestingTermsDataToNative(daml), + 'vestingTerms.vesting_conditions[1].trigger.value', + undefined, + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); + }); + test('preserves remainder: true', () => { const daml = makeDamlVestingTerms(); ( diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 3f69660e..13770c67 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -54,6 +54,16 @@ const nullableOcfDate: string | null = nullableDamlTimeToDateString(unknownDateI // @ts-expect-error every public date conversion requires an entity-specific field path dateStringToDAMLTime(unknownDateInput); +// @ts-expect-error every public date conversion requires an entity-specific field path +damlTimeToDateString(unknownDateInput); +// @ts-expect-error every public date conversion requires an entity-specific field path +optionalDamlTimeToDateString(unknownDateInput); +// @ts-expect-error every public date conversion requires an entity-specific field path +nullableDamlTimeToDateString(unknownDateInput); +// @ts-expect-error every public date conversion requires an entity-specific field path +optionalDateStringToDAMLTime(unknownDateInput); +// @ts-expect-error every public date conversion requires an entity-specific field path +nullableDateStringToDAMLTime(unknownDateInput); void publishedOcfObjectIsExact; void publishedOcfObjectExcludesLegacyPlanSecurity; diff --git a/test/utils/triggerFields.test.ts b/test/utils/triggerFields.test.ts index f7e405da..84fef1ab 100644 --- a/test/utils/triggerFields.test.ts +++ b/test/utils/triggerFields.test.ts @@ -13,13 +13,18 @@ function expectTriggerFieldError( receivedValue: unknown, code: OcpErrorCode ): void { - try { - action(); - throw new Error('Expected trigger field validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ code, fieldPath: `${PATH}.${field}`, receivedValue }); - } + let thrown: unknown; + + expect(() => { + try { + action(); + } catch (error) { + thrown = error; + throw error; + } + }).toThrow(OcpValidationError); + + expect(thrown).toMatchObject({ code, fieldPath: `${PATH}.${field}`, receivedValue }); } describe('trigger discriminator boundaries', () => { From a41fb1611b79ffe52dd77217de57b85c3f80a265 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 03:46:32 -0400 Subject: [PATCH 33/47] fix: freeze public entity mapping --- src/functions/OpenCapTable/capTable/entityTypes.ts | 4 ++-- test/batch/batchTypes.test.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/entityTypes.ts b/src/functions/OpenCapTable/capTable/entityTypes.ts index 5ce03c05..7aa1ea5d 100644 --- a/src/functions/OpenCapTable/capTable/entityTypes.ts +++ b/src/functions/OpenCapTable/capTable/entityTypes.ts @@ -173,7 +173,7 @@ export interface CapTableBatchExecuteResult { } /** Canonical OCF `object_type` to typed reader namespace. */ -export const OCF_OBJECT_TYPE_TO_ENTITY_TYPE = { +export const OCF_OBJECT_TYPE_TO_ENTITY_TYPE = Object.freeze({ CE_STAKEHOLDER_RELATIONSHIP: 'stakeholderRelationshipChangeEvent', CE_STAKEHOLDER_STATUS: 'stakeholderStatusChangeEvent', DOCUMENT: 'document', @@ -222,7 +222,7 @@ export const OCF_OBJECT_TYPE_TO_ENTITY_TYPE = { TX_WARRANT_TRANSFER: 'warrantTransfer', VALUATION: 'valuation', VESTING_TERMS: 'vestingTerms', -} as const satisfies Record; +} as const satisfies Record); type MappedOcfEntityType = (typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE)[keyof typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE]; const ALL_OCF_ENTITY_TYPES_ARE_MAPPED: [OcfEntityType] extends [MappedOcfEntityType] ? true : never = true; diff --git a/test/batch/batchTypes.test.ts b/test/batch/batchTypes.test.ts index 6afca747..1aa98dc1 100644 --- a/test/batch/batchTypes.test.ts +++ b/test/batch/batchTypes.test.ts @@ -3,6 +3,8 @@ import { isOcfDeletableEntityType, isOcfEditableEntityType, isOcfEntityType, + isOcfReadableObjectType, + mapOcfObjectTypeToEntityType, OCF_OBJECT_TYPE_TO_ENTITY_TYPE, type OcfEntityType, } from '../../src'; @@ -15,6 +17,18 @@ describe('batch entity capabilities', () => { expect(new Set(Object.values(OCF_OBJECT_TYPE_TO_ENTITY_TYPE))).toEqual(new Set(entityTypes)); }); + it('keeps the public object-type mapping immutable at runtime', () => { + expect(Object.isFrozen(OCF_OBJECT_TYPE_TO_ENTITY_TYPE)).toBe(true); + + expect(Reflect.set(OCF_OBJECT_TYPE_TO_ENTITY_TYPE, 'STOCK_CLASS', 'issuer')).toBe(false); + expect(Reflect.set(OCF_OBJECT_TYPE_TO_ENTITY_TYPE, 'SYNTHETIC', 'stakeholder')).toBe(false); + + expect(OCF_OBJECT_TYPE_TO_ENTITY_TYPE.STOCK_CLASS).toBe('stockClass'); + expect(mapOcfObjectTypeToEntityType('STOCK_CLASS')).toBe('stockClass'); + expect(isOcfReadableObjectType('SYNTHETIC')).toBe(false); + expect(mapOcfObjectTypeToEntityType('SYNTHETIC')).toBeNull(); + }); + it.each(entityTypes)('recognizes %s as a supported entity type', (entityType) => { expect(isOcfEntityType(entityType)).toBe(true); }); From d70c951b0f77b4ba7d679585b6b41e879e0b2b19 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 06:44:35 -0400 Subject: [PATCH 34/47] refactor: share batch operation decoding --- .../OpenCapTable/capTable/CapTableBatch.ts | 62 +++++++------------ 1 file changed, 23 insertions(+), 39 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/CapTableBatch.ts b/src/functions/OpenCapTable/capTable/CapTableBatch.ts index eb0942fa..e5e8ee3e 100644 --- a/src/functions/OpenCapTable/capTable/CapTableBatch.ts +++ b/src/functions/OpenCapTable/capTable/CapTableBatch.ts @@ -92,12 +92,7 @@ function decodeGeneratedOperation( } } -/** Build and validate one generated DAML create variant from a correlated entity-kind/data pair. */ -export function buildOcfCreateData( - ...args: Arguments -): OcfCreateDataFor; -export function buildOcfCreateData(...args: OcfCreateArguments): OcfCreateData { - const [type] = args; +function buildOcfCreateDataWith(type: OcfEntityType, convert: () => unknown): OcfCreateData { if (!isOcfCreatableEntityType(type)) { throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { code: OcpErrorCodes.INVALID_TYPE, @@ -107,25 +102,38 @@ export function buildOcfCreateData(...args: OcfCreateArguments): OcfCreateData { const tag = ENTITY_TAG_MAP[type].create; return decodeGeneratedOperation( Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, - { tag, value: convertToDaml(...args) }, + { tag, value: convert() }, 'create', type ); } +/** Build and validate one generated DAML create variant from a correlated entity-kind/data pair. */ +export function buildOcfCreateData( + ...args: Arguments +): OcfCreateDataFor; +export function buildOcfCreateData(...args: OcfCreateArguments): OcfCreateData { + const [type] = args; + return buildOcfCreateDataWith(type, () => convertToDaml(...args)); +} + function buildOcfCreateDataFromOperation(operation: OcfCreateOperation): OcfCreateData { const { type } = operation; - if (!isOcfCreatableEntityType(type)) { - throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { + return buildOcfCreateDataWith(type, () => convertOperationToDaml(operation)); +} + +function buildOcfEditDataWith(type: OcfEntityType, convert: () => unknown): OcfEditData { + if (!isOcfEditableEntityType(type)) { + throw new OcpValidationError('type', `Edit operation not supported for entity type: ${String(type)}`, { code: OcpErrorCodes.INVALID_TYPE, }); } - const tag = ENTITY_TAG_MAP[type].create; + const tag = ENTITY_TAG_MAP[type].edit; return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, - { tag, value: convertOperationToDaml(operation) }, - 'create', + Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, + { tag, value: convert() }, + 'edit', type ); } @@ -136,36 +144,12 @@ export function buildOcfEditData( ): OcfEditDataFor; export function buildOcfEditData(...args: OcfEditArguments): OcfEditData { const [type] = args; - if (!isOcfEditableEntityType(type)) { - throw new OcpValidationError('type', `Edit operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - - const tag = ENTITY_TAG_MAP[type].edit; - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, - { tag, value: convertToDaml(...args) }, - 'edit', - type - ); + return buildOcfEditDataWith(type, () => convertToDaml(...args)); } function buildOcfEditDataFromOperation(operation: OcfEditOperation): OcfEditData { const { type } = operation; - if (!isOcfEditableEntityType(type)) { - throw new OcpValidationError('type', `Edit operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - - const tag = ENTITY_TAG_MAP[type].edit; - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, - { tag, value: convertOperationToDaml(operation) }, - 'edit', - type - ); + return buildOcfEditDataWith(type, () => convertOperationToDaml(operation)); } /** Build and validate one generated DAML delete variant. */ From 6e8b7f6be519fbe0f499ccac40f4c25de45afa4d Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 08:42:00 -0400 Subject: [PATCH 35/47] fix: enforce exact trigger and date boundaries --- .../createConvertibleIssuance.ts | 103 ++++++------ .../getConvertibleIssuanceAsOcf.ts | 72 +++++---- .../OpenCapTable/shared/triggerFields.ts | 83 ++++++---- src/functions/OpenCapTable/shared/vesting.ts | 29 ++-- .../stockClass/stockClassDataToDaml.ts | 4 +- .../warrantIssuance/createWarrantIssuance.ts | 125 +++++++-------- .../getWarrantIssuanceAsOcf.ts | 64 +++++--- src/types/native.ts | 108 +++++++------ src/utils/cantonOcfExtractor.ts | 37 ++++- src/utils/typeConversions.ts | 5 +- .../convertibleIssuanceConverters.test.ts | 132 +++++++++++++++- .../converters/dateBoundaryValidation.test.ts | 12 ++ .../converters/planSecurityConverters.test.ts | 29 +++- .../warrantIssuanceConverters.test.ts | 148 +++++++++++++++++- test/declarations/publicApi.types.ts | 72 +++++++++ .../dateBoundaryInvariants.test.ts | 81 +++++++++- test/types/capTableBatch.types.ts | 98 ++++++++++++ test/utils/transactionSorting.test.ts | 128 ++++++++++++++- test/utils/triggerFields.test.ts | 90 ++++++----- test/utils/vesting.test.ts | 75 +++++++++ 20 files changed, 1170 insertions(+), 325 deletions(-) create mode 100644 test/utils/vesting.test.ts diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 92e1d9ab..d9ff73a1 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -1,6 +1,6 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; -import type { Monetary } from '../../../types'; +import type { ConversionTriggerFor, ConversionTriggerType, Monetary } from '../../../types'; import { cleanComments, dateStringToDAMLTime, @@ -12,13 +12,7 @@ import { } from '../../../utils/typeConversions'; import { triggerFieldsToDaml } from '../shared/triggerFields'; -type ConversionTriggerTypeInput = - | 'AUTOMATIC_ON_CONDITION' - | 'AUTOMATIC_ON_DATE' - | 'ELECTIVE_AT_WILL' - | 'ELECTIVE_ON_CONDITION' - | 'ELECTIVE_IN_RANGE' - | 'UNSPECIFIED'; +type ConversionTriggerTypeInput = ConversionTriggerType; type ConvertibleConversionMechanismInput = | 'CUSTOM_CONVERSION' @@ -30,23 +24,14 @@ type ConvertibleConversionMechanismInput = | 'PPS_BASED_CONVERSION' | (Record & { type: string }); -export type ConversionTriggerInput = - | ConversionTriggerTypeInput - | { - type: ConversionTriggerTypeInput; - trigger_id?: string; - nickname?: string; - trigger_description?: string; - trigger_date?: string; // YYYY-MM-DD or ISO datetime - trigger_condition?: string; - start_date?: string; // YYYY-MM-DD or ISO datetime (ELECTIVE_IN_RANGE) - end_date?: string; // YYYY-MM-DD or ISO datetime (ELECTIVE_IN_RANGE) - conversion_right?: { - conversion_mechanism?: ConvertibleConversionMechanismInput; - converts_to_future_round?: boolean; - converts_to_stock_class_id?: string; - }; - }; +interface ConvertibleConversionRightInput { + type: 'CONVERTIBLE_CONVERSION_RIGHT'; + conversion_mechanism: ConvertibleConversionMechanismInput; + converts_to_future_round?: boolean; + converts_to_stock_class_id?: string; +} + +export type ConversionTriggerInput = ConversionTriggerFor; function convertibleTypeToDaml( t: 'NOTE' | 'SAFE' | 'CONVERTIBLE_SECURITY' @@ -66,7 +51,8 @@ function normalizeTriggerType(t: ConversionTriggerTypeInput): ConversionTriggerT } function triggerTypeToDamlEnum( - t: ConversionTriggerTypeInput + t: ConversionTriggerTypeInput, + fieldPath: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTriggerType { switch (t) { case 'AUTOMATIC_ON_DATE': @@ -84,7 +70,7 @@ function triggerTypeToDamlEnum( default: { const exhaustiveCheck: never = t; throw new OcpParseError(`Unknown convertible trigger type: ${exhaustiveCheck as string}`, { - source: 'conversionTrigger.type', + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -424,15 +410,27 @@ function mechanismInputToDamlEnum( }); } -function buildConvertibleRight(input: ConversionTriggerInput | undefined, index: number) { - const details = typeof input === 'object' && 'conversion_right' in input ? input.conversion_right : undefined; - const mechanism = mechanismInputToDamlEnum( - details?.conversion_mechanism, - `convertibleIssuance.conversion_triggers[${index}].conversion_right.conversion_mechanism` - ); +function buildConvertibleRight(input: ConversionTriggerInput, index: number) { + const rightPath = `convertibleIssuance.conversion_triggers[${index}].conversion_right`; + const rawDetails = (input as unknown as Record).conversion_right; + if (!rawDetails || typeof rawDetails !== 'object' || Array.isArray(rawDetails)) { + throw new OcpValidationError(rightPath, 'conversion_right is required for each convertible conversion trigger', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: rawDetails, + }); + } + const rawDetailsRecord = rawDetails as Record; + if (rawDetailsRecord.type !== 'CONVERTIBLE_CONVERSION_RIGHT') { + throw new OcpValidationError(`${rightPath}.type`, 'Expected CONVERTIBLE_CONVERSION_RIGHT', { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + receivedValue: rawDetailsRecord.type, + }); + } + const details = rawDetailsRecord as unknown as ConvertibleConversionRightInput; + const mechanism = mechanismInputToDamlEnum(details.conversion_mechanism, `${rightPath}.conversion_mechanism`); const convertsToFutureRound = - details && typeof details.converts_to_future_round === 'boolean' ? details.converts_to_future_round : null; - const convertsToStockClassId = optionalString(details?.converts_to_stock_class_id); + typeof details.converts_to_future_round === 'boolean' ? details.converts_to_future_round : null; + const convertsToStockClassId = optionalString(details.converts_to_stock_class_id); return { type_: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: mechanism, @@ -441,23 +439,36 @@ function buildConvertibleRight(input: ConversionTriggerInput | undefined, index: }; } -function buildTriggerToDaml(t: ConversionTriggerInput, index: number, _issuanceId: string) { - const normalized = typeof t === 'string' ? normalizeTriggerType(t) : normalizeTriggerType(t.type); - const typeEnum = triggerTypeToDamlEnum(normalized); - if (typeof t !== 'object' || !t.trigger_id) { +function buildTriggerToDaml(t: ConversionTriggerInput, index: number) { + const triggerPath = `convertibleIssuance.conversion_triggers[${index}]`; + const rawTrigger: unknown = t; + if (!rawTrigger || typeof rawTrigger !== 'object' || Array.isArray(rawTrigger)) { + throw new OcpValidationError(triggerPath, 'Expected a conversion trigger object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: rawTrigger, + }); + } + const rawTriggerRecord = rawTrigger as Record; + if (typeof rawTriggerRecord.trigger_id !== 'string' || rawTriggerRecord.trigger_id.length === 0) { throw new OcpValidationError( - 'conversionTrigger.trigger_id', + `${triggerPath}.trigger_id`, 'trigger_id is required for each convertible conversion trigger', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: rawTriggerRecord.trigger_id, } ); } - const { trigger_id } = t; - const nickname = typeof t === 'object' && t.nickname ? t.nickname : null; - const trigger_description = typeof t === 'object' && t.trigger_description ? t.trigger_description : null; - const conversion_right = buildConvertibleRight(t, index); - const triggerFields = triggerFieldsToDaml(t, normalized, `convertibleIssuance.conversion_triggers[${index}]`); + const trigger = rawTriggerRecord as unknown as ConversionTriggerInput; + const normalized = normalizeTriggerType(trigger.type); + const typeEnum = triggerTypeToDamlEnum(normalized, `${triggerPath}.type`); + const { trigger_id } = trigger; + const nickname = optionalString(trigger.nickname); + const trigger_description = optionalString(trigger.trigger_description); + const conversion_right = buildConvertibleRight(trigger, index); + const triggerFields = triggerFieldsToDaml(trigger, triggerPath); return { type_: typeEnum, trigger_id, @@ -500,7 +511,7 @@ export function convertibleIssuanceDataToDaml(d: { security_law_exemptions: d.security_law_exemptions, investment_amount: monetaryToDaml(d.investment_amount), convertible_type: convertibleTypeToDaml(d.convertible_type), - conversion_triggers: d.conversion_triggers.map((t, idx) => buildTriggerToDaml(t, idx, d.id)), + conversion_triggers: d.conversion_triggers.map((t, idx) => buildTriggerToDaml(t, idx)), pro_rata: d.pro_rata != null ? normalizeNumericString(d.pro_rata) : null, seniority: d.seniority.toString(), comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 69f02e89..fd0b5477 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -4,6 +4,7 @@ import type { GetByContractIdParams } from '../../../types/common'; import type { CapitalizationDefinitionRules, ConversionTriggerType, + ConvertibleConversionTrigger, OcfConvertibleIssuance, } from '../../../types/native'; import { @@ -94,19 +95,6 @@ interface ConvertibleConversionRight { converts_to_stock_class_id?: string; } -interface ConversionTrigger { - type: ConversionTriggerType; - trigger_id: string; - conversion_right: ConvertibleConversionRight; - nickname?: string; - trigger_description?: string; - // Optional fields for specific trigger subtypes - trigger_date?: string; - trigger_condition?: string; - start_date?: string; - end_date?: string; -} - export type OcfConvertibleIssuanceEvent = OcfConvertibleIssuance; export interface GetConvertibleIssuanceAsOcfParams extends GetByContractIdParams {} @@ -122,7 +110,7 @@ const typeMap: Partial> OcfConvertibleSecurity: 'CONVERTIBLE_SECURITY', }; -const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): ConversionTrigger[] => { +const convertTriggers = (ts: unknown[] | undefined): ConvertibleConversionTrigger[] => { if (!Array.isArray(ts)) return []; const mapMechanism = (m: unknown, mechanismPath: string): ConvertibleConversionRight['conversion_mechanism'] => { @@ -533,22 +521,52 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers }; return ts.map((raw, idx) => { - const r = (raw ?? {}) as Record; - const tag = - typeof r.type_ === 'string' ? r.type_ : typeof r.tag === 'string' ? r.tag : typeof raw === 'string' ? raw : ''; - const type: ConversionTriggerType = mapDamlTriggerTypeToOcf(String(tag)); - const trigger_id: string = - typeof r.trigger_id === 'string' && r.trigger_id.length ? r.trigger_id : `${issuanceId}-trigger-${idx + 1}`; + const triggerPath = `convertibleIssuance.conversion_triggers[${idx}]`; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new OcpValidationError(triggerPath, 'Expected a conversion trigger object', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'non-null object', + receivedValue: raw, + }); + } + const r = raw as Record; + const hasCanonicalType = typeof r.type_ === 'string'; + const tag = hasCanonicalType ? r.type_ : typeof r.tag === 'string' ? r.tag : ''; + const type: ConversionTriggerType = mapDamlTriggerTypeToOcf( + String(tag), + `${triggerPath}.${hasCanonicalType ? 'type_' : typeof r.tag === 'string' ? 'tag' : 'type_'}` + ); + if (typeof r.trigger_id !== 'string' || r.trigger_id.length === 0) { + throw new OcpValidationError(`${triggerPath}.trigger_id`, 'A non-empty trigger_id is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: r.trigger_id, + }); + } + const { trigger_id } = r as { trigger_id: string }; const nickname: string | undefined = typeof r.nickname === 'string' && r.nickname.length ? r.nickname : undefined; const trigger_description: string | undefined = typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; - const triggerFields = triggerFieldsFromDaml(r, type, `convertibleIssuance.conversion_triggers[${idx}]`); - const mechanismPath = `convertibleIssuance.conversion_triggers[${idx}].conversion_right.conversion_mechanism`; + const triggerFields = triggerFieldsFromDaml(r, type, triggerPath); + const rightPath = `${triggerPath}.conversion_right`; + const mechanismPath = `${rightPath}.conversion_mechanism`; // Parse conversion_right if present and convertible variant is used let conversion_right: ConvertibleConversionRight | undefined; if (r.conversion_right && typeof r.conversion_right === 'object' && 'OcfRightConvertible' in r.conversion_right) { - const right = (r.conversion_right as Record).OcfRightConvertible as Record; + const rawRight = (r.conversion_right as Record).OcfRightConvertible; + if (!rawRight || typeof rawRight !== 'object' || Array.isArray(rawRight)) { + throw new OcpValidationError( + `${rightPath}.OcfRightConvertible`, + 'Expected a non-null convertible conversion-right value', + { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'non-null object', + receivedValue: rawRight, + } + ); + } + const right = rawRight as Record; conversion_right = { type: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: mapMechanism(right.conversion_mechanism, mechanismPath), @@ -582,13 +600,13 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers }; } if (!conversion_right) { - throw new OcpValidationError('conversionTrigger.conversion_right', 'Required field is missing', { + throw new OcpValidationError(rightPath, 'Required field is missing', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: r.conversion_right, }); } - const trigger: ConversionTrigger = { - type, + const trigger: ConvertibleConversionTrigger = { trigger_id, conversion_right, ...(nickname ? { nickname } : {}), @@ -688,7 +706,7 @@ export function damlConvertibleIssuanceDataToNative(d: Record): } return mapped; })(), - conversion_triggers: convertTriggers(d.conversion_triggers as unknown[], d.id), + conversion_triggers: convertTriggers(d.conversion_triggers as unknown[]), ...(typeof d.pro_rata === 'number' || typeof d.pro_rata === 'string' ? { pro_rata: normalizeNumericString(typeof d.pro_rata === 'number' ? d.pro_rata.toString() : d.pro_rata), diff --git a/src/functions/OpenCapTable/shared/triggerFields.ts b/src/functions/OpenCapTable/shared/triggerFields.ts index 5389c445..bfcaccf4 100644 --- a/src/functions/OpenCapTable/shared/triggerFields.ts +++ b/src/functions/OpenCapTable/shared/triggerFields.ts @@ -1,13 +1,12 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import type { + ConversionTriggerFieldShape, + ConversionTriggerFieldShapeFor, + ConversionTriggerType, +} from '../../../types/native'; import { damlTimeToDateString, dateStringToDAMLTime } from '../../../utils/typeConversions'; -export type OcfTriggerDiscriminator = - | 'AUTOMATIC_ON_CONDITION' - | 'AUTOMATIC_ON_DATE' - | 'ELECTIVE_AT_WILL' - | 'ELECTIVE_ON_CONDITION' - | 'ELECTIVE_IN_RANGE' - | 'UNSPECIFIED'; +export type OcfTriggerDiscriminator = ConversionTriggerType; type TriggerField = 'trigger_date' | 'trigger_condition' | 'start_date' | 'end_date'; @@ -25,12 +24,8 @@ export interface DamlTriggerFields { end_date: string | null; } -export interface NativeTriggerFields { - trigger_date?: string; - trigger_condition?: string; - start_date?: string; - end_date?: string; -} +export type NativeTriggerFields = + ConversionTriggerFieldShapeFor; const TRIGGER_FIELDS: readonly TriggerField[] = ['trigger_date', 'trigger_condition', 'start_date', 'end_date']; @@ -85,7 +80,7 @@ function rejectDamlFields( } function requiredCondition(value: unknown, path: string): string { - if (value === null || value === undefined) { + if (value === null || value === undefined || value === '') { throw new OcpValidationError(path, 'trigger_condition is required for condition-based triggers', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: value, @@ -101,32 +96,48 @@ function requiredCondition(value: unknown, path: string): string { return value; } +function requiredDateToDaml(value: unknown, dateFieldPath: string): string { + if (value === null || value === undefined) { + throw new OcpValidationError(dateFieldPath, 'Date is required for this trigger type', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: value, + }); + } + return dateStringToDAMLTime(value, dateFieldPath); +} + +function requiredDateFromDaml(value: unknown, dateFieldPath: string): string { + if (value === null || value === undefined) { + throw new OcpValidationError(dateFieldPath, 'Date is required for this trigger type', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: value, + }); + } + return damlTimeToDateString(value, dateFieldPath); +} + /** Validate an OCF trigger's complete discriminator-specific shape and encode it for DAML. */ -export function triggerFieldsToDaml( - input: TriggerFieldInput, - type: OcfTriggerDiscriminator, - basePath: string -): DamlTriggerFields { - switch (type) { +export function triggerFieldsToDaml(input: ConversionTriggerFieldShape, basePath: string): DamlTriggerFields { + switch (input.type) { case 'AUTOMATIC_ON_DATE': - rejectInputFields(input, ['trigger_condition', 'start_date', 'end_date'], type, basePath); + rejectInputFields(input, ['trigger_condition', 'start_date', 'end_date'], input.type, basePath); return { - trigger_date: dateStringToDAMLTime(input.trigger_date, fieldPath(basePath, 'trigger_date')), + trigger_date: requiredDateToDaml(input.trigger_date, fieldPath(basePath, 'trigger_date')), trigger_condition: null, start_date: null, end_date: null, }; case 'ELECTIVE_IN_RANGE': - rejectInputFields(input, ['trigger_date', 'trigger_condition'], type, basePath); + rejectInputFields(input, ['trigger_date', 'trigger_condition'], input.type, basePath); return { trigger_date: null, trigger_condition: null, - start_date: dateStringToDAMLTime(input.start_date, fieldPath(basePath, 'start_date')), - end_date: dateStringToDAMLTime(input.end_date, fieldPath(basePath, 'end_date')), + start_date: requiredDateToDaml(input.start_date, fieldPath(basePath, 'start_date')), + end_date: requiredDateToDaml(input.end_date, fieldPath(basePath, 'end_date')), }; case 'AUTOMATIC_ON_CONDITION': case 'ELECTIVE_ON_CONDITION': - rejectInputFields(input, ['trigger_date', 'start_date', 'end_date'], type, basePath); + rejectInputFields(input, ['trigger_date', 'start_date', 'end_date'], input.type, basePath); return { trigger_date: null, trigger_condition: requiredCondition(input.trigger_condition, fieldPath(basePath, 'trigger_condition')), @@ -135,12 +146,17 @@ export function triggerFieldsToDaml( }; case 'ELECTIVE_AT_WILL': case 'UNSPECIFIED': - rejectInputFields(input, TRIGGER_FIELDS, type, basePath); + rejectInputFields(input, TRIGGER_FIELDS, input.type, basePath); return { trigger_date: null, trigger_condition: null, start_date: null, end_date: null }; } } /** Validate a DAML trigger's complete discriminator-specific shape and decode it as OCF. */ +export function triggerFieldsFromDaml( + input: TriggerFieldInput, + type: Type, + basePath: string +): NativeTriggerFields; export function triggerFieldsFromDaml( input: TriggerFieldInput, type: OcfTriggerDiscriminator, @@ -149,22 +165,27 @@ export function triggerFieldsFromDaml( switch (type) { case 'AUTOMATIC_ON_DATE': rejectDamlFields(input, ['trigger_condition', 'start_date', 'end_date'], type, basePath); - return { trigger_date: damlTimeToDateString(input.trigger_date, fieldPath(basePath, 'trigger_date')) }; + return { + type, + trigger_date: requiredDateFromDaml(input.trigger_date, fieldPath(basePath, 'trigger_date')), + }; case 'ELECTIVE_IN_RANGE': rejectDamlFields(input, ['trigger_date', 'trigger_condition'], type, basePath); return { - start_date: damlTimeToDateString(input.start_date, fieldPath(basePath, 'start_date')), - end_date: damlTimeToDateString(input.end_date, fieldPath(basePath, 'end_date')), + type, + start_date: requiredDateFromDaml(input.start_date, fieldPath(basePath, 'start_date')), + end_date: requiredDateFromDaml(input.end_date, fieldPath(basePath, 'end_date')), }; case 'AUTOMATIC_ON_CONDITION': case 'ELECTIVE_ON_CONDITION': rejectDamlFields(input, ['trigger_date', 'start_date', 'end_date'], type, basePath); return { + type, trigger_condition: requiredCondition(input.trigger_condition, fieldPath(basePath, 'trigger_condition')), }; case 'ELECTIVE_AT_WILL': case 'UNSPECIFIED': rejectDamlFields(input, TRIGGER_FIELDS, type, basePath); - return {}; + return { type }; } } diff --git a/src/functions/OpenCapTable/shared/vesting.ts b/src/functions/OpenCapTable/shared/vesting.ts index 0700ba27..a81e2750 100644 --- a/src/functions/OpenCapTable/shared/vesting.ts +++ b/src/functions/OpenCapTable/shared/vesting.ts @@ -1,3 +1,4 @@ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import { dateStringToDAMLTime, normalizeNumericString } from '../../../utils/typeConversions'; interface VestingInput { @@ -10,21 +11,25 @@ interface DamlVesting { amount: string; } -/** Filter zero-value vestings while retaining original indexes for validation paths. */ +/** Validate every vesting row, then filter zero-value placeholders while retaining original indexes. */ export function filterAndMapVestingsToDaml( vestings: readonly VestingInput[] | null | undefined, basePath: string ): DamlVesting[] { - const filteredVestings = (vestings ?? []) - .map((vesting, index) => ({ index, vesting })) - .filter(({ vesting }) => { - // Preserve the converter boundary: malformed amounts fail before filtering. - const normalized = normalizeNumericString(vesting.amount); - return parseFloat(normalized) > 0; - }); + return (vestings ?? []) + .map((vesting, index) => { + const amountPath = `${basePath}[${index}].amount`; + const date = dateStringToDAMLTime(vesting.date, `${basePath}[${index}].date`); + const amount = normalizeNumericString(vesting.amount, amountPath); - return filteredVestings.map(({ index, vesting }) => ({ - date: dateStringToDAMLTime(vesting.date, `${basePath}[${index}].date`), - amount: normalizeNumericString(vesting.amount), - })); + if (Number(amount) < 0) { + throw new OcpValidationError(amountPath, 'Vesting amount must not be negative', { + code: OcpErrorCodes.OUT_OF_RANGE, + receivedValue: vesting.amount, + }); + } + + return { date, amount }; + }) + .filter(({ amount }) => Number(amount) !== 0); } diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 5d7ee10c..37908313 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -1,7 +1,7 @@ import type { ConversionMechanism, ConversionMechanismObject, - ConversionTrigger, + ConversionTriggerType, OcfStockClass, StockClassConversionRight, } from '../../../types'; @@ -16,7 +16,7 @@ import { optionalString, } from '../../../utils/typeConversions'; -function triggerTypeToDamlEnum(t: ConversionTrigger): string { +function triggerTypeToDamlEnum(t: ConversionTriggerType): string { switch (t) { case 'AUTOMATIC_ON_CONDITION': return 'OcfTriggerTypeTypeAutomaticOnCondition'; diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 0ad9b5d2..fd220f5b 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -1,6 +1,11 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; -import type { CapitalizationDefinitionRules, Monetary } from '../../../types'; +import type { + CapitalizationDefinitionRules, + ConversionTriggerFor, + ConversionTriggerType, + Monetary, +} from '../../../types'; import { cleanComments, dateStringToDAMLTime, @@ -10,19 +15,14 @@ import { optionalString, } from '../../../utils/typeConversions'; import { triggerFieldsToDaml } from '../shared/triggerFields'; +import { filterAndMapVestingsToDaml } from '../shared/vesting'; export interface SimpleVesting { date: string; amount: string; } -export type WarrantTriggerTypeInput = - | 'AUTOMATIC_ON_CONDITION' - | 'AUTOMATIC_ON_DATE' - | 'ELECTIVE_AT_WILL' - | 'ELECTIVE_ON_CONDITION' - | 'ELECTIVE_IN_RANGE' - | 'UNSPECIFIED'; +export type WarrantTriggerTypeInput = ConversionTriggerType; export type WarrantConversionMechanismInput = | { type: 'CUSTOM_CONVERSION'; custom_conversion_description: string } @@ -74,25 +74,16 @@ export interface StockClassConversionRightInput { export type WarrantConversionRightKindInput = WarrantConversionRightInput | StockClassConversionRightInput; -/** Object-shaped exercise trigger row (OCF schema); bare trigger-type strings are not accepted for issuance. */ -export interface WarrantExerciseTriggerInput { - type: WarrantTriggerTypeInput; - trigger_id?: string; - nickname?: string; - trigger_description?: string; - trigger_date?: string; // YYYY-MM-DD or ISO datetime - trigger_condition?: string; - start_date?: string; // YYYY-MM-DD or ISO datetime (ELECTIVE_IN_RANGE) - end_date?: string; // YYYY-MM-DD or ISO datetime (ELECTIVE_IN_RANGE) - conversion_right?: WarrantConversionRightKindInput; -} +/** Exact object-shaped exercise-trigger row from the OCF schema. */ +export type WarrantExerciseTriggerInput = ConversionTriggerFor; function normalizeTriggerType(t: WarrantTriggerTypeInput): WarrantTriggerTypeInput { return t; } function triggerTypeToDamlEnum( - t: WarrantTriggerTypeInput + t: WarrantTriggerTypeInput, + fieldPath: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTriggerType { switch (t) { case 'AUTOMATIC_ON_DATE': @@ -110,7 +101,7 @@ function triggerTypeToDamlEnum( default: { const exhaustiveCheck: never = t; throw new OcpParseError(`Unknown warrant trigger type: ${exhaustiveCheck as string}`, { - source: 'warrantTrigger.type', + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -210,8 +201,9 @@ function warrantNestedConversionTrigger( index: number ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { const normalized = normalizeTriggerType(t.type); - const typeEnum = triggerTypeToDamlEnum(normalized); - const triggerFields = triggerFieldsToDaml(t, normalized, `warrantIssuance.exercise_triggers[${index}]`); + const triggerPath = `warrantIssuance.exercise_triggers[${index}]`; + const typeEnum = triggerTypeToDamlEnum(normalized, `${triggerPath}.type`); + const triggerFields = triggerFieldsToDaml(t, triggerPath); return { type_: typeEnum, trigger_id: t.trigger_id, @@ -295,40 +287,32 @@ function buildWarrantStockClassConversionRight( } function buildWarrantRight( - exerciseTrigger: WarrantExerciseTriggerInput | undefined, + exerciseTrigger: unknown, index: number ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { - if (!exerciseTrigger || typeof exerciseTrigger !== 'object') { + const triggerPath = `warrantIssuance.exercise_triggers[${index}]`; + if (!exerciseTrigger || typeof exerciseTrigger !== 'object' || Array.isArray(exerciseTrigger)) { throw new OcpValidationError( - 'warrantTrigger.conversion_right', + `${triggerPath}.conversion_right`, 'conversion_right is required for each warrant exercise trigger', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } + { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: exerciseTrigger } ); } - const cr = exerciseTrigger.conversion_right; - if (!cr) { + const trigger = exerciseTrigger as WarrantExerciseTriggerInput; + const rawRight = (exerciseTrigger as Record).conversion_right; + if (!rawRight || typeof rawRight !== 'object' || Array.isArray(rawRight)) { throw new OcpValidationError( - 'warrantTrigger.conversion_right', + `${triggerPath}.conversion_right`, 'conversion_right is required for each warrant exercise trigger', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } + { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: rawRight } ); } + const cr = rawRight as WarrantConversionRightKindInput; switch (cr.type) { case 'STOCK_CLASS_CONVERSION_RIGHT': { - if (!exerciseTrigger.trigger_id) { - throw new OcpValidationError( - 'warrantTrigger.trigger_id', - 'trigger_id is required for STOCK_CLASS_CONVERSION_RIGHT triggers', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - } - return buildWarrantStockClassConversionRight( - exerciseTrigger as WarrantExerciseTriggerObject & { trigger_id: string }, - cr, - index - ); + return buildWarrantStockClassConversionRight(trigger, cr, index); } case 'WARRANT_CONVERSION_RIGHT': { const mechanism = warrantMechanismToDamlVariant(cr.conversion_mechanism); @@ -348,7 +332,7 @@ function buildWarrantRight( default: { const _exhaustive: never = cr; throw new OcpParseError(`Unknown conversion_right.type: "${(_exhaustive as { type: string }).type}"`, { - source: 'conversion_right.type', + source: `${triggerPath}.conversion_right.type`, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -390,23 +374,38 @@ function quantitySourceToDamlEnum( } } -function buildWarrantTrigger(t: WarrantExerciseTriggerInput, index: number, _ocfId: string) { - const normalized = normalizeTriggerType(t.type); - const typeEnum = triggerTypeToDamlEnum(normalized); - if (!t.trigger_id) { +function buildWarrantTrigger(t: WarrantExerciseTriggerInput, index: number) { + const triggerPath = `warrantIssuance.exercise_triggers[${index}]`; + const rawTrigger: unknown = t; + if (!rawTrigger || typeof rawTrigger !== 'object' || Array.isArray(rawTrigger)) { + throw new OcpValidationError(triggerPath, 'Expected an exercise trigger object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: rawTrigger, + }); + } + const rawTriggerRecord = rawTrigger as Record; + if (typeof rawTriggerRecord.trigger_id !== 'string' || rawTriggerRecord.trigger_id.length === 0) { throw new OcpValidationError( - 'warrantTrigger.trigger_id', + `${triggerPath}.trigger_id`, 'trigger_id is required for each warrant exercise trigger', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } + { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: rawTriggerRecord.trigger_id, + } ); } - const conversion_right = buildWarrantRight(t, index); - const triggerFields = triggerFieldsToDaml(t, normalized, `warrantIssuance.exercise_triggers[${index}]`); + const trigger = rawTriggerRecord as unknown as WarrantExerciseTriggerInput; + const normalized = normalizeTriggerType(trigger.type); + const typeEnum = triggerTypeToDamlEnum(normalized, `${triggerPath}.type`); + const conversion_right = buildWarrantRight(trigger, index); + const triggerFields = triggerFieldsToDaml(trigger, triggerPath); return { type_: typeEnum, - trigger_id: t.trigger_id, - nickname: typeof t.nickname === 'string' ? t.nickname : null, - trigger_description: typeof t.trigger_description === 'string' ? t.trigger_description : null, + trigger_id: trigger.trigger_id, + nickname: typeof trigger.nickname === 'string' ? trigger.nickname : null, + trigger_description: typeof trigger.trigger_description === 'string' ? trigger.trigger_description : null, conversion_right, ...triggerFields, }; @@ -464,23 +463,13 @@ export function warrantIssuanceDataToDaml(d: { quantity_source: quantitySourceDaml ?? null, exercise_price: d.exercise_price ? monetaryToDaml(d.exercise_price) : null, purchase_price: monetaryToDaml(d.purchase_price), - exercise_triggers: d.exercise_triggers.map((t, idx) => buildWarrantTrigger(t, idx, d.id)), + exercise_triggers: d.exercise_triggers.map((t, idx) => buildWarrantTrigger(t, idx)), warrant_expiration_date: optionalDateStringToDAMLTime( d.warrant_expiration_date, 'warrantIssuance.warrant_expiration_date' ), vesting_terms_id: optionalString(d.vesting_terms_id), - vestings: (d.vestings ?? []) - .map((vesting, index) => ({ index, vesting })) - .filter(({ vesting }) => { - // normalizeNumericString validates strict decimal format and rejects scientific notation - const normalized = normalizeNumericString(vesting.amount); - return parseFloat(normalized) > 0; - }) - .map(({ index, vesting }) => ({ - date: dateStringToDAMLTime(vesting.date, `warrantIssuance.vestings[${index}].date`), - amount: normalizeNumericString(vesting.amount), - })), + vestings: filterAndMapVestingsToDaml(d.vestings, 'warrantIssuance.vestings'), comments: cleanComments(d.comments), }; } diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 14a89ee5..1c033a4f 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -276,10 +276,17 @@ function mapStockClassWarrantRightFromDaml(value: Record): Warr return out; } -function mapAnyConversionRightFromDaml(r: unknown): WarrantTriggerConversionRight { - if (!r || typeof r !== 'object') { - throw new OcpValidationError('warrantRight', 'Invalid warrant conversion_right', { - code: OcpErrorCodes.INVALID_TYPE, +function mapAnyConversionRightFromDaml(r: unknown, fieldPath: string): WarrantTriggerConversionRight { + if (r === null || r === undefined) { + throw new OcpValidationError(fieldPath, 'A conversion_right is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'object with tag and value', + receivedValue: r, + }); + } + if (typeof r !== 'object' || Array.isArray(r)) { + throw new OcpValidationError(fieldPath, 'Invalid warrant conversion_right', { + code: OcpErrorCodes.SCHEMA_MISMATCH, expectedType: 'object with tag and value', receivedValue: r, }); @@ -291,7 +298,7 @@ function mapAnyConversionRightFromDaml(r: unknown): WarrantTriggerConversionRigh const value = typeof inner === 'object' && inner !== null ? (inner as Record) : null; if (!tag || !value) { - throw new OcpValidationError('warrantRight', 'Invalid warrant conversion_right: missing tag/value', { + throw new OcpValidationError(fieldPath, 'Invalid warrant conversion_right: missing tag/value', { code: OcpErrorCodes.INVALID_TYPE, receivedValue: r, }); @@ -305,7 +312,7 @@ function mapAnyConversionRightFromDaml(r: unknown): WarrantTriggerConversionRigh } throw new OcpParseError(`Unknown warrant conversion_right tag: "${tag}"`, { - source: 'conversion_right.tag', + source: `${fieldPath}.tag`, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -331,30 +338,41 @@ function mapQuantitySource(qs: unknown): OcfWarrantIssuance['quantity_source'] | export function damlWarrantIssuanceDataToNative(d: Record): OcfWarrantIssuance { const exercise_triggers: WarrantExerciseTrigger[] = Array.isArray(d.exercise_triggers) ? (d.exercise_triggers as unknown[]).map((raw: unknown, idx: number) => { - const r = (raw ?? {}) as Record; - const tag = - typeof r.type_ === 'string' - ? r.type_ - : typeof r.tag === 'string' - ? r.tag - : typeof raw === 'string' - ? raw - : ''; - const type: ConversionTriggerType = mapDamlTriggerTypeToOcf(tag); - const trigger_id: string = - typeof r.trigger_id === 'string' && r.trigger_id.length - ? r.trigger_id - : `${typeof d.id === 'string' ? d.id : ''}-warrant-trigger-${idx + 1}`; + const triggerPath = `warrantIssuance.exercise_triggers[${idx}]`; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new OcpValidationError(triggerPath, 'Expected an exercise trigger object', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'non-null object', + receivedValue: raw, + }); + } + const r = raw as Record; + const hasCanonicalType = typeof r.type_ === 'string'; + const tag = hasCanonicalType ? r.type_ : typeof r.tag === 'string' ? r.tag : ''; + const type: ConversionTriggerType = mapDamlTriggerTypeToOcf( + String(tag), + `${triggerPath}.${hasCanonicalType ? 'type_' : typeof r.tag === 'string' ? 'tag' : 'type_'}` + ); + if (typeof r.trigger_id !== 'string' || r.trigger_id.length === 0) { + throw new OcpValidationError(`${triggerPath}.trigger_id`, 'A non-empty trigger_id is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: r.trigger_id, + }); + } + const { trigger_id } = r as { trigger_id: string }; const nickname: string | undefined = typeof r.nickname === 'string' && r.nickname.length ? r.nickname : undefined; const trigger_description: string | undefined = typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; - const triggerFields = triggerFieldsFromDaml(r, type, `warrantIssuance.exercise_triggers[${idx}]`); + const triggerFields = triggerFieldsFromDaml(r, type, triggerPath); - const conversion_right: WarrantTriggerConversionRight = mapAnyConversionRightFromDaml(r.conversion_right); + const conversion_right: WarrantTriggerConversionRight = mapAnyConversionRightFromDaml( + r.conversion_right, + `${triggerPath}.conversion_right` + ); const t: WarrantExerciseTrigger = { - type, trigger_id, conversion_right, ...(nickname ? { nickname } : {}), diff --git a/src/types/native.ts b/src/types/native.ts index edce2b95..068ae981 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -90,10 +90,64 @@ export type ConversionTriggerType = | 'ELECTIVE_AT_WILL' | 'UNSPECIFIED'; +/** Fields shared by every OCF conversion-trigger variant. */ +export interface ConversionTriggerBase { + /** Unique identifier for this trigger within its parent issuance. */ + trigger_id: string; + /** Conversion right applied when this trigger fires. */ + conversion_right: ConversionRight; + /** Human-readable nickname for the trigger. */ + nickname?: string; + /** Long-form description of the trigger. */ + trigger_description?: string; +} + /** - * @deprecated Use ConversionTriggerType instead. Alias kept for backward compatibility. - */ -export type ConversionTrigger = ConversionTriggerType; + * Exact discriminator-specific fields for an OCF conversion trigger. + * + * Forbidden fields use `never` so object variables, not only fresh literals, + * cannot mix fields from different trigger variants. + */ +export type ConversionTriggerFieldShapeFor = Type extends 'AUTOMATIC_ON_DATE' + ? { + type: Type; + trigger_date: string; + trigger_condition?: never; + start_date?: never; + end_date?: never; + } + : Type extends 'AUTOMATIC_ON_CONDITION' | 'ELECTIVE_ON_CONDITION' + ? { + type: Type; + trigger_condition: string; + trigger_date?: never; + start_date?: never; + end_date?: never; + } + : Type extends 'ELECTIVE_IN_RANGE' + ? { + type: Type; + start_date: string; + end_date: string; + trigger_date?: never; + trigger_condition?: never; + } + : Type extends 'ELECTIVE_AT_WILL' | 'UNSPECIFIED' + ? { + type: Type; + trigger_date?: never; + trigger_condition?: never; + start_date?: never; + end_date?: never; + } + : never; + +/** Union of every exact discriminator-specific trigger field shape. */ +export type ConversionTriggerFieldShape = ConversionTriggerFieldShapeFor; + +/** Exact OCF conversion-trigger union parameterized by its conversion-right type. */ +export type ConversionTriggerFor = ConversionTriggerBase & + ConversionTriggerFieldShape; // ===== Capitalization Definition Rules ===== @@ -203,27 +257,8 @@ export interface WarrantStockClassConversionRight { /** Union — warrant exercise triggers may carry either variant per OCF {@code ConversionTrigger} schema */ export type WarrantTriggerConversionRight = WarrantConversionRight | WarrantStockClassConversionRight; -/** Warrant Exercise Trigger Describes when and how a warrant can be exercised */ -export interface WarrantExerciseTrigger { - /** Type of trigger */ - type: ConversionTriggerType; - /** Unique identifier for this trigger */ - trigger_id: string; - /** Conversion right associated with this trigger */ - conversion_right: WarrantTriggerConversionRight; - /** Human-readable nickname for the trigger */ - nickname?: string; - /** Description of trigger conditions */ - trigger_description?: string; - /** Date when trigger becomes active (YYYY-MM-DD) */ - trigger_date?: string; - /** Condition that activates the trigger */ - trigger_condition?: string; - /** Start date of the trigger's validity window (YYYY-MM-DD) — used by ELECTIVE_IN_RANGE triggers */ - start_date?: string; - /** End date of the trigger's validity window (YYYY-MM-DD) — used by ELECTIVE_IN_RANGE triggers */ - end_date?: string; -} +/** Warrant Exercise Trigger Describes exactly when and how a warrant can be exercised. */ +export type WarrantExerciseTrigger = ConversionTriggerFor; // ===== Convertible Conversion Mechanism Types ===== @@ -361,27 +396,8 @@ export interface ConvertibleConversionRight { converts_to_stock_class_id?: string; } -/** Convertible Conversion Trigger Describes when and how a convertible instrument can convert */ -export interface ConvertibleConversionTrigger { - /** Type of trigger */ - type: ConversionTriggerType; - /** Unique identifier for this trigger */ - trigger_id: string; - /** Conversion right associated with this trigger */ - conversion_right: ConvertibleConversionRight; - /** Human-readable nickname for the trigger */ - nickname?: string; - /** Description of trigger conditions */ - trigger_description?: string; - /** Date when trigger becomes active (YYYY-MM-DD) */ - trigger_date?: string; - /** Condition that activates the trigger */ - trigger_condition?: string; - /** Start date of the trigger's validity window (YYYY-MM-DD) — used by ELECTIVE_IN_RANGE triggers */ - start_date?: string; - /** End date of the trigger's validity window (YYYY-MM-DD) — used by ELECTIVE_IN_RANGE triggers */ - end_date?: string; -} +/** Exact trigger union describing when and how a convertible instrument can convert. */ +export type ConvertibleConversionTrigger = ConversionTriggerFor; /** * Enum - Rounding Type Rounding method for numeric values OCF: @@ -475,7 +491,7 @@ export interface StockClassConversionRight { // ----- DAML-internal passthrough fields (not in OCF output) ----- /** @internal DAML passthrough — trigger that would cause conversion */ - conversion_trigger?: ConversionTrigger; + conversion_trigger?: ConversionTriggerType; /** @internal DAML passthrough — ratio numerator for RATIO_CONVERSION */ ratio_numerator?: string; /** @internal DAML passthrough — ratio denominator for RATIO_CONVERSION */ diff --git a/src/utils/cantonOcfExtractor.ts b/src/utils/cantonOcfExtractor.ts index 7f6f9d50..16d48dc5 100644 --- a/src/utils/cantonOcfExtractor.ts +++ b/src/utils/cantonOcfExtractor.ts @@ -39,6 +39,7 @@ import { createDiagnosedContractReadError, } from './contractReadDiagnostics'; import { ledgerReadScope } from './readScope'; +import { tryIsoDateToDateString } from './typeConversions'; // ===== Transaction Sorting ===== // These utilities ensure Canton transactions are sorted consistently with DB data. @@ -52,7 +53,8 @@ import { ledgerReadScope } from './readScope'; export function getTimestampOrNull(input: unknown): number | null { if (input == null) return null; if (typeof input === 'number') { - return Number.isNaN(input) ? null : input; + if (!Number.isFinite(input)) return null; + return Number.isNaN(new Date(input).getTime()) ? null : input; } if (typeof input === 'string') { const ms = new Date(input).getTime(); @@ -179,6 +181,13 @@ export function txWeight(tx: Record): number { } } +const SORT_ERROR_VALUE_LIMIT = 96; + +function boundedSortErrorValue(value: string): string { + const rendered = JSON.stringify(value); + return rendered.length <= SORT_ERROR_VALUE_LIMIT ? rendered : `${rendered.slice(0, SORT_ERROR_VALUE_LIMIT - 3)}...`; +} + /** * Build a composite sort key for deterministic same-day ordering. * @@ -192,17 +201,29 @@ export function txWeight(tx: Record): number { * @throws OcpValidationError if tx.date is missing or invalid - fail fast on malformed records */ export function buildTransactionSortKey(tx: Record): string { - const dateMs = getTimestampOrNull(tx.date); - if (dateMs === null) { - const txId = typeof tx.id === 'string' ? tx.id : 'unknown'; - const txType = typeof tx.object_type === 'string' ? tx.object_type : 'unknown'; + const day = tryIsoDateToDateString(tx.date); + if (day === null) { + const txId = boundedSortErrorValue(typeof tx.id === 'string' ? tx.id : 'unknown'); + const txType = boundedSortErrorValue(typeof tx.object_type === 'string' ? tx.object_type : 'unknown'); + const isMissing = tx.date === null || tx.date === undefined; + const isInvalidType = !isMissing && typeof tx.date !== 'string'; + const code = isMissing + ? OcpErrorCodes.REQUIRED_FIELD_MISSING + : isInvalidType + ? OcpErrorCodes.INVALID_TYPE + : OcpErrorCodes.INVALID_FORMAT; + const reason = isMissing ? 'missing' : isInvalidType ? 'not a string' : 'invalid'; + const renderedDate = typeof tx.date === 'string' ? boundedSortErrorValue(tx.date) : `<${typeof tx.date}>`; throw new OcpValidationError( 'tx.date', - `Transaction has missing or invalid date - id: ${txId}, object_type: ${txType}, date: ${JSON.stringify(tx.date)}`, - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: tx.date } + `Transaction has ${reason} date - id: ${txId}, object_type: ${txType}, date: ${renderedDate}`, + { + code, + expectedType: 'YYYY-MM-DD or RFC 3339 date-time string with Z or numeric offset', + receivedValue: tx.date, + } ); } - const day = new Date(dateMs).toISOString().slice(0, 10); const weight = txWeight(tx).toString().padStart(3, '0'); const group = typeof tx.security_id === 'string' ? tx.security_id : '_no_security_'; diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index c03c6733..556c8266 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -225,9 +225,10 @@ export function safeString(value: unknown, fieldPath = 'safeString'): string { * to the standardized OCF enum values. * * @param tag - The DAML trigger type tag (e.g., 'OcfTriggerTypeTypeAutomaticOnDate') + * @param source - Contextual source path used when the tag is unknown * @returns The corresponding OCF ConversionTriggerType enum value */ -export function mapDamlTriggerTypeToOcf(tag: string): ConversionTriggerType { +export function mapDamlTriggerTypeToOcf(tag: string, source = 'triggerType.tag'): ConversionTriggerType { if (tag === 'OcfTriggerTypeTypeAutomaticOnDate') return 'AUTOMATIC_ON_DATE'; if (tag === 'OcfTriggerTypeTypeAutomaticOnCondition') return 'AUTOMATIC_ON_CONDITION'; if (tag === 'OcfTriggerTypeTypeElectiveInRange') return 'ELECTIVE_IN_RANGE'; @@ -235,7 +236,7 @@ export function mapDamlTriggerTypeToOcf(tag: string): ConversionTriggerType { if (tag === 'OcfTriggerTypeTypeElectiveAtWill') return 'ELECTIVE_AT_WILL'; if (tag === 'OcfTriggerTypeTypeUnspecified') return 'UNSPECIFIED'; throw new OcpParseError(`Unknown trigger type tag: ${tag}`, { - source: 'triggerType.tag', + source, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 94468cd4..10ffb92e 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -207,14 +207,64 @@ describe('ConvertibleConversionMechanismInput bare string handling', () => { }); describe('write-side conversion mechanism paths', () => { + it('rejects a bare trigger discriminator at the writer boundary', () => { + const error = captureError(() => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: ['AUTOMATIC_ON_DATE'] as unknown as Parameters< + typeof convertibleIssuanceDataToDaml + >[0]['conversion_triggers'], + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'convertibleIssuance.conversion_triggers[0]', + receivedValue: 'AUTOMATIC_ON_DATE', + }); + }); + + it('requires a caller-provided trigger_id instead of synthesizing one', () => { + const error = captureError(() => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [{ ...SAFE_TRIGGER_BASE, trigger_id: '' }], + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'convertibleIssuance.conversion_triggers[0].trigger_id', + receivedValue: '', + }); + }); + + it('rejects a truthy non-string writer trigger_id', () => { + const error = captureError(() => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [{ ...SAFE_TRIGGER_BASE, trigger_id: 42 }] as unknown as Parameters< + typeof convertibleIssuanceDataToDaml + >[0]['conversion_triggers'], + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'convertibleIssuance.conversion_triggers[0].trigger_id', + expectedType: 'non-empty string', + receivedValue: 42, + }); + }); + it('reports the exact trigger index for a malformed nested numeric field', () => { const invalidTrigger = { ...SAFE_TRIGGER_BASE, trigger_id: 'trigger-002', conversion_right: { - type: 'CONVERTIBLE_CONVERSION_RIGHT', + type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, conversion_mechanism: { - type: 'FIXED_AMOUNT_CONVERSION', + type: 'FIXED_AMOUNT_CONVERSION' as const, converts_to_quantity: '1e2', }, }, @@ -317,6 +367,78 @@ function buildDamlNoteTrigger(dayCount: string, interestPayout: string, triggerI } describe('read-side conversion mechanism paths', () => { + it('requires a ledger trigger_id instead of synthesizing one', () => { + const { trigger_id: _triggerId, ...triggerWithoutId } = buildDamlSafeTrigger(); + const error = captureError(() => + damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [triggerWithoutId] }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'convertibleIssuance.conversion_triggers[0].trigger_id', + receivedValue: undefined, + }); + }); + + it('rejects a bare trigger discriminator read from the ledger', () => { + const error = captureError(() => + damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: ['AUTOMATIC_ON_DATE'] }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleIssuance.conversion_triggers[0]', + receivedValue: 'AUTOMATIC_ON_DATE', + }); + }); + + it('reports the indexed canonical field for an unknown trigger discriminator', () => { + const error = captureError(() => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [{ ...buildDamlSafeTrigger(), type_: 'OcfTriggerTypeTypeUnknown' }], + }) + ); + + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'convertibleIssuance.conversion_triggers[0].type_', + }); + }); + + it('reports the exact path for a missing conversion_right', () => { + const { conversion_right: _conversionRight, ...triggerWithoutRight } = buildDamlSafeTrigger(); + const error = captureError(() => + damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [triggerWithoutRight] }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right', + receivedValue: undefined, + }); + }); + + test.each([null, 'not-an-object', 42, []])( + 'rejects malformed wrapped convertible conversion-right value %p', + (value) => { + const trigger = { + ...buildDamlSafeTrigger(), + conversion_right: { OcfRightConvertible: value }, + }; + const error = captureError(() => + damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [trigger] }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.OcfRightConvertible', + receivedValue: value, + }); + } + ); + it('reports the exact trigger index for a malformed nested field', () => { const invalidTrigger = { ...buildDamlSafeTrigger(), @@ -597,7 +719,7 @@ describe('convertible issuance approval-date read boundaries', () => { }), 'convertibleIssuance.conversion_triggers[0].trigger_date', null, - OcpErrorCodes.INVALID_TYPE + OcpErrorCodes.REQUIRED_FIELD_MISSING ); }); @@ -792,7 +914,9 @@ describe('convertible issuance write date boundaries', () => { () => convertibleIssuanceDataToDaml({ ...BASE_INPUT, - conversion_triggers: [{ ...SAFE_TRIGGER_BASE, trigger_date: '2024-01-15' }], + conversion_triggers: [ + { ...SAFE_TRIGGER_BASE, trigger_date: '2024-01-15' } as unknown as typeof SAFE_TRIGGER_BASE, + ], }), 'convertibleIssuance.conversion_triggers[0].trigger_date', '2024-01-15', diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index 91dcd42f..22f3278a 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -376,6 +376,18 @@ describe('OCF write converter optional date boundaries', () => { ); }); + test('validates an equity-compensation vesting date before filtering its zero amount', () => { + expectInvalidDate( + () => + equityCompensationIssuanceDataToDaml({ + ...EQUITY_COMPENSATION_WRITE_BASE, + vestings: [{ date: 'not-a-date', amount: '0' }], + }), + 'equityCompensationIssuance.vestings[0].date', + 'not-a-date' + ); + }); + test('reports original array indexes for nested issuance and stock-class dates', () => { expectInvalidDate( () => diff --git a/test/converters/planSecurityConverters.test.ts b/test/converters/planSecurityConverters.test.ts index d26f119d..16c407bf 100644 --- a/test/converters/planSecurityConverters.test.ts +++ b/test/converters/planSecurityConverters.test.ts @@ -116,7 +116,7 @@ describe('PlanSecurity Type Converters', () => { ]); }); - it('reports the original vesting index after zero-amount entries are filtered', () => { + it('validates zero-amount vesting dates before filtering and preserves the original index', () => { const input: OcfPlanSecurityIssuance = { object_type: 'TX_PLAN_SECURITY_ISSUANCE', id: 'psi-indexed-date', @@ -127,8 +127,8 @@ describe('PlanSecurity Type Converters', () => { compensation_type: 'OPTION', quantity: '100', vestings: [ + { date: '2025-06-01', amount: '0' }, { date: '', amount: '0' }, - { date: '', amount: '1' }, ], expiration_date: null, termination_exercise_windows: [], @@ -144,6 +144,31 @@ describe('PlanSecurity Type Converters', () => { ); }); + it('rejects a negative vesting amount instead of silently filtering it', () => { + const input: OcfPlanSecurityIssuance = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', + id: 'psi-negative-vesting', + date: '2025-01-15', + security_id: 'sec-negative-vesting', + custom_id: 'custom-negative-vesting', + stakeholder_id: 'stakeholder-negative-vesting', + compensation_type: 'OPTION', + quantity: '100', + vestings: [{ date: '2025-06-01', amount: '-1' }], + expiration_date: null, + termination_exercise_windows: [], + security_law_exemptions: [], + }; + + expect(() => planSecurityIssuanceDataToDaml(input)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'planSecurityIssuance.vestings[0].amount', + receivedValue: '-1', + }) + ); + }); + it.each([ ['undefined', undefined, OcpErrorCodes.INVALID_TYPE], ['empty', '', OcpErrorCodes.INVALID_FORMAT], diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 6e673e5d..8dd049d4 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -9,6 +9,7 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { warrantIssuanceDataToDaml, + type WarrantExerciseTriggerInput, type WarrantTriggerTypeInput, } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; @@ -23,6 +24,15 @@ function roundTrip(ocfInput: Parameters[0]): R return { ...native, object_type: 'TX_WARRANT_ISSUANCE' }; } +function captureError(action: () => unknown): unknown { + try { + action(); + } catch (error) { + return error; + } + throw new Error('Expected warrant issuance conversion to fail'); +} + describe('WarrantIssuance round-trip equivalence', () => { const baseWarrantIssuance = { id: '4afe6226-a717-4596-8bcc-fa3c22b154de', @@ -53,7 +63,7 @@ describe('WarrantIssuance round-trip equivalence', () => { object_type: 'TX_WARRANT_ISSUANCE' as const, }; - function stockClassTrigger(overrides: Record = {}) { + function stockClassTrigger(overrides: Record = {}): WarrantExerciseTriggerInput { const triggerType = (overrides.type ?? 'AUTOMATIC_ON_CONDITION') as WarrantTriggerTypeInput; const trigger = { trigger_id: 'w_stock_ratio', @@ -70,9 +80,11 @@ describe('WarrantIssuance round-trip equivalence', () => { ...overrides, type: triggerType, }; - return triggerType === 'AUTOMATIC_ON_CONDITION' || triggerType === 'ELECTIVE_ON_CONDITION' - ? { trigger_condition: 'X', ...trigger } - : trigger; + return ( + triggerType === 'AUTOMATIC_ON_CONDITION' || triggerType === 'ELECTIVE_ON_CONDITION' + ? { trigger_condition: 'X', ...trigger } + : trigger + ) as WarrantExerciseTriggerInput; } test('basic warrant issuance survives round-trip', () => { @@ -82,6 +94,56 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(ocfDeepEqual(dbData, cantonData)).toBe(true); }); + test('requires a caller-provided exercise trigger_id', () => { + const error = captureError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], trigger_id: '' }], + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'warrantIssuance.exercise_triggers[0].trigger_id', + receivedValue: '', + }); + }); + + test('rejects a truthy non-string writer exercise trigger_id', () => { + const error = captureError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], trigger_id: 42 }] as unknown as Parameters< + typeof warrantIssuanceDataToDaml + >[0]['exercise_triggers'], + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'warrantIssuance.exercise_triggers[0].trigger_id', + expectedType: 'non-empty string', + receivedValue: 42, + }); + }); + + test('rejects a bare exercise-trigger discriminator at the writer boundary', () => { + const error = captureError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: ['AUTOMATIC_ON_DATE'] as unknown as Parameters< + typeof warrantIssuanceDataToDaml + >[0]['exercise_triggers'], + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'warrantIssuance.exercise_triggers[0]', + receivedValue: 'AUTOMATIC_ON_DATE', + }); + }); + test('warrant issuance with numeric amount as JS number survives round-trip', () => { // DB JSONB can store amount as a number instead of a string const dbData = { @@ -247,6 +309,63 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(result.exercise_triggers[0]).not.toHaveProperty('end_date'); }); + it('requires a ledger exercise trigger_id instead of synthesizing one', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const { trigger_id: _triggerId, ...triggerWithoutId } = daml.exercise_triggers[0]; + const error = captureError(() => + damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [triggerWithoutId] }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'warrantIssuance.exercise_triggers[0].trigger_id', + receivedValue: undefined, + }); + }); + + it('rejects a bare exercise-trigger discriminator read from the ledger', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const error = captureError(() => + damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: ['AUTOMATIC_ON_DATE'] }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'warrantIssuance.exercise_triggers[0]', + receivedValue: 'AUTOMATIC_ON_DATE', + }); + }); + + it('reports the indexed canonical field for an unknown exercise-trigger discriminator', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const error = captureError(() => + damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [{ ...daml.exercise_triggers[0], type_: 'OcfTriggerTypeTypeUnknown' }], + }) + ); + + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'warrantIssuance.exercise_triggers[0].type_', + }); + }); + + it('reports the exact path for a missing exercise conversion_right', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const { conversion_right: _conversionRight, ...triggerWithoutRight } = daml.exercise_triggers[0]; + const error = captureError(() => + damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [triggerWithoutRight] }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right', + receivedValue: undefined, + }); + }); + it('decodes only the required ELECTIVE_IN_RANGE dates', () => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); const trigger = { @@ -335,7 +454,7 @@ describe('WarrantIssuance round-trip equivalence', () => { ...baseWarrantIssuance.exercise_triggers[0], trigger_id: 'warrant2_trigger_invalid', trigger_date: '2024-01-15', - }, + } as unknown as WarrantExerciseTriggerInput, ], }), 'warrantIssuance.exercise_triggers[1].trigger_date', @@ -344,14 +463,14 @@ describe('WarrantIssuance round-trip equivalence', () => { ); }); - it('reports original vesting indexes on write and readback', () => { + it('validates zero-amount vesting dates before filtering and reports original indexes', () => { expectInvalidDate( () => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, vestings: [ + { date: '2024-01-15', amount: '0' }, { date: '', amount: '0' }, - { date: '', amount: '1' }, ], }), 'warrantIssuance.vestings[1].date', @@ -373,6 +492,21 @@ describe('WarrantIssuance round-trip equivalence', () => { ); }); + it('rejects a negative warrant vesting amount instead of silently filtering it', () => { + const error = captureError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [{ date: '2024-01-15', amount: '-1' }], + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'warrantIssuance.vestings[0].amount', + receivedValue: '-1', + }); + }); + test('uses identical canonical AUTOMATIC_ON_DATE semantics for outer and nested stock-class triggers', () => { const result = warrantIssuanceDataToDaml({ ...baseWarrantIssuance, diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 13770c67..7558d12a 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -5,6 +5,9 @@ import { convertToDaml, type CapTableBatch, type CapTableBatchOperations, + type ConversionTriggerFor, + type ConvertibleConversionRight, + type ConvertibleConversionTrigger, type OcfCreateOperation, type OcfEntityDataMap, type OcfEntityType, @@ -17,6 +20,8 @@ import { type OcfVestingEvent, type OcfVestingStart, type OcfWarrantAcceptance, + type WarrantExerciseTrigger, + type WarrantTriggerConversionRight, } from '../../dist'; import { damlTimeToDateString, @@ -162,3 +167,70 @@ function verifyPublishedUtilsApi(candidateEntityType: string): void { void verifyPublishedBatchApi; void verifyPublishedUtilsApi; + +type PublishedCanonicalConvertibleTrigger = ConversionTriggerFor; +type PublishedCanonicalWarrantTrigger = ConversionTriggerFor; + +const publishedConvertibleTriggerIsCanonical: Assert< + IsExactly +> = true; +const publishedWarrantTriggerIsCanonical: Assert> = + true; + +declare const publishedConvertibleRight: ConvertibleConversionRight; +declare const publishedWarrantRight: WarrantTriggerConversionRight; + +const publishedValidConditionTrigger: ConvertibleConversionTrigger = { + type: 'AUTOMATIC_ON_CONDITION', + trigger_id: 'convertible-trigger-1', + conversion_right: publishedConvertibleRight, + trigger_condition: 'qualified financing', +}; +const publishedValidAtWillTrigger: WarrantExerciseTrigger = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'warrant-trigger-1', + conversion_right: publishedWarrantRight, +}; + +const publishedMixedRangeTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'warrant-trigger-mixed', + conversion_right: publishedWarrantRight, + start_date: '2026-01-01', + end_date: '2026-02-01', + trigger_date: '2026-01-15', +} as const; +// @ts-expect-error built declarations forbid fields from another discriminator variant +const publishedInvalidMixedRangeTrigger: WarrantExerciseTrigger = publishedMixedRangeTrigger; + +// @ts-expect-error built declarations require trigger_condition for condition triggers +const publishedMissingCondition: ConvertibleConversionTrigger = { + type: 'ELECTIVE_ON_CONDITION', + trigger_id: 'convertible-trigger-missing-condition', + conversion_right: publishedConvertibleRight, +}; + +// @ts-expect-error built declarations require trigger_id +const publishedMissingTriggerId: WarrantExerciseTrigger = { + type: 'UNSPECIFIED', + conversion_right: publishedWarrantRight, +}; + +// @ts-expect-error built declarations require conversion_right +const publishedMissingConversionRight: ConvertibleConversionTrigger = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'convertible-trigger-missing-right', +}; + +// @ts-expect-error published API does not accept a bare trigger discriminator +const publishedBareTriggerString: WarrantExerciseTrigger = 'AUTOMATIC_ON_DATE'; + +void publishedConvertibleTriggerIsCanonical; +void publishedWarrantTriggerIsCanonical; +void publishedValidConditionTrigger; +void publishedValidAtWillTrigger; +void publishedInvalidMixedRangeTrigger; +void publishedMissingCondition; +void publishedMissingTriggerId; +void publishedMissingConversionRight; +void publishedBareTriggerString; diff --git a/test/schemaAlignment/dateBoundaryInvariants.test.ts b/test/schemaAlignment/dateBoundaryInvariants.test.ts index 311a2f94..7029d5f4 100644 --- a/test/schemaAlignment/dateBoundaryInvariants.test.ts +++ b/test/schemaAlignment/dateBoundaryInvariants.test.ts @@ -18,6 +18,13 @@ const REQUIRED_DATE_CONVERTERS = new Set(['dateStringToDAMLTime', 'damlTimeToDat const TRIGGER_FIELD_CONVERTERS = new Set(['triggerFieldsToDaml', 'triggerFieldsFromDaml']); const DISCRIMINATED_TRIGGER_DATE_FIELDS = new Set(['trigger_date', 'start_date', 'end_date']); const TRIGGER_FIELDS_HELPER = `${path.sep}shared${path.sep}triggerFields.ts`; +const VESTING_HELPER_FILE = path.join(SRC_ROOT, 'functions', 'OpenCapTable', 'shared', 'vesting.ts'); +const VESTING_WRITER_FILES = [ + 'equityCompensationIssuance/createEquityCompensationIssuance.ts', + 'planSecurityIssuance/planSecurityIssuanceDataToDaml.ts', + 'stockIssuance/createStockIssuance.ts', + 'warrantIssuance/createWarrantIssuance.ts', +] as const; const OPTIONAL_DATE_FIELDS = new Set([ 'accrual_end_date', 'board_approval_date', @@ -57,6 +64,69 @@ function rawDateProperties(node: ts.Node): string[] { } describe('date boundary source invariants', () => { + test('validates shared vesting rows before filtering and routes every writer through that boundary', () => { + const helperSource = ts.createSourceFile( + VESTING_HELPER_FILE, + fs.readFileSync(VESTING_HELPER_FILE, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + let dateValidationPosition: number | undefined; + let amountValidationPosition: number | undefined; + let filterPosition: number | undefined; + + function visitHelper(node: ts.Node): void { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + if (node.expression.text === 'dateStringToDAMLTime') dateValidationPosition = node.getStart(helperSource); + if (node.expression.text === 'normalizeNumericString') amountValidationPosition = node.getStart(helperSource); + } + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === 'filter' + ) { + filterPosition = node.expression.name.getStart(helperSource); + } + ts.forEachChild(node, visitHelper); + } + visitHelper(helperSource); + + expect(dateValidationPosition).toBeDefined(); + expect(amountValidationPosition).toBeDefined(); + expect(filterPosition).toBeDefined(); + expect(dateValidationPosition).toBeLessThan(filterPosition as number); + expect(amountValidationPosition).toBeLessThan(filterPosition as number); + + for (const relativeFile of VESTING_WRITER_FILES) { + const file = path.join(SRC_ROOT, 'functions', 'OpenCapTable', relativeFile); + const sourceFile = ts.createSourceFile( + file, + fs.readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + let delegatesVestings = false; + + function visitWriter(node: ts.Node): void { + if ( + ts.isPropertyAssignment(node) && + node.name.getText(sourceFile) === 'vestings' && + ts.isCallExpression(node.initializer) && + ts.isIdentifier(node.initializer.expression) && + node.initializer.expression.text === 'filterAndMapVestingsToDaml' + ) { + delegatesVestings = true; + } + ts.forEachChild(node, visitWriter); + } + visitWriter(sourceFile); + + expect({ file: relativeFile, delegatesVestings }).toEqual({ file: relativeFile, delegatesVestings: true }); + } + }); + test('requires contextual paths and forbids raw date presence guards', () => { const violations: string[] = []; @@ -127,10 +197,15 @@ describe('date boundary source invariants', () => { if ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && - TRIGGER_FIELD_CONVERTERS.has(node.expression.text) && - node.arguments.length > 2 + TRIGGER_FIELD_CONVERTERS.has(node.expression.text) ) { - const fieldPath = node.arguments[2]; + const expectedArgumentCount = node.expression.text === 'triggerFieldsToDaml' ? 2 : 3; + if (node.arguments.length !== expectedArgumentCount) { + violations.push( + `${location(sourceFile, node)} ${node.expression.text} must use its discriminator-correlated signature` + ); + } + const fieldPath = node.arguments[expectedArgumentCount - 1]; if (fieldPath.getText(sourceFile).includes('[]')) { violations.push(`${location(sourceFile, fieldPath)} trigger array fieldPath must include its index`); } diff --git a/test/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index ed172fea..04e87a26 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -9,6 +9,9 @@ import { type CapTableBatch, type CapTableBatchOperations, + type ConversionTriggerFor, + type ConvertibleConversionRight, + type ConvertibleConversionTrigger, convertToDaml, type OcfCreateOperation, type OcfEntityDataMap, @@ -22,6 +25,8 @@ import { type OcfVestingEvent, type OcfVestingStart, type OcfWarrantAcceptance, + type WarrantExerciseTrigger, + type WarrantTriggerConversionRight, } from '../../src'; type Assert = T; @@ -143,3 +148,96 @@ function verifyCapTableBatchContract( } void verifyCapTableBatchContract; + +type CanonicalConvertibleTrigger = ConversionTriggerFor; +type CanonicalWarrantTrigger = ConversionTriggerFor; + +const convertibleTriggerAliasIsCanonical: Assert> = + true; +const warrantTriggerAliasIsCanonical: Assert> = true; + +declare const convertibleRight: ConvertibleConversionRight; +declare const warrantRight: WarrantTriggerConversionRight; + +const validDateTrigger: ConvertibleConversionTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'convertible-trigger-1', + conversion_right: convertibleRight, + trigger_date: '2026-01-01', +}; +const validRangeTrigger: WarrantExerciseTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'warrant-trigger-1', + conversion_right: warrantRight, + start_date: '2026-01-01', + end_date: '2026-02-01', +}; + +const mixedDateTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'convertible-trigger-mixed', + conversion_right: convertibleRight, + trigger_date: '2026-01-01', + trigger_condition: 'forbidden', +} as const; +// @ts-expect-error discriminator-specific fields cannot be mixed, including through a variable +const invalidMixedDateTrigger: ConvertibleConversionTrigger = mixedDateTrigger; + +const fieldFreeTriggerWithDate = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'warrant-trigger-with-date', + conversion_right: warrantRight, + trigger_date: '2026-01-01', +} as const; +// @ts-expect-error field-free variants reject discriminator-specific fields +const invalidFieldFreeTrigger: WarrantExerciseTrigger = fieldFreeTriggerWithDate; + +// @ts-expect-error AUTOMATIC_ON_DATE requires trigger_date +const missingTriggerDate: ConvertibleConversionTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'convertible-trigger-missing-date', + conversion_right: convertibleRight, +}; + +// @ts-expect-error ELECTIVE_IN_RANGE requires end_date +const missingRangeEnd: WarrantExerciseTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'warrant-trigger-missing-end', + conversion_right: warrantRight, + start_date: '2026-01-01', +}; + +// @ts-expect-error every trigger requires trigger_id +const missingTriggerId: ConvertibleConversionTrigger = { + type: 'ELECTIVE_AT_WILL', + conversion_right: convertibleRight, +}; + +// @ts-expect-error every trigger requires conversion_right +const missingConversionRight: WarrantExerciseTrigger = { + type: 'UNSPECIFIED', + trigger_id: 'warrant-trigger-missing-right', +}; + +// @ts-expect-error bare trigger strings are not conversion-trigger records +const bareTriggerString: ConvertibleConversionTrigger = 'AUTOMATIC_ON_DATE'; + +const wrongTriggerRight: ConvertibleConversionTrigger = { + type: 'UNSPECIFIED', + trigger_id: 'convertible-trigger-wrong-right', + // @ts-expect-error convertible triggers require a convertible conversion right + conversion_right: warrantRight, +}; + +void convertibleTriggerAliasIsCanonical; +void warrantTriggerAliasIsCanonical; +void validDateTrigger; +void validRangeTrigger; +void invalidMixedDateTrigger; +void invalidFieldFreeTrigger; +void missingTriggerDate; +void missingRangeEnd; +void missingTriggerId; +void missingConversionRight; +void bareTriggerString; +void wrongTriggerRight; diff --git a/test/utils/transactionSorting.test.ts b/test/utils/transactionSorting.test.ts index 59cb9879..a397f2fe 100644 --- a/test/utils/transactionSorting.test.ts +++ b/test/utils/transactionSorting.test.ts @@ -6,6 +6,7 @@ * order as DB-loaded transactions by the cap table engine. */ +import { OcpErrorCodes } from '../../src/errors/codes'; import { OcpValidationError } from '../../src/errors/OcpValidationError'; import { buildTransactionSortKey, @@ -45,6 +46,13 @@ describe('getTimestampOrNull', () => { it('returns null for empty string', () => { expect(getTimestampOrNull('')).toBeNull(); }); + + it.each([NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.MAX_VALUE])( + 'returns null for a numeric input outside the Date timestamp range: %s', + (value) => { + expect(getTimestampOrNull(value)).toBeNull(); + } + ); }); describe('txWeight', () => { @@ -167,6 +175,20 @@ describe('buildTransactionSortKey', () => { expect(key).toContain('|9999-12-31T23:59:59.999Z|'); }); + it.each([NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.MAX_VALUE])( + 'uses the far-future timestamp when createdAt is outside the Date timestamp range: %s', + (createdAt) => { + const key = buildTransactionSortKey({ + id: 'tx-123', + date: '2025-03-15', + object_type: 'TX_STOCK_ISSUANCE', + createdAt, + }); + + expect(key).toContain('|9999-12-31T23:59:59.999Z|'); + } + ); + it('handles created_at (underscore) as fallback for createdAt', () => { const tx = { id: 'tx-123', @@ -186,9 +208,33 @@ describe('buildTransactionSortKey', () => { }; expect(() => buildTransactionSortKey(tx)).toThrow(OcpValidationError); - expect(() => buildTransactionSortKey(tx)).toThrow(/missing or invalid date/); - expect(() => buildTransactionSortKey(tx)).toThrow(/id: tx-123/); - expect(() => buildTransactionSortKey(tx)).toThrow(/object_type: TX_STOCK_ISSUANCE/); + expect(() => buildTransactionSortKey(tx)).toThrow(/missing date/); + expect(() => buildTransactionSortKey(tx)).toThrow(/id: "tx-123"/); + expect(() => buildTransactionSortKey(tx)).toThrow(/object_type: "TX_STOCK_ISSUANCE"/); + + try { + buildTransactionSortKey(tx); + } catch (error) { + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'tx.date', + receivedValue: undefined, + }); + } + }); + + it('classifies an explicit null date as missing', () => { + try { + buildTransactionSortKey({ id: 'tx-null-date', date: null, object_type: 'TX_STOCK_ISSUANCE' }); + throw new Error('Expected buildTransactionSortKey to reject the transaction date'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'tx.date', + receivedValue: null, + }); + } }); it('throws OcpValidationError for invalid date', () => { @@ -199,8 +245,63 @@ describe('buildTransactionSortKey', () => { }; expect(() => buildTransactionSortKey(tx)).toThrow(OcpValidationError); - expect(() => buildTransactionSortKey(tx)).toThrow(/missing or invalid date/); - expect(() => buildTransactionSortKey(tx)).toThrow(/id: tx-456/); + expect(() => buildTransactionSortKey(tx)).toThrow(/invalid date/); + expect(() => buildTransactionSortKey(tx)).toThrow(/id: "tx-456"/); + + try { + buildTransactionSortKey(tx); + } catch (error) { + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'tx.date', + receivedValue: 'not-a-valid-date', + }); + } + }); + + it.each(['2023-02-29', '2024-02-30', '2024-13-01'])('rejects impossible calendar date %s', (date) => { + expect(() => buildTransactionSortKey({ id: 'tx-invalid-date', date, object_type: 'TX_STOCK_ISSUANCE' })).toThrow( + OcpValidationError + ); + + try { + buildTransactionSortKey({ id: 'tx-invalid-date', date, object_type: 'TX_STOCK_ISSUANCE' }); + } catch (error) { + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'tx.date', + receivedValue: date, + }); + } + }); + + it('accepts a valid leap day', () => { + expect(buildTransactionSortKey({ id: 'tx-leap', date: '2024-02-29' })).toMatch(/^2024-02-29\|/); + }); + + it.each([0, 1710502200000, {}, true, 1n, Symbol('date')])('rejects non-string transaction date %p', (date) => { + try { + buildTransactionSortKey({ id: 'tx-invalid-type', date, object_type: 'TX_STOCK_ISSUANCE' }); + throw new Error('Expected buildTransactionSortKey to reject the transaction date'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'tx.date', + receivedValue: date, + }); + expect((error as Error).message.length).toBeLessThan(500); + } + }); + + it('preserves the lexical calendar day of an offset date-time', () => { + const key = buildTransactionSortKey({ + id: 'tx-offset', + date: '2024-01-15T23:30:00-05:00', + object_type: 'TX_STOCK_ISSUANCE', + }); + + expect(key).toMatch(/^2024-01-15\|/); }); it('includes date value in error message', () => { @@ -213,6 +314,23 @@ describe('buildTransactionSortKey', () => { expect(() => buildTransactionSortKey(tx)).toThrow(OcpValidationError); expect(() => buildTransactionSortKey(tx)).toThrow(/"invalid"/); }); + + it('bounds arbitrary transaction values in invalid-date diagnostics', () => { + const longValue = 'x'.repeat(20_000); + + try { + buildTransactionSortKey({ id: longValue, object_type: longValue, date: longValue }); + throw new Error('Expected buildTransactionSortKey to reject the transaction date'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'tx.date', + receivedValue: longValue, + }); + expect((error as Error).message.length).toBeLessThan(500); + } + }); }); describe('sortTransactions', () => { diff --git a/test/utils/triggerFields.test.ts b/test/utils/triggerFields.test.ts index 84fef1ab..e4a12417 100644 --- a/test/utils/triggerFields.test.ts +++ b/test/utils/triggerFields.test.ts @@ -4,9 +4,14 @@ import { triggerFieldsToDaml, type OcfTriggerDiscriminator, } from '../../src/functions/OpenCapTable/shared/triggerFields'; +import type { ConversionTriggerFieldShape } from '../../src/types/native'; const PATH = 'issuance.triggers[]'; +function fieldsToDaml(type: OcfTriggerDiscriminator, input: Record) { + return triggerFieldsToDaml({ type, ...input } as ConversionTriggerFieldShape, PATH); +} + function expectTriggerFieldError( action: () => unknown, field: 'trigger_date' | 'trigger_condition' | 'start_date' | 'end_date', @@ -29,7 +34,7 @@ function expectTriggerFieldError( describe('trigger discriminator boundaries', () => { test('AUTOMATIC_ON_DATE requires and canonicalizes only trigger_date on write', () => { - expect(triggerFieldsToDaml({ trigger_date: '2024-01-15T23:30:00-05:00' }, 'AUTOMATIC_ON_DATE', PATH)).toEqual({ + expect(fieldsToDaml('AUTOMATIC_ON_DATE', { trigger_date: '2024-01-15T23:30:00-05:00' })).toEqual({ trigger_date: '2024-01-15T00:00:00.000Z', trigger_condition: null, start_date: null, @@ -38,13 +43,13 @@ describe('trigger discriminator boundaries', () => { }); test.each([ - [null, OcpErrorCodes.INVALID_TYPE], - [undefined, OcpErrorCodes.INVALID_TYPE], + [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], ['', OcpErrorCodes.INVALID_FORMAT], [{ seconds: 1 }, OcpErrorCodes.INVALID_TYPE], ] as const)('AUTOMATIC_ON_DATE rejects required trigger_date %p on write', (value, code) => { expectTriggerFieldError( - () => triggerFieldsToDaml({ trigger_date: value }, 'AUTOMATIC_ON_DATE', PATH), + () => fieldsToDaml('AUTOMATIC_ON_DATE', { trigger_date: value }), 'trigger_date', value, code @@ -53,11 +58,10 @@ describe('trigger discriminator boundaries', () => { test('ELECTIVE_IN_RANGE requires and canonicalizes start_date and end_date on write', () => { expect( - triggerFieldsToDaml( - { start_date: '2024-01-15T00:30:00+14:00', end_date: '2024-02-15T23:30:00-05:00' }, - 'ELECTIVE_IN_RANGE', - PATH - ) + fieldsToDaml('ELECTIVE_IN_RANGE', { + start_date: '2024-01-15T00:30:00+14:00', + end_date: '2024-02-15T23:30:00-05:00', + }) ).toEqual({ trigger_date: null, trigger_condition: null, @@ -70,18 +74,18 @@ describe('trigger discriminator boundaries', () => { 'ELECTIVE_IN_RANGE rejects missing or malformed required %s on write', (field) => { for (const [value, code] of [ - [null, OcpErrorCodes.INVALID_TYPE], - [undefined, OcpErrorCodes.INVALID_TYPE], + [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], ['', OcpErrorCodes.INVALID_FORMAT], [{ seconds: 1 }, OcpErrorCodes.INVALID_TYPE], ] as const) { expectTriggerFieldError( () => - triggerFieldsToDaml( - { start_date: '2024-01-15', end_date: '2024-02-15', [field]: value }, - 'ELECTIVE_IN_RANGE', - PATH - ), + fieldsToDaml('ELECTIVE_IN_RANGE', { + start_date: '2024-01-15', + end_date: '2024-02-15', + [field]: value, + }), field, value, code @@ -106,26 +110,27 @@ describe('trigger discriminator boundaries', () => { : type === 'ELECTIVE_IN_RANGE' ? { start_date: '2024-01-15', end_date: '2024-02-15', [field]: value } : { [field]: value }; - expectTriggerFieldError(() => triggerFieldsToDaml(input, type, PATH), field, value, OcpErrorCodes.INVALID_FORMAT); + expectTriggerFieldError(() => fieldsToDaml(type, input), field, value, OcpErrorCodes.INVALID_FORMAT); } }); test.each(['AUTOMATIC_ON_CONDITION', 'ELECTIVE_ON_CONDITION'] as const)( '%s requires a string trigger_condition on write', (type) => { - expect(triggerFieldsToDaml({ trigger_condition: '' }, type, PATH)).toEqual({ + expect(fieldsToDaml(type, { trigger_condition: 'condition' })).toEqual({ trigger_date: null, - trigger_condition: '', + trigger_condition: 'condition', start_date: null, end_date: null, }); for (const [value, code] of [ [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['', OcpErrorCodes.REQUIRED_FIELD_MISSING], [{ condition: true }, OcpErrorCodes.INVALID_TYPE], ] as const) { expectTriggerFieldError( - () => triggerFieldsToDaml({ trigger_condition: value }, type, PATH), + () => fieldsToDaml(type, { trigger_condition: value }), 'trigger_condition', value, code @@ -144,7 +149,7 @@ describe('trigger discriminator boundaries', () => { ? { start_date: '2024-01-15', end_date: '2024-02-15', trigger_condition: 'forbidden' } : { trigger_condition: 'forbidden' }; expectTriggerFieldError( - () => triggerFieldsToDaml(input, type, PATH), + () => fieldsToDaml(type, input), 'trigger_condition', 'forbidden', OcpErrorCodes.INVALID_FORMAT @@ -153,7 +158,7 @@ describe('trigger discriminator boundaries', () => { ); test('field-free variants encode all discriminator optionals as null', () => { - expect(triggerFieldsToDaml({}, 'ELECTIVE_AT_WILL', PATH)).toEqual({ + expect(fieldsToDaml('ELECTIVE_AT_WILL', {})).toEqual({ trigger_date: null, trigger_condition: null, start_date: null, @@ -168,7 +173,7 @@ describe('trigger discriminator boundaries', () => { 'AUTOMATIC_ON_DATE', PATH ) - ).toEqual({ trigger_date: '2024-01-15' }); + ).toEqual({ type: 'AUTOMATIC_ON_DATE', trigger_date: '2024-01-15' }); expect( triggerFieldsFromDaml( { @@ -180,7 +185,7 @@ describe('trigger discriminator boundaries', () => { 'ELECTIVE_IN_RANGE', PATH ) - ).toEqual({ start_date: '2024-01-15', end_date: '2024-02-15' }); + ).toEqual({ type: 'ELECTIVE_IN_RANGE', start_date: '2024-01-15', end_date: '2024-02-15' }); }); test.each([ @@ -192,7 +197,12 @@ describe('trigger discriminator boundaries', () => { type === 'AUTOMATIC_ON_DATE' ? { trigger_date: null, start_date: null, end_date: null } : { trigger_date: null, start_date: '2024-01-15', end_date: '2024-02-15', [field]: null }; - expectTriggerFieldError(() => triggerFieldsFromDaml(input, type, PATH), field, null, OcpErrorCodes.INVALID_TYPE); + expectTriggerFieldError( + () => triggerFieldsFromDaml(input, type, PATH), + field, + null, + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); }); test.each([ @@ -227,22 +237,24 @@ describe('trigger discriminator boundaries', () => { (type) => { expect( triggerFieldsFromDaml( - { trigger_date: null, trigger_condition: '', start_date: null, end_date: null }, + { trigger_date: null, trigger_condition: 'condition', start_date: null, end_date: null }, type, PATH ) - ).toEqual({ trigger_condition: '' }); - expectTriggerFieldError( - () => - triggerFieldsFromDaml( - { trigger_date: null, trigger_condition: null, start_date: null, end_date: null }, - type, - PATH - ), - 'trigger_condition', - null, - OcpErrorCodes.REQUIRED_FIELD_MISSING - ); + ).toEqual({ type, trigger_condition: 'condition' }); + for (const value of [null, '']) { + expectTriggerFieldError( + () => + triggerFieldsFromDaml( + { trigger_date: null, trigger_condition: value, start_date: null, end_date: null }, + type, + PATH + ), + 'trigger_condition', + value, + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); + } } ); @@ -278,7 +290,7 @@ describe('trigger discriminator boundaries', () => { type, PATH ) - ).toEqual({}); + ).toEqual({ type }); } ); }); diff --git a/test/utils/vesting.test.ts b/test/utils/vesting.test.ts new file mode 100644 index 00000000..f6c435c5 --- /dev/null +++ b/test/utils/vesting.test.ts @@ -0,0 +1,75 @@ +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { filterAndMapVestingsToDaml } from '../../src/functions/OpenCapTable/shared/vesting'; + +const PATH = 'issuance.vestings'; + +function captureError(action: () => unknown): OcpValidationError { + try { + action(); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + return error as OcpValidationError; + } + throw new Error('Expected vesting validation to fail'); +} + +describe('shared vesting write boundary', () => { + test('validates and canonicalizes every row before filtering exact zero placeholders', () => { + expect( + filterAndMapVestingsToDaml( + [ + { date: '2026-01-01T23:30:00-05:00', amount: '0.000' }, + { date: '2026-02-01', amount: '-0' }, + { date: '2026-03-01T00:30:00+14:00', amount: '10.5000' }, + ], + PATH + ) + ).toEqual([{ date: '2026-03-01T00:00:00.000Z', amount: '10.5' }]); + }); + + test.each(['0', '-0'])('rejects a malformed date before filtering placeholder amount %s', (amount) => { + const error = captureError(() => filterAndMapVestingsToDaml([{ date: 'not-a-date', amount }], PATH)); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${PATH}[0].date`, + receivedValue: 'not-a-date', + }); + }); + + test('rejects a negative amount instead of silently dropping it', () => { + const error = captureError(() => + filterAndMapVestingsToDaml( + [ + { date: '2026-01-01', amount: '0' }, + { date: '2026-02-01', amount: '-1.2500' }, + ], + PATH + ) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: `${PATH}[1].amount`, + receivedValue: '-1.2500', + }); + }); + + test('reports malformed amounts at their original index', () => { + const error = captureError(() => + filterAndMapVestingsToDaml( + [ + { date: '2026-01-01', amount: '0' }, + { date: '2026-02-01', amount: '1e2' }, + ], + PATH + ) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${PATH}[1].amount`, + receivedValue: '1e2', + }); + }); +}); From 7e2a75759997285ed95e49450368f7308c830da1 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 09:07:32 -0400 Subject: [PATCH 36/47] fix: make stock class conversion rights schema exact --- .../stockClass/getStockClassAsOcf.ts | 140 ++++------- .../stockClass/stockClassDataToDaml.ts | 238 ++++++------------ src/types/native.ts | 75 +----- src/utils/entityValidators.ts | 2 +- src/utils/planSecurityAliases.ts | 50 ---- .../converters/dateBoundaryValidation.test.ts | 27 +- test/converters/stockClassConverters.test.ts | 105 ++++++++ test/declarations/publicApi.types.ts | 48 ++++ test/types/capTableBatch.types.ts | 53 ++++ test/utils/planSecurityAliases.test.ts | 9 +- test/utils/triggerFields.test.ts | 18 ++ 11 files changed, 366 insertions(+), 399 deletions(-) diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index ed0ce6f6..acae16a6 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -2,13 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; -import type { - ConversionMechanism, - ConversionMechanismObject, - Monetary, - OcfStockClass, - StockClassConversionRight, -} from '../../../types/native'; +import type { OcfStockClass, RatioConversionMechanism, StockClassConversionRight } from '../../../types/native'; import { damlStockClassTypeToNative } from '../../../utils/enumConversions'; import { damlMonetaryToNative, @@ -131,106 +125,56 @@ export function damlStockClassDataToNative( price_per_share: damlMonetaryToNative(damlData.price_per_share), }), ...(damlData.conversion_rights.length > 0 && { - conversion_rights: damlData.conversion_rights.map((right) => { - const rec = right as unknown as Record; - - // --- conversion_mechanism: build as OCF RatioConversionMechanism --- - // OCF StockClassConversionRight only allows RatioConversionMechanism, which requires: - // type, ratio, conversion_price, rounding_type (all required) - const mechRaw = rec.conversion_mechanism; - let mechanismTag: string; - if (typeof mechRaw === 'string') { - mechanismTag = mechRaw; - } else if (mechRaw && typeof mechRaw === 'object' && 'tag' in mechRaw) { - mechanismTag = (mechRaw as { tag: string }).tag; - } else { - mechanismTag = ''; + conversion_rights: damlData.conversion_rights.map((right, index) => { + const path = `stockClass.conversion_rights[${index}]`; + if (right.type_ !== 'STOCK_CLASS_CONVERSION_RIGHT') { + throw new OcpValidationError(`${path}.type`, 'Unexpected stock-class conversion-right discriminator', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'STOCK_CLASS_CONVERSION_RIGHT', + receivedValue: right.type_, + }); } - const mechanismType: ConversionMechanism = (() => { - switch (mechanismTag) { - case 'OcfConversionMechanismRatioConversion': - return 'RATIO_CONVERSION'; - case 'OcfConversionMechanismPercentCapitalizationConversion': - return 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION'; - case 'OcfConversionMechanismFixedAmountConversion': - return 'FIXED_AMOUNT_CONVERSION'; - default: - throw new OcpParseError(`Unknown stock class conversion mechanism: ${mechanismTag}`, { - source: 'conversion_right.conversion_mechanism', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - } - })(); - - // Extract ratio from DAML Optional(OcfRatio) - const extractRatio = (raw: unknown): { numerator: string; denominator: string } | undefined => { - if (!raw || typeof raw !== 'object') return undefined; - let val: unknown = raw; - if ('tag' in (val as Record)) { - const tagged = val as { tag: string; value?: unknown }; - if (tagged.tag !== 'Some' || !tagged.value) return undefined; - val = tagged.value; - } - const r = val as Record; - if (!('numerator' in r) || !('denominator' in r)) return undefined; - const num = r.numerator; - const den = r.denominator; - if (num == null || den == null) return undefined; - const numStr = typeof num === 'string' ? num : typeof num === 'number' ? num.toString() : null; - const denStr = typeof den === 'string' ? den : typeof den === 'number' ? den.toString() : null; - if (numStr === null || denStr === null) return undefined; - return { numerator: normalizeNumericString(numStr), denominator: normalizeNumericString(denStr) }; - }; - - let ratio = extractRatio(rec.ratio); - if (!ratio && mechRaw && typeof mechRaw === 'object' && 'value' in mechRaw) { - ratio = extractRatio(mechRaw.value); + if (right.conversion_mechanism !== 'OcfConversionMechanismRatioConversion') { + throw new OcpParseError(`Unknown stock class conversion mechanism: ${right.conversion_mechanism}`, { + source: `${path}.conversion_mechanism`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + if (!right.ratio) { + throw new OcpValidationError(`${path}.conversion_mechanism.ratio`, 'Required OCF ratio is missing', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + if (!right.conversion_price) { + throw new OcpValidationError( + `${path}.conversion_mechanism.conversion_price`, + 'Required OCF conversion price is missing', + { code: OcpErrorCodes.SCHEMA_MISMATCH } + ); + } + if (right.converts_to_stock_class_id.length === 0) { + throw new OcpValidationError(`${path}.converts_to_stock_class_id`, 'Required target stock class is missing', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); } - // Extract optional monetary from DAML Optional(OcfMonetary) - const extractOptionalMonetary = (raw: unknown): Monetary | undefined => { - if (raw && typeof raw === 'object' && 'tag' in raw && raw.tag === 'Some' && 'value' in raw) { - return damlMonetaryToNative((raw as { value: Fairmint.OpenCapTable.Types.Monetary.OcfMonetary }).value); - } - return undefined; - }; - - const conversionPrice = extractOptionalMonetary(rec.conversion_price); - - // Extract rounding_type from DAML Optional(OcfRoundingType) - const extractRoundingType = (raw: unknown): 'CEILING' | 'FLOOR' | 'NORMAL' => { - if (!raw || typeof raw !== 'object') return 'NORMAL'; - const tag = - 'tag' in (raw as Record) - ? (raw as { tag: string }).tag - : 'value' in (raw as Record) - ? (raw as { value: unknown } as Record).tag - : null; - if (tag === 'OcfRoundingCeiling') return 'CEILING'; - if (tag === 'OcfRoundingFloor') return 'FLOOR'; - if (tag === 'OcfRoundingNormal') return 'NORMAL'; - return 'NORMAL'; - }; - const roundingType = extractRoundingType(rec.rounding_type); - - // Build OCF RatioConversionMechanism (required: type, ratio, conversion_price, rounding_type) - // StockClassConversionRight schema only allows RatioConversionMechanism; additionalProperties: false - const mechanismObj: ConversionMechanismObject = { - type: mechanismType, - ratio: ratio ?? { numerator: '1', denominator: '1' }, - conversion_price: conversionPrice ?? { amount: '0', currency: 'USD' }, - rounding_type: roundingType, + const mechanismObj: RatioConversionMechanism = { + type: 'RATIO_CONVERSION', + ratio: { + numerator: normalizeNumericString(right.ratio.numerator), + denominator: normalizeNumericString(right.ratio.denominator), + }, + conversion_price: damlMonetaryToNative(right.conversion_price), + // DAML v34 has no rounding field. The writer only accepts NORMAL. + rounding_type: 'NORMAL', }; - // OCF StockClassConversionRight schema allows ONLY: type, conversion_mechanism, - // converts_to_future_round, converts_to_stock_class_id. No conversion_trigger or other fields. - const convertsToFutureRound = rec.converts_to_future_round; const convRight: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: mechanismObj, converts_to_stock_class_id: right.converts_to_stock_class_id, - ...(convertsToFutureRound !== undefined && convertsToFutureRound !== null - ? { converts_to_future_round: Boolean(convertsToFutureRound) } + ...(right.converts_to_future_round !== null + ? { converts_to_future_round: right.converts_to_future_round } : {}), }; diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 37908313..3ecfada1 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -1,10 +1,6 @@ -import type { - ConversionMechanism, - ConversionMechanismObject, - ConversionTriggerType, - OcfStockClass, - StockClassConversionRight, -} from '../../../types'; +import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import type { OcfStockClass, StockClassConversionRight } from '../../../types'; import { validateStockClassData } from '../../../utils/entityValidators'; import { stockClassTypeToDaml } from '../../../utils/enumConversions'; import { @@ -13,143 +9,109 @@ import { monetaryToDaml, normalizeNumericString, optionalDateStringToDAMLTime, - optionalString, } from '../../../utils/typeConversions'; -function triggerTypeToDamlEnum(t: ConversionTriggerType): string { - switch (t) { - case 'AUTOMATIC_ON_CONDITION': - return 'OcfTriggerTypeTypeAutomaticOnCondition'; - case 'AUTOMATIC_ON_DATE': - return 'OcfTriggerTypeTypeAutomaticOnDate'; - case 'ELECTIVE_AT_WILL': - return 'OcfTriggerTypeTypeElectiveAtWill'; - case 'ELECTIVE_ON_CONDITION': - return 'OcfTriggerTypeTypeElectiveOnCondition'; - case 'ELECTIVE_IN_RANGE': - return 'OcfTriggerTypeTypeElectiveInRange'; - case 'UNSPECIFIED': - return 'OcfTriggerTypeTypeUnspecified'; - default: { - const _exhaustive: never = t; - throw new Error(`Unknown stock class conversion trigger type: ${String(_exhaustive)}`); - } - } -} - /** - * Normalize a ConversionMechanism (string) or ConversionMechanismObject - * ({ type, ratio?, conversion_price? }) to the DAML enum string. - */ -function conversionMechanismToDaml( - mechanism: ConversionMechanism | ConversionMechanismObject -): - | 'OcfConversionMechanismRatioConversion' - | 'OcfConversionMechanismPercentCapitalizationConversion' - | 'OcfConversionMechanismFixedAmountConversion' { - const type: ConversionMechanism = typeof mechanism === 'string' ? mechanism : mechanism.type; - switch (type) { - case 'RATIO_CONVERSION': - return 'OcfConversionMechanismRatioConversion'; - case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': - return 'OcfConversionMechanismPercentCapitalizationConversion'; - case 'FIXED_AMOUNT_CONVERSION': - return 'OcfConversionMechanismFixedAmountConversion'; - default: { - const _exhaustive: never = type; - throw new Error(`Unknown stock class conversion mechanism: ${String(_exhaustive)}`); - } - } -} - -/** - * Extract ratio and conversion_price from a ConversionMechanismObject. - * Returns nulls when mechanism is a plain string. - */ -function extractMechanismDetails(mechanism: ConversionMechanism | ConversionMechanismObject): { - ratio: { numerator: string; denominator: string } | null; - conversion_price: { amount: string; currency: string } | null; -} { - if (typeof mechanism === 'string') { - return { ratio: null, conversion_price: null }; - } - return { - ratio: mechanism.ratio ?? null, - conversion_price: mechanism.conversion_price ?? null, - }; -} - -/** - * Build an OcfConversionTrigger record for a stock class conversion right. + * Adapt the OCF/DAML v34 schema mismatch at the private storage boundary. * - * DAML expects conversion_trigger to be a full OcfConversionTrigger record - * (not a plain string). The trigger's conversion_right field uses the - * OcfRightConvertible variant to avoid circular nesting with - * OcfRightStockClass (which itself requires a conversion_trigger). + * Canonical OCF forbids a conversion_trigger on StockClassConversionRight, + * while DAML v34 structurally requires one but never validates or consumes it. + * Keep that artifact out of the public model and use one stable, inert value so + * a future DAML change that starts interpreting it fails generated/LocalNet tests. */ -function buildStockClassTrigger( +function buildStorageOnlyStockClassTrigger( right: StockClassConversionRight, + convertsToStockClassId: string, stockClassId: string, index: number -): Record { - const triggerType = right.conversion_trigger; - - // When conversion_trigger is absent from the OCF data, produce a default - // unspecified trigger so the DAML contract still receives a valid record. - if (triggerType === undefined) { - return { - trigger_id: `default-${stockClassId}-${index}`, - type_: 'OcfTriggerTypeTypeUnspecified', - conversion_right: { - tag: 'OcfRightConvertible', - value: { - type_: right.type, - conversion_mechanism: { - tag: 'OcfConvMechCustom', - value: { custom_conversion_description: 'Stock class conversion' }, - }, - converts_to_future_round: null, - converts_to_stock_class_id: right.converts_to_stock_class_id, - }, - }, - nickname: null, - trigger_condition: null, - trigger_date: null, - trigger_description: null, - }; - } - - const typeEnum = triggerTypeToDamlEnum(triggerType); - +): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { return { - trigger_id: `${stockClassId}-trigger-${index}`, - type_: typeEnum, conversion_right: { tag: 'OcfRightConvertible', value: { - type_: right.type, conversion_mechanism: { tag: 'OcfConvMechCustom', - value: { custom_conversion_description: 'Stock class conversion' }, + value: { custom_conversion_description: 'OCF stock-class conversion storage adapter' }, }, - converts_to_future_round: null, - converts_to_stock_class_id: right.converts_to_stock_class_id, + type_: 'CONVERTIBLE_CONVERSION_RIGHT', + converts_to_future_round: right.converts_to_future_round ?? null, + converts_to_stock_class_id: convertsToStockClassId, }, }, + trigger_id: `ocp-sdk:stock-class:${stockClassId}:conversion-right:${index}:unspecified`, + type_: 'OcfTriggerTypeTypeUnspecified', + end_date: null, nickname: null, + start_date: null, trigger_condition: null, trigger_date: null, trigger_description: null, }; } +function stockClassConversionRightToDaml( + right: StockClassConversionRight, + stockClassId: string, + index: number +): Fairmint.OpenCapTable.Types.Conversion.OcfStockClassConversionRight { + const convertsToStockClassId = right.converts_to_stock_class_id; + if (typeof convertsToStockClassId !== 'string' || convertsToStockClassId.length === 0) { + throw new OcpValidationError( + `stockClass.conversion_rights[${index}].converts_to_stock_class_id`, + 'The current DAML package requires a target stock class for every stock-class conversion right', + { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: convertsToStockClassId, + } + ); + } + + const path = `stockClass.conversion_rights[${index}].conversion_mechanism.rounding_type`; + if (right.conversion_mechanism.rounding_type !== 'NORMAL') { + throw new OcpValidationError( + path, + 'The current DAML package does not persist stock-class conversion rounding; only NORMAL round-trips losslessly', + { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'NORMAL', + receivedValue: right.conversion_mechanism.rounding_type, + } + ); + } + + return { + conversion_mechanism: 'OcfConversionMechanismRatioConversion', + conversion_trigger: buildStorageOnlyStockClassTrigger(right, convertsToStockClassId, stockClassId, index), + converts_to_stock_class_id: convertsToStockClassId, + type_: right.type, + ceiling_price_per_share: null, + conversion_price: monetaryToDaml(right.conversion_mechanism.conversion_price), + converts_to_future_round: right.converts_to_future_round ?? null, + custom_description: null, + discount_rate: null, + expires_at: null, + floor_price_per_share: null, + percent_of_capitalization: null, + ratio: { + numerator: normalizeNumericString(right.conversion_mechanism.ratio.numerator), + denominator: normalizeNumericString(right.conversion_mechanism.ratio.denominator), + }, + reference_share_price: null, + reference_valuation_price_per_share: null, + valuation_cap: null, + }; +} + /** * Convert native OcfStockClass to DAML StockClassOcfData format. * * @param stockClassData - Native stock class data * @returns DAML-formatted stock class data */ -export function stockClassDataToDaml(stockClassData: OcfStockClass): Record { +export function stockClassDataToDaml( + stockClassData: OcfStockClass +): Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData { validateStockClassData(stockClassData, 'stockClass'); const d = stockClassData; @@ -168,51 +130,9 @@ export function stockClassDataToDaml(stockClassData: OcfStockClass): Record { - const mechanism = conversionMechanismToDaml(right.conversion_mechanism); - const mechDetails = extractMechanismDetails(right.conversion_mechanism); - - let ratio: { numerator: string; denominator: string } | null = null; - if (right.ratio_numerator !== undefined && right.ratio_denominator !== undefined) { - ratio = { - numerator: normalizeNumericString(right.ratio_numerator), - denominator: normalizeNumericString(right.ratio_denominator), - }; - } else if (mechDetails.ratio) { - ratio = { - numerator: normalizeNumericString(mechDetails.ratio.numerator), - denominator: normalizeNumericString(mechDetails.ratio.denominator), - }; - } - - const conversionPrice = right.conversion_price ?? mechDetails.conversion_price; - - return { - type_: right.type, - conversion_mechanism: mechanism, - conversion_trigger: buildStockClassTrigger(right, d.id, index), - converts_to_stock_class_id: right.converts_to_stock_class_id, - ratio: ratio ?? null, - percent_of_capitalization: - right.percent_of_capitalization !== undefined - ? normalizeNumericString(right.percent_of_capitalization) - : null, - conversion_price: conversionPrice ? monetaryToDaml(conversionPrice) : null, - reference_share_price: right.reference_share_price ? monetaryToDaml(right.reference_share_price) : null, - - reference_valuation_price_per_share: right.reference_valuation_price_per_share - ? monetaryToDaml(right.reference_valuation_price_per_share) - : null, - discount_rate: right.discount_rate !== undefined ? normalizeNumericString(right.discount_rate) : null, - valuation_cap: right.valuation_cap ? monetaryToDaml(right.valuation_cap) : null, - floor_price_per_share: right.floor_price_per_share ? monetaryToDaml(right.floor_price_per_share) : null, - ceiling_price_per_share: right.ceiling_price_per_share ? monetaryToDaml(right.ceiling_price_per_share) : null, - converts_to_future_round: - typeof right.converts_to_future_round === 'boolean' ? right.converts_to_future_round : null, - custom_description: optionalString(right.custom_description), - expires_at: optionalDateStringToDAMLTime(right.expires_at, `stockClass.conversion_rights[${index}].expires_at`), - }; - }), + conversion_rights: (d.conversion_rights ?? []).map((right, index) => + stockClassConversionRightToDaml(right, d.id, index) + ), liquidation_preference_multiple: d.liquidation_preference_multiple != null ? normalizeNumericString(d.liquidation_preference_multiple) : null, participation_cap_multiple: diff --git a/src/types/native.ts b/src/types/native.ts index 068ae981..254ace53 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -70,14 +70,17 @@ export interface ConversionMechanismObject { rounding_type?: RoundingType; } -/** RATIO_CONVERSION with ratio, price, and rounding required (warrant stock-class path). */ -export interface WarrantRatioConversionMechanism { +/** Exact OCF RatioConversionMechanism with every schema-required field. */ +export interface RatioConversionMechanism { type: 'RATIO_CONVERSION'; ratio: NonNullable; conversion_price: NonNullable; rounding_type: NonNullable; } +/** @deprecated Use {@link RatioConversionMechanism}. */ +export type WarrantRatioConversionMechanism = RatioConversionMechanism; + /** * Enum - Conversion Trigger Type Type of conversion trigger OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/enums/ConversionTriggerType.schema.json @@ -243,17 +246,17 @@ export interface WarrantConversionRight { converts_to_stock_class_id?: string; } -/** - * WarrantIssuance ConversionTrigger.oneOf StockClassConversionRight (OCF) — exercised as - * DAML {@code OcfAnyConversionRight} tag {@code OcfRightStockClass}. - */ -export interface WarrantStockClassConversionRight { +/** Exact OCF StockClassConversionRight shared by stock classes and warrant triggers. */ +export interface StockClassConversionRight { type: 'STOCK_CLASS_CONVERSION_RIGHT'; - conversion_mechanism: WarrantRatioConversionMechanism; - converts_to_stock_class_id: string; + conversion_mechanism: RatioConversionMechanism; + converts_to_stock_class_id?: string; converts_to_future_round?: boolean; } +/** Stock-class conversion-right branch accepted by warrant exercise triggers. */ +export type WarrantStockClassConversionRight = StockClassConversionRight; + /** Union — warrant exercise triggers may carry either variant per OCF {@code ConversionTrigger} schema */ export type WarrantTriggerConversionRight = WarrantConversionRight | WarrantStockClassConversionRight; @@ -466,60 +469,6 @@ export interface TaxId { tax_id: string; } -/** - * Stock Class Conversion Right (shared) OCF: - * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/types/conversion_rights/StockClassConversionRight.schema.json - * - * OCF-compliant fields: type, conversion_mechanism, converts_to_future_round, converts_to_stock_class_id. - * The OCF schema has additionalProperties: false — no other fields are allowed in OCF output. - * - * The remaining fields below are DAML-internal passthrough fields. The DAML contract - * `OcfStockClassConversionRight` stores conversion details flat (ratio, conversion_price, etc.) - * rather than nested inside the conversion_mechanism object. These fields are accepted on write - * for backwards compatibility but are NOT included in OCF-compliant reader output. - */ -export interface StockClassConversionRight { - /** Type descriptor — must be 'STOCK_CLASS_CONVERSION_RIGHT' per OCF schema */ - type: string; - /** Mechanism by which conversion occurs (OCF: RatioConversionMechanism only) */ - conversion_mechanism: ConversionMechanism | ConversionMechanismObject; - /** Identifier of stock class to which this converts */ - converts_to_stock_class_id: string; - /** Is this potentially convertible into a future, as-yet undetermined stock class? */ - converts_to_future_round?: boolean; - - // ----- DAML-internal passthrough fields (not in OCF output) ----- - - /** @internal DAML passthrough — trigger that would cause conversion */ - conversion_trigger?: ConversionTriggerType; - /** @internal DAML passthrough — ratio numerator for RATIO_CONVERSION */ - ratio_numerator?: string; - /** @internal DAML passthrough — ratio denominator for RATIO_CONVERSION */ - ratio_denominator?: string; - /** @internal DAML passthrough — percent of capitalization */ - percent_of_capitalization?: string; - /** @internal DAML passthrough — conversion price per share */ - conversion_price?: Monetary; - /** @internal DAML passthrough — reference share price */ - reference_share_price?: Monetary; - /** @internal DAML passthrough — reference valuation price per share */ - reference_valuation_price_per_share?: Monetary; - /** @internal DAML passthrough — discount rate */ - discount_rate?: string; - /** @internal DAML passthrough — valuation cap */ - valuation_cap?: Monetary; - /** @internal DAML passthrough — floor price per share */ - floor_price_per_share?: Monetary; - /** @internal DAML passthrough — ceiling price per share */ - ceiling_price_per_share?: Monetary; - /** @internal DAML passthrough — custom description */ - custom_description?: string; - /** @internal DAML passthrough — rounding type for fractional shares */ - rounding_type?: RoundingType; - /** @internal DAML passthrough — expiration date (YYYY-MM-DD) */ - expires_at?: string; -} - /** Canonical OCF object discriminators supported by this SDK. */ export type OcfObjectType = | 'ISSUER' diff --git a/src/utils/entityValidators.ts b/src/utils/entityValidators.ts index 8283c3e5..72ba4ea1 100644 --- a/src/utils/entityValidators.ts +++ b/src/utils/entityValidators.ts @@ -462,7 +462,7 @@ export function validateStockClassData(data: unknown, fieldPath: string): void { for (let i = 0; i < conversionRights.length; i++) { const right = conversionRights[i]; validateRequiredObject(right, `${fieldPath}.conversion_rights[${i}]`); - validateRequiredString( + validateOptionalString( right.converts_to_stock_class_id, `${fieldPath}.conversion_rights[${i}].converts_to_stock_class_id` ); diff --git a/src/utils/planSecurityAliases.ts b/src/utils/planSecurityAliases.ts index 649b60f3..137a39e2 100644 --- a/src/utils/planSecurityAliases.ts +++ b/src/utils/planSecurityAliases.ts @@ -771,8 +771,6 @@ export function normalizeOcfData(data: unknown): Record { result = normalizeVestingTermsDefaults(result); - result = normalizeConversionMechanismRoundTrip(result); - result = normalizeCapitalizationDefinitionRules(result); result = deepNormalizeNumericStrings(result); @@ -857,54 +855,6 @@ function normalizeCapitalizationDefinitionRules(data: Record): return { ...data, conversion_triggers: normalizedTriggers }; } -/** - * Strip fields from conversion_mechanism objects that DAML does not store, - * so DB-sourced and Canton-sourced data compare identically. - * - * The DAML StockClass contract stores conversion_mechanism as an enum string - * with ratio and conversion_price as separate top-level optional fields. - * Fields like rounding_type exist in the OCF schema but are not stored in - * the DAML contract, so they cannot survive the round-trip. - * - * Schema-default equivalence: When conversion_rights has exactly one - * RATIO_CONVERSION right with ratio 1:1, normalize to empty. The OCP Canton - * SDK reader (getStockClassAsOcf) fills in { numerator: '1', denominator: '1' } - * when DAML has no explicit ratio, while the DB omits conversion_rights for - * 1:1 preferred stock. Both are semantically equivalent. - */ -function normalizeConversionMechanismRoundTrip(data: Record): Record { - if (data.object_type !== 'STOCK_CLASS') return data; - const rights = data.conversion_rights; - if (!Array.isArray(rights) || rights.length === 0) return data; - - const normalized = (rights as Array>).map((right) => { - const mechanism = right.conversion_mechanism; - if (!mechanism || typeof mechanism !== 'object' || Array.isArray(mechanism)) return right; - - const mech = { ...(mechanism as Record) }; - delete mech.rounding_type; - - return { ...right, conversion_mechanism: mech }; - }); - - // Schema-default: single 1:1 RATIO_CONVERSION → empty (matches DB omission) - if (normalized.length === 1) { - const right = normalized[0]; - const mech = right.conversion_mechanism as Record | undefined; - if (mech?.type === 'RATIO_CONVERSION') { - const ratio = mech.ratio as { numerator?: string | number; denominator?: string | number } | undefined; - const num = ratio?.numerator; - const den = ratio?.denominator; - const isOneToOne = (num === '1' || num === 1) && (den === '1' || den === 1); - if (isOneToOne && right.converts_to_future_round !== true) { - return { ...data, conversion_rights: [] }; - } - } - } - - return { ...data, conversion_rights: normalized }; -} - /** * Strip OCF schema-default values from VESTING_TERMS objects so that both DB-sourced * and Canton-sourced manifests compare identically after normalization. diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index 22f3278a..e0c6546e 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -100,16 +100,6 @@ const PLAN_SECURITY_ISSUANCE_WRITE_BASE: Parameters[0]), + }), })), ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ name: `stock issuance ${field}`, @@ -388,7 +378,7 @@ describe('OCF write converter optional date boundaries', () => { ); }); - test('reports original array indexes for nested issuance and stock-class dates', () => { + test('reports original array indexes for nested issuance dates', () => { expectInvalidDate( () => damlEquityCompensationIssuanceDataToNative({ @@ -414,19 +404,6 @@ describe('OCF write converter optional date boundaries', () => { 'stockIssuance.vestings[1].date', '' ); - - expectInvalidDate( - () => - stockClassDataToDaml({ - ...STOCK_CLASS_WRITE_BASE, - conversion_rights: [ - { ...STOCK_CLASS_CONVERSION_RIGHT, expires_at: '2025-01-15' }, - { ...STOCK_CLASS_CONVERSION_RIGHT, expires_at: '' }, - ], - }), - 'stockClass.conversion_rights[1].expires_at', - '' - ); }); test.each([ diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index 108c02f7..c53b1a08 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -10,7 +10,11 @@ * - OcfInitialSharesEnum for "UNLIMITED" or "NOT APPLICABLE" */ +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes } from '../../src/errors'; +import { buildOcfCreateData } from '../../src/functions/OpenCapTable/capTable/CapTableBatch'; import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; +import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; import type { OcfStockClass } from '../../src/types/native'; import { initialSharesAuthorizedToDaml } from '../../src/utils/typeConversions'; @@ -142,6 +146,107 @@ describe('StockClass Converters', () => { }); }); + test('keeps the DAML-only conversion trigger private and round-trips canonical OCF data exactly', () => { + const dataWithConversionRight: OcfStockClass = { + ...baseData, + comments: [], + conversion_rights: [ + { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '3', denominator: '2' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + converts_to_stock_class_id: 'class-common', + }, + ], + }; + + const generatedCreate = buildOcfCreateData('stockClass', dataWithConversionRight); + expect(generatedCreate.tag).toBe('OcfCreateStockClass'); + + const storedTrigger = generatedCreate.value.conversion_rights[0].conversion_trigger; + expect(storedTrigger).toEqual( + expect.objectContaining({ + trigger_id: 'ocp-sdk:stock-class:class-001:conversion-right:0:unspecified', + type_: 'OcfTriggerTypeTypeUnspecified', + end_date: null, + nickname: null, + start_date: null, + trigger_condition: null, + trigger_date: null, + trigger_description: null, + conversion_right: expect.objectContaining({ + tag: 'OcfRightConvertible', + value: expect.objectContaining({ type_: 'CONVERTIBLE_CONVERSION_RIGHT' }), + }), + }) + ); + + const decoded = Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData.decoder.runWithException( + generatedCreate.value + ); + expect(damlStockClassDataToNative(decoded)).toEqual(dataWithConversionRight); + }); + + test.each(['CEILING', 'FLOOR'] as const)( + 'rejects %s rounding because DAML v34 cannot persist it', + (roundingType) => { + const dataWithLossyRounding: OcfStockClass = { + ...baseData, + conversion_rights: [ + { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '3', denominator: '2' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: roundingType, + }, + converts_to_stock_class_id: 'class-common', + }, + ], + }; + + expect(() => convertToDaml('stockClass', dataWithLossyRounding)).toThrow( + expect.objectContaining({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.rounding_type', + receivedValue: roundingType, + }) + ); + } + ); + + test('rejects an OCF future-round right that the current DAML package cannot target', () => { + const futureRoundRight: OcfStockClass = { + ...baseData, + conversion_rights: [ + { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '3', denominator: '2' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + converts_to_future_round: true, + }, + ], + }; + + expect(() => convertToDaml('stockClass', futureRoundRight)).toThrow( + expect.objectContaining({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'stockClass.conversion_rights[0].converts_to_stock_class_id', + }) + ); + }); + test('throws error when id is missing', () => { const invalidData = { ...baseData, id: '' }; diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 7558d12a..0ea29eb8 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -20,6 +20,8 @@ import { type OcfVestingEvent, type OcfVestingStart, type OcfWarrantAcceptance, + type RatioConversionMechanism, + type StockClassConversionRight, type WarrantExerciseTrigger, type WarrantTriggerConversionRight, } from '../../dist'; @@ -234,3 +236,49 @@ void publishedMissingCondition; void publishedMissingTriggerId; void publishedMissingConversionRight; void publishedBareTriggerString; + +interface PublishedRatioConversionMechanism { + type: 'RATIO_CONVERSION'; + ratio: { numerator: string; denominator: string }; + conversion_price: { amount: string; currency: string }; + rounding_type: 'CEILING' | 'FLOOR' | 'NORMAL'; +} +interface PublishedStockClassConversionRight { + type: 'STOCK_CLASS_CONVERSION_RIGHT'; + conversion_mechanism: PublishedRatioConversionMechanism; + converts_to_stock_class_id?: string; + converts_to_future_round?: boolean; +} + +const publishedRatioMechanismIsExact: Assert> = + true; +const publishedStockClassRightIsExact: Assert< + IsExactly +> = true; + +const publishedStockClassRight: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + converts_to_stock_class_id: 'common', +}; +const publishedInvalidStockClassType: StockClassConversionRight = { + ...publishedStockClassRight, + // @ts-expect-error built declarations preserve the exact stock-class-right tag + type: 'NOT_THE_SCHEMA_TAG', +}; +const publishedInvalidScalarTrigger: StockClassConversionRight = { + ...publishedStockClassRight, + // @ts-expect-error built declarations do not expose the DAML-only trigger artifact + conversion_trigger: 'AUTOMATIC_ON_DATE', +}; + +void publishedRatioMechanismIsExact; +void publishedStockClassRightIsExact; +void publishedStockClassRight; +void publishedInvalidStockClassType; +void publishedInvalidScalarTrigger; diff --git a/test/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index 04e87a26..93ce2d32 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -25,6 +25,8 @@ import { type OcfVestingEvent, type OcfVestingStart, type OcfWarrantAcceptance, + type RatioConversionMechanism, + type StockClassConversionRight, type WarrantExerciseTrigger, type WarrantTriggerConversionRight, } from '../../src'; @@ -241,3 +243,54 @@ void missingTriggerId; void missingConversionRight; void bareTriggerString; void wrongTriggerRight; + +interface CanonicalRatioConversionMechanism { + type: 'RATIO_CONVERSION'; + ratio: { numerator: string; denominator: string }; + conversion_price: { amount: string; currency: string }; + rounding_type: 'CEILING' | 'FLOOR' | 'NORMAL'; +} +interface CanonicalStockClassConversionRight { + type: 'STOCK_CLASS_CONVERSION_RIGHT'; + conversion_mechanism: CanonicalRatioConversionMechanism; + converts_to_stock_class_id?: string; + converts_to_future_round?: boolean; +} + +const ratioMechanismIsSchemaExact: Assert> = + true; +const stockClassRightIsSchemaExact: Assert> = + true; + +const validStockClassRight: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + converts_to_stock_class_id: 'common', +}; +const invalidStockClassRightType: StockClassConversionRight = { + ...validStockClassRight, + // @ts-expect-error stock-class conversion rights have one exact discriminator + type: 'NOT_THE_SCHEMA_TAG', +}; +const invalidStockClassScalarTrigger: StockClassConversionRight = { + ...validStockClassRight, + // @ts-expect-error DAML-only trigger artifacts are not part of canonical OCF + conversion_trigger: 'AUTOMATIC_ON_DATE', +}; +const invalidStockClassStringMechanism: StockClassConversionRight = { + ...validStockClassRight, + // @ts-expect-error stock-class rights require the complete ratio mechanism object + conversion_mechanism: 'RATIO_CONVERSION', +}; + +void ratioMechanismIsSchemaExact; +void stockClassRightIsSchemaExact; +void validStockClassRight; +void invalidStockClassRightType; +void invalidStockClassScalarTrigger; +void invalidStockClassStringMechanism; diff --git a/test/utils/planSecurityAliases.test.ts b/test/utils/planSecurityAliases.test.ts index 928d76c7..d48a3a95 100644 --- a/test/utils/planSecurityAliases.test.ts +++ b/test/utils/planSecurityAliases.test.ts @@ -1206,8 +1206,8 @@ describe('PlanSecurity alias utilities', () => { }); }); - describe('stock class conversion rights 1:1 schema-default', () => { - it('normalizes single 1:1 RATIO_CONVERSION to empty conversion_rights', () => { + describe('canonical stock class conversion rights', () => { + it('preserves a 1:1 ratio because a conversion right is not equivalent to its omission', () => { const cantonStyle = { object_type: 'STOCK_CLASS', id: 'sc-preferred', @@ -1221,13 +1221,14 @@ describe('PlanSecurity alias utilities', () => { type: 'RATIO_CONVERSION', ratio: { numerator: '1', denominator: '1' }, conversion_price: { amount: '0', currency: 'USD' }, + rounding_type: 'NORMAL', }, converts_to_stock_class_id: 'common', }, ], } as Record; const result = normalizeOcfData(cantonStyle); - expect(result.conversion_rights).toEqual([]); + expect(result.conversion_rights).toEqual(cantonStyle.conversion_rights); }); it('preserves non-1:1 conversion rights', () => { @@ -1244,6 +1245,7 @@ describe('PlanSecurity alias utilities', () => { type: 'RATIO_CONVERSION', ratio: { numerator: '2', denominator: '1' }, conversion_price: { amount: '0', currency: 'USD' }, + rounding_type: 'NORMAL', }, converts_to_stock_class_id: 'common', }, @@ -1254,6 +1256,7 @@ describe('PlanSecurity alias utilities', () => { expect((result.conversion_rights as Array>)[0].conversion_mechanism).toMatchObject({ type: 'RATIO_CONVERSION', ratio: { numerator: '2', denominator: '1' }, + rounding_type: 'NORMAL', }); }); diff --git a/test/utils/triggerFields.test.ts b/test/utils/triggerFields.test.ts index e4a12417..3ffc7ece 100644 --- a/test/utils/triggerFields.test.ts +++ b/test/utils/triggerFields.test.ts @@ -157,6 +157,24 @@ describe('trigger discriminator boundaries', () => { } ); + test.each(['AUTOMATIC_ON_DATE', 'ELECTIVE_IN_RANGE', 'ELECTIVE_AT_WILL', 'UNSPECIFIED'] as const)( + '%s rejects an explicitly present undefined forbidden field at runtime', + (type) => { + const requiredFields = + type === 'AUTOMATIC_ON_DATE' + ? { trigger_date: '2024-01-15' } + : type === 'ELECTIVE_IN_RANGE' + ? { start_date: '2024-01-15', end_date: '2024-02-15' } + : {}; + expectTriggerFieldError( + () => fieldsToDaml(type, { ...requiredFields, trigger_condition: undefined }), + 'trigger_condition', + undefined, + OcpErrorCodes.INVALID_FORMAT + ); + } + ); + test('field-free variants encode all discriminator optionals as null', () => { expect(fieldsToDaml('ELECTIVE_AT_WILL', {})).toEqual({ trigger_date: null, From 6f396cabddf5a271019bbc15e978cbc7211077bb Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 09:16:46 -0400 Subject: [PATCH 37/47] fix: reject lossy stock class conversion reads --- .../OpenCapTable/capTable/damlToOcf.ts | 2 +- .../stockClass/getStockClassAsOcf.ts | 185 +++++++++++++++++- .../stockClass/stockClassDataToDaml.ts | 13 ++ .../warrantIssuance/createWarrantIssuance.ts | 17 +- test/batch/damlToOcfDispatcher.test.ts | 2 +- test/converters/stockClassConverters.test.ts | 129 +++++++++--- .../warrantIssuanceConverters.test.ts | 19 ++ test/createOcf/falsyFieldRoundtrip.test.ts | 10 +- test/validation/damlToOcfValidation.test.ts | 1 + 9 files changed, 339 insertions(+), 39 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index 690f1aff..d724fb55 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -122,7 +122,7 @@ export function convertToOcf( case 'stakeholder': return damlStakeholderDataToNative(data as Parameters[0]); case 'stockClass': - return damlStockClassDataToNative(data as Parameters[0]); + return damlStockClassDataToNative(data); case 'stockLegendTemplate': return damlStockLegendTemplateDataToNative(data as Parameters[0]); case 'stockPlan': diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index acae16a6..9548cca8 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -11,13 +11,94 @@ import { } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; +function firstLossyGeneratedPath( + source: unknown, + encoded: unknown, + path: string, + ancestors = new WeakSet() +): string | undefined { + if (source === null || typeof source !== 'object') { + return Object.is(source, encoded) ? undefined : path; + } + if (ancestors.has(source)) return path; + ancestors.add(source); + try { + if (Array.isArray(source)) { + if (!Array.isArray(encoded) || source.length !== encoded.length) return path; + for (let index = 0; index < source.length; index += 1) { + const mismatch = firstLossyGeneratedPath(source[index], encoded[index], `${path}[${index}]`, ancestors); + if (mismatch !== undefined) return mismatch; + } + return undefined; + } + if (encoded === null || typeof encoded !== 'object' || Array.isArray(encoded)) return path; + + const encodedRecord = encoded as Record; + for (const [key, value] of Object.entries(source)) { + if (!Object.prototype.hasOwnProperty.call(encodedRecord, key)) return `${path}.${key}`; + const mismatch = firstLossyGeneratedPath(value, encodedRecord[key], `${path}.${key}`, ancestors); + if (mismatch !== undefined) return mismatch; + } + return undefined; + } finally { + ancestors.delete(source); + } +} + /** * Internal type for the intermediate stock class data converted from DAML. * This represents the data structure before it's transformed to the final OCF output. */ -export function damlStockClassDataToNative( - damlData: Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData -): OcfStockClass { +export function damlStockClassDataToNative(input: unknown): OcfStockClass { + if (input !== null && typeof input === 'object' && !Array.isArray(input)) { + const raw = input as Record; + if (typeof raw.id !== 'string' || raw.id.length === 0) { + throw new OcpValidationError('stockClass.id', 'Required field is missing', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: raw.id, + }); + } + if (typeof raw.name !== 'string' || raw.name.length === 0) { + throw new OcpValidationError('stockClass.name', 'Required field is missing', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: raw.name, + }); + } + optionalDamlTimeToDateString(raw.board_approval_date, 'stockClass.board_approval_date'); + optionalDamlTimeToDateString(raw.stockholder_approval_date, 'stockClass.stockholder_approval_date'); + } + + let damlData: Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData; + try { + damlData = Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData.decoder.runWithException(input); + } catch (error) { + const rawMessage = error instanceof Error ? error.message : String(error); + const message = rawMessage.length > 500 ? `${rawMessage.slice(0, 500)}…` : rawMessage; + throw new OcpParseError(`Invalid DAML stock class data: ${message}`, { + source: 'stockClass', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + + let encoded: unknown; + try { + encoded = Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData.encode(damlData); + } catch (error) { + const rawMessage = error instanceof Error ? error.message : String(error); + const message = rawMessage.length > 500 ? `${rawMessage.slice(0, 500)}…` : rawMessage; + throw new OcpParseError(`Unable to encode DAML stock class data: ${message}`, { + source: 'stockClass', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + const lossyPath = firstLossyGeneratedPath(input, encoded, 'stockClass'); + if (lossyPath !== undefined) { + throw new OcpParseError(`Generated DAML decoding would discard or alter ${lossyPath}`, { + source: lossyPath, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + // Access fields via Record type to handle DAML union types that may vary from the SDK definition const damlRecord = damlData as Record; const dataWithId = damlRecord as { id?: string }; @@ -158,6 +239,104 @@ export function damlStockClassDataToNative( }); } + const unsupportedFields = [ + ['ceiling_price_per_share', right.ceiling_price_per_share], + ['custom_description', right.custom_description], + ['discount_rate', right.discount_rate], + ['expires_at', right.expires_at], + ['floor_price_per_share', right.floor_price_per_share], + ['percent_of_capitalization', right.percent_of_capitalization], + ['reference_share_price', right.reference_share_price], + ['reference_valuation_price_per_share', right.reference_valuation_price_per_share], + ['valuation_cap', right.valuation_cap], + ] as const; + for (const [field, value] of unsupportedFields) { + if (value !== null) { + throw new OcpValidationError( + `${path}.${field}`, + `DAML field ${field} cannot be represented by canonical OCF StockClassConversionRight`, + { code: OcpErrorCodes.SCHEMA_MISMATCH, receivedValue: value } + ); + } + } + + const trigger = right.conversion_trigger; + const triggerPath = `${path}.conversion_trigger`; + const expectedTriggerId = `ocp-sdk:stock-class:${damlData.id}:conversion-right:${index}:unspecified`; + if (trigger.trigger_id !== expectedTriggerId) { + throw new OcpValidationError(`${triggerPath}.trigger_id`, 'Unexpected storage-only trigger identifier', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: expectedTriggerId, + receivedValue: trigger.trigger_id, + }); + } + if (trigger.type_ !== 'OcfTriggerTypeTypeUnspecified') { + throw new OcpValidationError(`${triggerPath}.type`, 'Unexpected storage-only trigger type', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'OcfTriggerTypeTypeUnspecified', + receivedValue: trigger.type_, + }); + } + const populatedTriggerField = [ + ['end_date', trigger.end_date], + ['nickname', trigger.nickname], + ['start_date', trigger.start_date], + ['trigger_condition', trigger.trigger_condition], + ['trigger_date', trigger.trigger_date], + ['trigger_description', trigger.trigger_description], + ].find((entry) => entry[1] !== null); + if (populatedTriggerField) { + throw new OcpValidationError( + `${triggerPath}.${String(populatedTriggerField[0])}`, + 'Storage-only trigger fields must be empty', + { code: OcpErrorCodes.SCHEMA_MISMATCH, receivedValue: populatedTriggerField[1] } + ); + } + if (trigger.conversion_right.tag !== 'OcfRightConvertible') { + throw new OcpValidationError( + `${triggerPath}.conversion_right.tag`, + 'Unexpected storage-only conversion-right variant', + { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'OcfRightConvertible', + receivedValue: trigger.conversion_right.tag, + } + ); + } + const sentinelRight = trigger.conversion_right.value; + if (sentinelRight.type_ !== 'CONVERTIBLE_CONVERSION_RIGHT') { + throw new OcpValidationError( + `${triggerPath}.conversion_right.type`, + 'Unexpected storage-only conversion-right discriminator', + { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'CONVERTIBLE_CONVERSION_RIGHT', + receivedValue: sentinelRight.type_, + } + ); + } + if ( + sentinelRight.converts_to_stock_class_id !== right.converts_to_stock_class_id || + sentinelRight.converts_to_future_round !== right.converts_to_future_round + ) { + throw new OcpValidationError( + `${triggerPath}.conversion_right`, + 'Storage-only conversion right does not match its stock-class right', + { code: OcpErrorCodes.SCHEMA_MISMATCH } + ); + } + if ( + sentinelRight.conversion_mechanism.tag !== 'OcfConvMechCustom' || + sentinelRight.conversion_mechanism.value.custom_conversion_description !== + 'OCF stock-class conversion storage adapter' + ) { + throw new OcpValidationError( + `${triggerPath}.conversion_right.conversion_mechanism`, + 'Unexpected storage-only conversion mechanism', + { code: OcpErrorCodes.SCHEMA_MISMATCH } + ); + } + const mechanismObj: RatioConversionMechanism = { type: 'RATIO_CONVERSION', ratio: { diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 3ecfada1..4d1c5185 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -54,6 +54,19 @@ function stockClassConversionRightToDaml( stockClassId: string, index: number ): Fairmint.OpenCapTable.Types.Conversion.OcfStockClassConversionRight { + const rawRight = right as unknown as Record; + if (rawRight.type !== 'STOCK_CLASS_CONVERSION_RIGHT') { + throw new OcpValidationError( + `stockClass.conversion_rights[${index}].type`, + 'Stock-class conversion rights require the exact OCF discriminator', + { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'STOCK_CLASS_CONVERSION_RIGHT', + receivedValue: rawRight.type, + } + ); + } + const convertsToStockClassId = right.converts_to_stock_class_id; if (typeof convertsToStockClassId !== 'string' || convertsToStockClassId.length === 0) { throw new OcpValidationError( diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index fd220f5b..d5d1ccc9 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -252,6 +252,19 @@ function buildWarrantStockClassConversionRight( details: StockClassConversionRightInput, index: number ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { + const targetStockClassId = (details as { converts_to_stock_class_id?: unknown }).converts_to_stock_class_id; + if (typeof targetStockClassId !== 'string' || targetStockClassId.length === 0) { + throw new OcpValidationError( + `warrantIssuance.exercise_triggers[${index}].conversion_right.converts_to_stock_class_id`, + 'The current DAML package requires a target stock class for warrant stock-class conversion rights', + { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: targetStockClassId, + } + ); + } + // Runtime guard: protect against invalid data reaching this path (e.g. from untyped JSON) const mechType = (details.conversion_mechanism as { type: string }).type; if (mechType !== 'RATIO_CONVERSION') { @@ -267,8 +280,8 @@ function buildWarrantStockClassConversionRight( const value: Fairmint.OpenCapTable.Types.Conversion.OcfStockClassConversionRight = { type_: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: 'OcfConversionMechanismRatioConversion', - conversion_trigger: warrantNestedConversionTrigger(exerciseTrigger, details.converts_to_stock_class_id, index), - converts_to_stock_class_id: details.converts_to_stock_class_id, + conversion_trigger: warrantNestedConversionTrigger(exerciseTrigger, targetStockClassId, index), + converts_to_stock_class_id: targetStockClassId, ratio, conversion_price, converts_to_future_round, diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index 7a21be22..fd4fa15e 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -187,7 +187,7 @@ describe('damlToOcf dispatcher', () => { name: 'Common', class_type: 'OcfStockClassTypeCommon', default_id_prefix: 'CS-', - initial_shares_authorized: '1000', + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '1000' }, votes_per_share: '1', seniority: '1', conversion_rights: [], diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index c53b1a08..45657f21 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -84,6 +84,22 @@ describe('StockClass Converters', () => { votes_per_share: '1', }; + const stockClassWithRatioRight = (roundingType: 'CEILING' | 'FLOOR' | 'NORMAL' = 'NORMAL'): OcfStockClass => ({ + ...baseData, + conversion_rights: [ + { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '3', denominator: '2' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: roundingType, + }, + converts_to_stock_class_id: 'class-common', + }, + ], + }); + test('converts stockClass with numeric initial_shares_authorized as tagged union', () => { const result = convertToDaml('stockClass', baseData); @@ -148,20 +164,8 @@ describe('StockClass Converters', () => { test('keeps the DAML-only conversion trigger private and round-trips canonical OCF data exactly', () => { const dataWithConversionRight: OcfStockClass = { - ...baseData, + ...stockClassWithRatioRight(), comments: [], - conversion_rights: [ - { - type: 'STOCK_CLASS_CONVERSION_RIGHT', - conversion_mechanism: { - type: 'RATIO_CONVERSION', - ratio: { numerator: '3', denominator: '2' }, - conversion_price: { amount: '1', currency: 'USD' }, - rounding_type: 'NORMAL', - }, - converts_to_stock_class_id: 'class-common', - }, - ], }; const generatedCreate = buildOcfCreateData('stockClass', dataWithConversionRight); @@ -191,24 +195,78 @@ describe('StockClass Converters', () => { expect(damlStockClassDataToNative(decoded)).toEqual(dataWithConversionRight); }); + test.each([ + ['missing target', (right: Record) => ({ ...right, converts_to_stock_class_id: undefined })], + ['malformed ratio', (right: Record) => ({ ...right, ratio: { numerator: '3' } })], + ])('wraps %s ledger payloads instead of leaking raw TypeError', (_case, mutateRight) => { + const generated = buildOcfCreateData('stockClass', stockClassWithRatioRight()); + const right = generated.value.conversion_rights[0] as unknown as Record; + const malformed = { + ...generated.value, + conversion_rights: [mutateRight(right)], + }; + + let thrown: unknown; + expect(() => { + try { + damlStockClassDataToNative(malformed); + } catch (error) { + thrown = error; + throw error; + } + }).toThrow(); + expect(thrown).not.toBeInstanceOf(TypeError); + expect(thrown).toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH }); + }); + + test('rejects populated non-ratio DAML fields instead of dropping them', () => { + const generated = buildOcfCreateData('stockClass', stockClassWithRatioRight()); + const right = generated.value.conversion_rights[0]; + const withDiscount = { + ...generated.value, + conversion_rights: [{ ...right, discount_rate: '0.1' }], + }; + + expect(() => damlStockClassDataToNative(withDiscount)).toThrow( + expect.objectContaining({ + name: 'OcpValidationError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClass.conversion_rights[0].discount_rate', + receivedValue: '0.1', + }) + ); + }); + + test('rejects arbitrary DAML conversion triggers instead of silently discarding them', () => { + const generated = buildOcfCreateData('stockClass', stockClassWithRatioRight()); + const right = generated.value.conversion_rights[0]; + const withArbitraryTrigger = { + ...generated.value, + conversion_rights: [ + { + ...right, + conversion_trigger: { + ...right.conversion_trigger, + trigger_id: 'legacy-or-caller-supplied-trigger', + }, + }, + ], + }; + + expect(() => damlStockClassDataToNative(withArbitraryTrigger)).toThrow( + expect.objectContaining({ + name: 'OcpValidationError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClass.conversion_rights[0].conversion_trigger.trigger_id', + receivedValue: 'legacy-or-caller-supplied-trigger', + }) + ); + }); + test.each(['CEILING', 'FLOOR'] as const)( 'rejects %s rounding because DAML v34 cannot persist it', (roundingType) => { - const dataWithLossyRounding: OcfStockClass = { - ...baseData, - conversion_rights: [ - { - type: 'STOCK_CLASS_CONVERSION_RIGHT', - conversion_mechanism: { - type: 'RATIO_CONVERSION', - ratio: { numerator: '3', denominator: '2' }, - conversion_price: { amount: '1', currency: 'USD' }, - rounding_type: roundingType, - }, - converts_to_stock_class_id: 'class-common', - }, - ], - }; + const dataWithLossyRounding = stockClassWithRatioRight(roundingType); expect(() => convertToDaml('stockClass', dataWithLossyRounding)).toThrow( expect.objectContaining({ @@ -247,6 +305,21 @@ describe('StockClass Converters', () => { ); }); + test('rejects a runtime stock-class right without the exact OCF discriminator', () => { + const data = stockClassWithRatioRight(); + const right = { ...data.conversion_rights?.[0] } as Record; + delete right.type; + const missingType = { ...data, conversion_rights: [right] } as unknown as OcfStockClass; + + expect(() => convertToDaml('stockClass', missingType)).toThrow( + expect.objectContaining({ + name: 'OcpValidationError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClass.conversion_rights[0].type', + }) + ); + }); + test('throws error when id is missing', () => { const invalidData = { ...baseData, id: '' }; diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 8dd049d4..8e2a3fdc 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -577,6 +577,25 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(() => warrantIssuanceDataToDaml(input)).toThrow(/rounding_type/); }); + test('STOCK_CLASS_CONVERSION_RIGHT rejects a missing v34 target with an indexed SDK error', () => { + const trigger = stockClassTrigger(); + const right = { ...trigger.conversion_right } as Record; + delete right.converts_to_stock_class_id; + const error = captureError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [{ ...trigger, conversion_right: right }] as unknown as WarrantExerciseTriggerInput[], + }) + ); + + expect(error).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.converts_to_stock_class_id', + expectedType: 'non-empty string', + }); + }); + test('STOCK_CLASS_CONVERSION_RIGHT + RATIO_CONVERSION maps to OcfRightStockClass and round-trips', () => { const stockClassId = '16faa6e5-b13a-4dda-bad2-885fccd2975a'; const input = { diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 5bb51cef..35d7564e 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -115,13 +115,14 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { name: 'Series A', class_type: 'OcfStockClassTypePreferred', default_id_prefix: 'SA-', - initial_shares_authorized: '1000000', + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '1000000' }, votes_per_share: '1', seniority: '1', conversion_rights: [], + comments: [], liquidation_preference_multiple: '0', }; - const result = damlStockClassDataToNative(daml as unknown as Parameters[0]); + const result = damlStockClassDataToNative(daml); expect(result.liquidation_preference_multiple).toBe('0'); }); @@ -131,13 +132,14 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { name: 'Series B', class_type: 'OcfStockClassTypePreferred', default_id_prefix: 'SB-', - initial_shares_authorized: '1000000', + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '1000000' }, votes_per_share: '1', seniority: '2', conversion_rights: [], + comments: [], participation_cap_multiple: '0', }; - const result = damlStockClassDataToNative(daml as unknown as Parameters[0]); + const result = damlStockClassDataToNative(daml); expect(result.participation_cap_multiple).toBe('0'); }); diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 1d5143d8..2e3993f7 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -298,6 +298,7 @@ describe('DAML to OCF Validation', () => { votes_per_share: '1', seniority: '1', conversion_rights: [], + comments: [], }; test('throws OcpValidationError when id is missing', async () => { From 02bb2a1a263f7cd0a6da6aaacffca69602e08286 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 12:21:07 -0400 Subject: [PATCH 38/47] fix: address exact-head review feedback --- .../getEquityCompensationIssuanceAsOcf.ts | 7 +- .../stockClass/getStockClassAsOcf.ts | 8 +- .../stockClass/stockClassConversionStorage.ts | 5 + .../stockClass/stockClassDataToDaml.ts | 80 +++++++++-- .../vestingTerms/getVestingTermsAsOcf.ts | 8 +- test/converters/stockClassConverters.test.ts | 125 +++++++++++++++++- .../valuationVestingConverters.test.ts | 60 +++++++++ .../dateBoundaryInvariants.test.ts | 9 +- test/validation/damlToOcfValidation.test.ts | 22 +++ 9 files changed, 302 insertions(+), 22 deletions(-) create mode 100644 src/functions/OpenCapTable/stockClass/stockClassConversionStorage.ts diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 52410791..979c2797 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -56,8 +56,11 @@ const twMapPeriodType: Partial> = { * Used by both getEquityCompensationIssuanceAsOcf and the damlToOcf dispatcher. */ export function damlEquityCompensationIssuanceDataToNative(d: Record): OcfEquityCompensationIssuance { - const exercise_price = damlMonetaryToNativeWithValidation(d.exercise_price); - const base_price = damlMonetaryToNativeWithValidation(d.base_price); + const exercise_price = damlMonetaryToNativeWithValidation( + d.exercise_price, + 'equityCompensationIssuance.exercise_price' + ); + const base_price = damlMonetaryToNativeWithValidation(d.base_price, 'equityCompensationIssuance.base_price'); const vestings = Array.isArray(d.vestings) && d.vestings.length > 0 diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index 9548cca8..5e59b027 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -10,6 +10,10 @@ import { optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; +import { + STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION, + stockClassConversionStorageTriggerId, +} from './stockClassConversionStorage'; function firstLossyGeneratedPath( source: unknown, @@ -262,7 +266,7 @@ export function damlStockClassDataToNative(input: unknown): OcfStockClass { const trigger = right.conversion_trigger; const triggerPath = `${path}.conversion_trigger`; - const expectedTriggerId = `ocp-sdk:stock-class:${damlData.id}:conversion-right:${index}:unspecified`; + const expectedTriggerId = stockClassConversionStorageTriggerId(damlData.id, index); if (trigger.trigger_id !== expectedTriggerId) { throw new OcpValidationError(`${triggerPath}.trigger_id`, 'Unexpected storage-only trigger identifier', { code: OcpErrorCodes.SCHEMA_MISMATCH, @@ -328,7 +332,7 @@ export function damlStockClassDataToNative(input: unknown): OcfStockClass { if ( sentinelRight.conversion_mechanism.tag !== 'OcfConvMechCustom' || sentinelRight.conversion_mechanism.value.custom_conversion_description !== - 'OCF stock-class conversion storage adapter' + STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION ) { throw new OcpValidationError( `${triggerPath}.conversion_right.conversion_mechanism`, diff --git a/src/functions/OpenCapTable/stockClass/stockClassConversionStorage.ts b/src/functions/OpenCapTable/stockClass/stockClassConversionStorage.ts new file mode 100644 index 00000000..10a8edc9 --- /dev/null +++ b/src/functions/OpenCapTable/stockClass/stockClassConversionStorage.ts @@ -0,0 +1,5 @@ +export const STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION = 'OCF stock-class conversion storage adapter'; + +export function stockClassConversionStorageTriggerId(stockClassId: string, index: number): string { + return `ocp-sdk:stock-class:${stockClassId}:conversion-right:${index}:unspecified`; +} diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 4d1c5185..aa29359c 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -5,11 +5,16 @@ import { validateStockClassData } from '../../../utils/entityValidators'; import { stockClassTypeToDaml } from '../../../utils/enumConversions'; import { cleanComments, + damlMonetaryToNativeWithValidation, initialSharesAuthorizedToDaml, monetaryToDaml, normalizeNumericString, optionalDateStringToDAMLTime, } from '../../../utils/typeConversions'; +import { + STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION, + stockClassConversionStorageTriggerId, +} from './stockClassConversionStorage'; /** * Adapt the OCF/DAML v34 schema mismatch at the private storage boundary. @@ -31,14 +36,14 @@ function buildStorageOnlyStockClassTrigger( value: { conversion_mechanism: { tag: 'OcfConvMechCustom', - value: { custom_conversion_description: 'OCF stock-class conversion storage adapter' }, + value: { custom_conversion_description: STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION }, }, type_: 'CONVERTIBLE_CONVERSION_RIGHT', converts_to_future_round: right.converts_to_future_round ?? null, converts_to_stock_class_id: convertsToStockClassId, }, }, - trigger_id: `ocp-sdk:stock-class:${stockClassId}:conversion-right:${index}:unspecified`, + trigger_id: stockClassConversionStorageTriggerId(stockClassId, index), type_: 'OcfTriggerTypeTypeUnspecified', end_date: null, nickname: null, @@ -80,26 +85,83 @@ function stockClassConversionRightToDaml( ); } - const path = `stockClass.conversion_rights[${index}].conversion_mechanism.rounding_type`; - if (right.conversion_mechanism.rounding_type !== 'NORMAL') { + const mechanismPath = `stockClass.conversion_rights[${index}].conversion_mechanism`; + const rawMechanism = rawRight.conversion_mechanism; + if (rawMechanism === null || typeof rawMechanism !== 'object' || Array.isArray(rawMechanism)) { + throw new OcpValidationError(mechanismPath, 'A ratio conversion mechanism is required', { + code: + rawMechanism === undefined || rawMechanism === null + ? OcpErrorCodes.REQUIRED_FIELD_MISSING + : OcpErrorCodes.INVALID_TYPE, + expectedType: 'RatioConversionMechanism object', + receivedValue: rawMechanism, + }); + } + const mechanism = rawMechanism as Record; + if (mechanism.type !== 'RATIO_CONVERSION') { + throw new OcpValidationError(`${mechanismPath}.type`, 'Stock-class rights require a ratio conversion mechanism', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'RATIO_CONVERSION', + receivedValue: mechanism.type, + }); + } + + const roundingPath = `${mechanismPath}.rounding_type`; + if (mechanism.rounding_type !== 'NORMAL') { throw new OcpValidationError( - path, + roundingPath, 'The current DAML package does not persist stock-class conversion rounding; only NORMAL round-trips losslessly', { code: OcpErrorCodes.INVALID_FORMAT, expectedType: 'NORMAL', - receivedValue: right.conversion_mechanism.rounding_type, + receivedValue: mechanism.rounding_type, } ); } + const conversionPricePath = `${mechanismPath}.conversion_price`; + const conversionPrice = damlMonetaryToNativeWithValidation(mechanism.conversion_price, conversionPricePath); + if (conversionPrice === undefined) { + throw new OcpValidationError(conversionPricePath, 'A conversion price is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'Monetary object', + receivedValue: mechanism.conversion_price, + }); + } + + const ratioPath = `${mechanismPath}.ratio`; + const rawRatio = mechanism.ratio; + if (rawRatio === null || typeof rawRatio !== 'object' || Array.isArray(rawRatio)) { + throw new OcpValidationError(ratioPath, 'A conversion ratio is required', { + code: + rawRatio === undefined || rawRatio === null ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_TYPE, + expectedType: '{ numerator: string; denominator: string }', + receivedValue: rawRatio, + }); + } + const ratio = rawRatio as Record; + const requireRatioPart = (field: 'numerator' | 'denominator'): string => { + const value = ratio[field]; + const fieldPath = `${ratioPath}.${field}`; + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, `Conversion ratio ${field} must be a decimal string`, { + code: value === undefined || value === null ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_TYPE, + expectedType: 'decimal string', + receivedValue: value, + }); + } + return normalizeNumericString(value, fieldPath); + }; + const ratioNumerator = requireRatioPart('numerator'); + const ratioDenominator = requireRatioPart('denominator'); + return { conversion_mechanism: 'OcfConversionMechanismRatioConversion', conversion_trigger: buildStorageOnlyStockClassTrigger(right, convertsToStockClassId, stockClassId, index), converts_to_stock_class_id: convertsToStockClassId, type_: right.type, ceiling_price_per_share: null, - conversion_price: monetaryToDaml(right.conversion_mechanism.conversion_price), + conversion_price: monetaryToDaml(conversionPrice, conversionPricePath), converts_to_future_round: right.converts_to_future_round ?? null, custom_description: null, discount_rate: null, @@ -107,8 +169,8 @@ function stockClassConversionRightToDaml( floor_price_per_share: null, percent_of_capitalization: null, ratio: { - numerator: normalizeNumericString(right.conversion_mechanism.ratio.numerator), - denominator: normalizeNumericString(right.conversion_mechanism.ratio.denominator), + numerator: ratioNumerator, + denominator: ratioDenominator, }, reference_share_price: null, reference_valuation_price_per_share: null, diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index 1c861026..82f63df1 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -220,7 +220,7 @@ function damlVestingTriggerToNative( if (tag === 'OcfVestingScheduleRelativeTrigger') { const value = typeof t === 'string' ? undefined : t.value; if (!value || typeof value !== 'object') { - throw new OcpValidationError('vestingTrigger.value', 'Invalid value for OcfVestingScheduleRelativeTrigger', { + throw new OcpValidationError(`${fieldPath}.value`, 'Invalid value for OcfVestingScheduleRelativeTrigger', { code: OcpErrorCodes.INVALID_TYPE, receivedValue: value, }); @@ -232,7 +232,7 @@ function damlVestingTriggerToNative( !('tag' in periodValue) || typeof periodValue.tag !== 'string' ) { - throw new OcpValidationError('vestingTrigger.period', 'Invalid period in OcfVestingScheduleRelativeTrigger', { + throw new OcpValidationError(`${fieldPath}.period`, 'Invalid period in OcfVestingScheduleRelativeTrigger', { code: OcpErrorCodes.INVALID_TYPE, receivedValue: periodValue, }); @@ -240,7 +240,7 @@ function damlVestingTriggerToNative( const relativeToConditionId = value.relative_to_condition_id; if (typeof relativeToConditionId !== 'string' || relativeToConditionId.length === 0) { throw new OcpValidationError( - 'vestingTrigger.relative_to_condition_id', + `${fieldPath}.relative_to_condition_id`, 'Missing relative_to_condition_id for OcfVestingScheduleRelativeTrigger', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: relativeToConditionId } ); @@ -254,7 +254,7 @@ function damlVestingTriggerToNative( } throw new OcpParseError('Unknown DAML vesting trigger', { - source: 'vestingTrigger.tag', + source: `${fieldPath}.tag`, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index 45657f21..3bbc661e 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -11,10 +11,11 @@ */ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { buildOcfCreateData } from '../../src/functions/OpenCapTable/capTable/CapTableBatch'; import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; import type { OcfStockClass } from '../../src/types/native'; import { initialSharesAuthorizedToDaml } from '../../src/utils/typeConversions'; @@ -279,6 +280,128 @@ describe('StockClass Converters', () => { } ); + test.each([ + { + name: 'missing conversion_mechanism', + conversionMechanism: undefined, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }, + { + name: 'malformed conversion_mechanism', + conversionMechanism: 'RATIO_CONVERSION', + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism', + code: OcpErrorCodes.INVALID_TYPE, + }, + { + name: 'missing conversion_price', + conversionMechanism: { + type: 'RATIO_CONVERSION', + rounding_type: 'NORMAL', + ratio: { numerator: '3', denominator: '2' }, + }, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.conversion_price', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }, + { + name: 'malformed conversion_price', + conversionMechanism: { + type: 'RATIO_CONVERSION', + rounding_type: 'NORMAL', + conversion_price: 'USD 1', + ratio: { numerator: '3', denominator: '2' }, + }, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.conversion_price', + code: OcpErrorCodes.INVALID_TYPE, + }, + { + name: 'missing ratio', + conversionMechanism: { + type: 'RATIO_CONVERSION', + rounding_type: 'NORMAL', + conversion_price: { amount: '1', currency: 'USD' }, + }, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.ratio', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }, + { + name: 'malformed ratio', + conversionMechanism: { + type: 'RATIO_CONVERSION', + rounding_type: 'NORMAL', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: '3/2', + }, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.ratio', + code: OcpErrorCodes.INVALID_TYPE, + }, + { + name: 'missing ratio numerator', + conversionMechanism: { + type: 'RATIO_CONVERSION', + rounding_type: 'NORMAL', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { denominator: '2' }, + }, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.ratio.numerator', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }, + { + name: 'malformed ratio numerator', + conversionMechanism: { + type: 'RATIO_CONVERSION', + rounding_type: 'NORMAL', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: 3, denominator: '2' }, + }, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.ratio.numerator', + code: OcpErrorCodes.INVALID_TYPE, + }, + { + name: 'missing ratio denominator', + conversionMechanism: { + type: 'RATIO_CONVERSION', + rounding_type: 'NORMAL', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '3' }, + }, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.ratio.denominator', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }, + { + name: 'malformed ratio denominator', + conversionMechanism: { + type: 'RATIO_CONVERSION', + rounding_type: 'NORMAL', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '3', denominator: 2 }, + }, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.ratio.denominator', + code: OcpErrorCodes.INVALID_TYPE, + }, + ])('wraps $name as OcpValidationError instead of leaking TypeError', ({ conversionMechanism, fieldPath, code }) => { + const valid = stockClassWithRatioRight(); + const originalRight = valid.conversion_rights?.[0]; + if (!originalRight) throw new Error('Expected stock-class conversion-right fixture'); + const malformed = { + ...valid, + conversion_rights: [{ ...originalRight, conversion_mechanism: conversionMechanism }], + } as unknown as OcfStockClass; + + let thrown: unknown; + expect(() => { + try { + stockClassDataToDaml(malformed); + } catch (error) { + thrown = error; + throw error; + } + }).toThrow(); + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(thrown).not.toBeInstanceOf(TypeError); + expect(thrown).toMatchObject({ code, fieldPath }); + }); + test('rejects an OCF future-round right that the current DAML package cannot target', () => { const futureRoundRight: OcfStockClass = { ...baseData, diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index edea8306..12357f8a 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -707,6 +707,66 @@ describe('VestingTerms drift regression', () => { ); }); + test.each([ + { + name: 'missing value', + trigger: { tag: 'OcfVestingScheduleRelativeTrigger' }, + fieldPath: 'vestingTerms.vesting_conditions[1].trigger.value', + }, + { + name: 'missing period', + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { relative_to_condition_id: 'start' }, + }, + fieldPath: 'vestingTerms.vesting_conditions[1].trigger.period', + }, + { + name: 'missing relative condition id', + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { period: { tag: 'OcfVestingPeriodDays' } }, + }, + fieldPath: 'vestingTerms.vesting_conditions[1].trigger.relative_to_condition_id', + }, + ])('reports the exact vesting-condition index for a relative trigger with $name', ({ trigger, fieldPath }) => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'bad-relative-trigger', + description: null, + quantity: null, + portion: null, + trigger, + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + name: 'OcpValidationError', + fieldPath, + }) + ); + }); + + test('reports the exact vesting-condition index for an unknown trigger tag', () => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'unknown-trigger', + description: null, + quantity: null, + portion: null, + trigger: { tag: 'OcfUnknownVestingTrigger' }, + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + name: 'OcpParseError', + source: 'vestingTerms.vesting_conditions[1].trigger.tag', + }) + ); + }); + test('preserves remainder: true', () => { const daml = makeDamlVestingTerms(); ( diff --git a/test/schemaAlignment/dateBoundaryInvariants.test.ts b/test/schemaAlignment/dateBoundaryInvariants.test.ts index 7029d5f4..07a1aa07 100644 --- a/test/schemaAlignment/dateBoundaryInvariants.test.ts +++ b/test/schemaAlignment/dateBoundaryInvariants.test.ts @@ -204,10 +204,11 @@ describe('date boundary source invariants', () => { violations.push( `${location(sourceFile, node)} ${node.expression.text} must use its discriminator-correlated signature` ); - } - const fieldPath = node.arguments[expectedArgumentCount - 1]; - if (fieldPath.getText(sourceFile).includes('[]')) { - violations.push(`${location(sourceFile, fieldPath)} trigger array fieldPath must include its index`); + } else { + const fieldPath = node.arguments[expectedArgumentCount - 1]; + if (fieldPath.getText(sourceFile).includes('[]')) { + violations.push(`${location(sourceFile, fieldPath)} trigger array fieldPath must include its index`); + } } } diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 2e3993f7..ac17e137 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -138,6 +138,28 @@ describe('DAML to OCF Validation', () => { ); }); + test.each(['exercise_price', 'base_price'] as const)( + 'reports the contextual %s path for malformed monetary data', + async (field) => { + const invalidData = { + ...validIssuanceData, + [field]: { amount: 1, currency: 'USD' }, + }; + const client = createMockClient('issuance_data', invalidData, { + templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, + }); + + await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject( + { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `equityCompensationIssuance.${field}.amount`, + receivedValue: 1, + } + ); + } + ); + test('succeeds with valid data', async () => { const client = createMockClient('issuance_data', validIssuanceData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, From f094c35854a434e89da4ef389b9f7d17c846807a Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 12:57:10 -0400 Subject: [PATCH 39/47] fix: preserve vesting amount diagnostic indexes --- .../getEquityCompensationIssuanceAsOcf.ts | 14 +++++++---- .../converters/dateBoundaryValidation.test.ts | 24 ++++++++++++++++++- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 979c2797..06dad855 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -67,11 +67,15 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record).map((v, index) => { // Validate vesting amount if (typeof v.amount !== 'string' && typeof v.amount !== 'number') { - throw new OcpValidationError('vesting.amount', `Must be string or number, got ${typeof v.amount}`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: v.amount, - }); + throw new OcpValidationError( + `equityCompensationIssuance.vestings[${index}].amount`, + `Must be string or number, got ${typeof v.amount}`, + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string | number', + receivedValue: v.amount, + } + ); } // Convert to string after validation const amountStr = typeof v.amount === 'number' ? v.amount.toString() : v.amount; diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index e0c6546e..4e68bf81 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -1,4 +1,4 @@ -import { OcpErrorCodes } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; import { damlEquityCompensationIssuanceDataToNative } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; import { issuerAuthorizedSharesAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment'; @@ -406,6 +406,28 @@ describe('OCF write converter optional date boundaries', () => { ); }); + test('reports the original array index for an invalid equity-compensation vesting amount', () => { + const invalidAmount = { value: '1' }; + try { + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + vestings: [ + { date: '2024-01-15T00:00:00Z', amount: '1' }, + { date: '2024-01-16T00:00:00Z', amount: invalidAmount }, + ], + }); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'equityCompensationIssuance.vestings[1].amount', + receivedValue: invalidAmount, + }); + return; + } + throw new Error('Expected invalid vesting amount to be rejected'); + }); + test.each([ ['undefined', undefined, OcpErrorCodes.INVALID_TYPE], ['empty', '', OcpErrorCodes.INVALID_FORMAT], From d9c6080be18e5341d0ab2c2761371785dfad53cf Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 15:07:31 -0400 Subject: [PATCH 40/47] fix: isolate malformed manifest transactions --- src/utils/cantonOcfExtractor.ts | 43 +++++++---- test/utils/extractCantonOcfManifest.test.ts | 85 +++++++++++++++++++++ 2 files changed, 114 insertions(+), 14 deletions(-) diff --git a/src/utils/cantonOcfExtractor.ts b/src/utils/cantonOcfExtractor.ts index 16d48dc5..9c8462b0 100644 --- a/src/utils/cantonOcfExtractor.ts +++ b/src/utils/cantonOcfExtractor.ts @@ -260,6 +260,21 @@ export function sortTransactions(transactions: Array>): return decorated.map((item) => item.tx); } +/** + * Validate a transaction's sort boundary before adding it to an extracted manifest. + * + * Extraction handles conversion failures per source contract. Validating here keeps + * malformed dates inside that same failure boundary, so partial extraction can skip + * only the invalid contract instead of failing later while sorting the whole manifest. + */ +function appendValidatedTransaction( + transactions: Array>, + transaction: Record +): void { + buildTransactionSortKey(transaction); + transactions.push(transaction); +} + /** * Entity types that are classified as transactions for buildCaptableInput. * All entity types except core objects and a few non-transaction types. @@ -392,12 +407,12 @@ export interface ExtractCantonOcfOptions { /** * Compatibility mode for callers that intentionally accept partial manifests. * - * Default: true. Non-benign child read failures still throw with classified - * diagnostics. Archived or not-found contracts remain soft-skipped regardless - * of this flag. + * Default: true. Non-benign child read or conversion failures still throw with + * classified diagnostics. Archived or not-found contracts remain soft-skipped + * regardless of this flag. * - * Set to false to log and skip classified non-benign read failures instead of - * throwing, returning a partial manifest. + * Set to false to log and skip classified non-benign read or conversion failures + * instead of throwing, returning a partial manifest. */ failOnReadErrors?: boolean; } @@ -533,34 +548,34 @@ export async function extractCantonOcfManifest( result.vestingTerms.push(vestingTerms as unknown as Record); } else if (entityType === 'stockIssuance') { const { stockIssuance } = await getStockIssuanceAsOcf(client, { contractId, ...readScopeOpts }); - result.transactions.push(stockIssuance as unknown as Record); + appendValidatedTransaction(result.transactions, stockIssuance as unknown as Record); } else if (entityType === 'convertibleIssuance') { const { event } = await getConvertibleIssuanceAsOcf(client, { contractId, ...readScopeOpts }); - result.transactions.push(event as unknown as Record); + appendValidatedTransaction(result.transactions, event as unknown as Record); } else if (entityType === 'warrantIssuance') { const { warrantIssuance } = await getWarrantIssuanceAsOcf(client, { contractId, ...readScopeOpts }); - result.transactions.push(warrantIssuance as unknown as Record); + appendValidatedTransaction(result.transactions, warrantIssuance as unknown as Record); } else if (entityType === 'equityCompensationIssuance') { const { event } = await getEquityCompensationIssuanceAsOcf(client, { contractId, ...readScopeOpts }); - result.transactions.push(event as unknown as Record); + appendValidatedTransaction(result.transactions, event as unknown as Record); } else if (entityType === 'equityCompensationExercise') { const { event } = await getEquityCompensationExerciseAsOcf(client, { contractId, ...readScopeOpts }); - result.transactions.push(event as unknown as Record); + appendValidatedTransaction(result.transactions, event as unknown as Record); } else if (entityType === 'stockClassAuthorizedSharesAdjustment') { const { event } = await getStockClassAuthorizedSharesAdjustmentAsOcf(client, { contractId, ...readScopeOpts, }); - result.transactions.push(event as unknown as Record); + appendValidatedTransaction(result.transactions, event as unknown as Record); } else if (entityType === 'issuerAuthorizedSharesAdjustment') { const { event } = await getIssuerAuthorizedSharesAdjustmentAsOcf(client, { contractId, ...readScopeOpts, }); - result.transactions.push(event as unknown as Record); + appendValidatedTransaction(result.transactions, event as unknown as Record); } else if (entityType === 'stockPlanPoolAdjustment') { const { event } = await getStockPlanPoolAdjustmentAsOcf(client, { contractId, ...readScopeOpts }); - result.transactions.push(event as unknown as Record); + appendValidatedTransaction(result.transactions, event as unknown as Record); } else if (entityType === 'valuation') { const { valuation } = await getValuationAsOcf(client, { contractId, ...readScopeOpts }); result.valuations.push(valuation as unknown as Record); @@ -585,7 +600,7 @@ export async function extractCantonOcfManifest( { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); } - result.transactions.push(txData); + appendValidatedTransaction(result.transactions, txData); } // Unsupported types are silently skipped lastError = null; diff --git a/test/utils/extractCantonOcfManifest.test.ts b/test/utils/extractCantonOcfManifest.test.ts index 217b2cd1..286e8937 100644 --- a/test/utils/extractCantonOcfManifest.test.ts +++ b/test/utils/extractCantonOcfManifest.test.ts @@ -368,6 +368,91 @@ describe('extractCantonOcfManifest', () => { true ); }); + + it('should fail loud with contract diagnostics when a transaction has a malformed date', async () => { + const state = buildCapTableState({ + contractIds: new Map([['stockTransfer', new Map([['tx-invalid', 'stock-transfer-invalid-cid']])]]), + entities: new Map([['stockTransfer', new Set(['tx-invalid'])]]), + }); + + getEntityAsOcf.mockResolvedValue({ + data: { + id: 'tx-invalid', + object_type: 'TX_STOCK_TRANSFER', + date: '2024-02-30', + security_id: 'security-invalid', + }, + contractId: 'stock-transfer-invalid-cid', + }); + + await expect(extractCantonOcfManifest(mockClient, state)).rejects.toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + contractId: 'stock-transfer-invalid-cid', + message: 'Failed to fetch stockTransfer/tx-invalid (schema)', + diagnostics: { + classification: 'schema', + operation: 'extractCantonOcfManifest', + entityType: 'stockTransfer', + objectId: 'tx-invalid', + contractId: 'stock-transfer-invalid-cid', + attempts: 1, + }, + }); + expect(getEntityAsOcf).toHaveBeenCalledTimes(1); + }); + + it('should skip malformed transaction dates in partial mode and sort the valid transactions', async () => { + const state = buildCapTableState({ + contractIds: new Map([ + [ + 'stockTransfer', + new Map([ + ['tx-later', 'stock-transfer-later-cid'], + ['tx-invalid', 'stock-transfer-invalid-cid'], + ['tx-earlier', 'stock-transfer-earlier-cid'], + ]), + ], + ]), + entities: new Map([['stockTransfer', new Set(['tx-later', 'tx-invalid', 'tx-earlier'])]]), + }); + + getEntityAsOcf.mockImplementation((_client: LedgerJsonApiClient, _entityType: string, contractId: string) => { + const transactionsByContractId: Record> = { + 'stock-transfer-later-cid': { + id: 'tx-later', + object_type: 'TX_STOCK_TRANSFER', + date: '2025-01-03', + security_id: 'security-1', + }, + 'stock-transfer-invalid-cid': { + id: 'tx-invalid', + object_type: 'TX_STOCK_TRANSFER', + date: 'not-a-date', + security_id: 'security-1', + }, + 'stock-transfer-earlier-cid': { + id: 'tx-earlier', + object_type: 'TX_STOCK_TRANSFER', + date: '2025-01-01', + security_id: 'security-1', + }, + }; + return { data: transactionsByContractId[contractId], contractId }; + }); + + const logs: string[] = []; + const manifest = await extractCantonOcfManifest(mockClient, state, { + failOnReadErrors: false, + logger: (msg: string) => logs.push(msg), + }); + + expect(manifest.transactions.map((transaction) => transaction.id)).toEqual(['tx-earlier', 'tx-later']); + expect(logs.some((l) => l.includes('Failed to fetch stockTransfer/tx-invalid [schema]'))).toBe(true); + expect(logs.some((l) => l.includes('Continuing with partial manifest because failOnReadErrors=false'))).toBe( + true + ); + expect(getEntityAsOcf).toHaveBeenCalledTimes(3); + }); }); describe('empty state', () => { From 351c6668ebacc4b61f8bd2145354152989f1d352 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 15:08:22 -0400 Subject: [PATCH 41/47] refactor: centralize generated operation building --- .../capTable/generatedBatchOperations.ts | 97 +++++++++++-------- .../generatedOperationConstruction.test.ts | 19 ++++ 2 files changed, 76 insertions(+), 40 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts b/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts index 76ccf685..6e966e1c 100644 --- a/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts +++ b/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts @@ -12,10 +12,12 @@ import { type OcfEditDataFor, } from './batchTypes'; import type { + OcfCreatableEntityType, OcfCreateArguments, OcfCreateOperation, OcfDeletableEntityType, OcfDeleteOperation, + OcfEditableEntityType, OcfEditArguments, OcfEditOperation, OcfEntityType, @@ -44,17 +46,57 @@ function decodeGeneratedOperation( } } -function buildOcfCreateDataWith(type: OcfEntityType, convert: () => unknown): OcfCreateData { - if (!isOcfCreatableEntityType(type)) { - throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); +interface GeneratedOperationBuilder { + readonly operation: 'create' | 'edit' | 'delete'; + readonly displayName: 'Create' | 'Edit' | 'Delete'; + readonly supports: (type: string) => type is EntityType; + readonly tagFor: (type: EntityType) => string; + readonly decoder: { runWithException: (input: unknown) => Data }; +} + +const CREATE_OPERATION_BUILDER: GeneratedOperationBuilder = { + operation: 'create', + displayName: 'Create', + supports: isOcfCreatableEntityType, + tagFor: (type) => ENTITY_TAG_MAP[type].create, + decoder: Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, +}; + +const EDIT_OPERATION_BUILDER: GeneratedOperationBuilder = { + operation: 'edit', + displayName: 'Edit', + supports: isOcfEditableEntityType, + tagFor: (type) => ENTITY_TAG_MAP[type].edit, + decoder: Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, +}; + +const DELETE_OPERATION_BUILDER: GeneratedOperationBuilder = { + operation: 'delete', + displayName: 'Delete', + supports: isOcfDeletableEntityType, + tagFor: (type) => ENTITY_TAG_MAP[type].delete, + decoder: Fairmint.OpenCapTable.CapTable.OcfDeleteData.decoder, +}; + +function buildGeneratedOperationData( + type: OcfEntityType, + convert: () => unknown, + builder: GeneratedOperationBuilder +): Data { + if (!builder.supports(type)) { + throw new OcpValidationError( + 'type', + `${builder.displayName} operation not supported for entity type: ${String(type)}`, + { + code: OcpErrorCodes.INVALID_TYPE, + } + ); } return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, - { tag: ENTITY_TAG_MAP[type].create, value: convert() }, - 'create', + builder.decoder, + { tag: builder.tagFor(type), value: convert() }, + builder.operation, type ); } @@ -65,27 +107,12 @@ export function buildOcfCreateData( ): OcfCreateDataFor; export function buildOcfCreateData(...args: OcfCreateArguments): OcfCreateData { const [type] = args; - return buildOcfCreateDataWith(type, () => convertToDaml(...args)); + return buildGeneratedOperationData(type, () => convertToDaml(...args), CREATE_OPERATION_BUILDER); } export function buildOcfCreateDataFromOperation(operation: OcfCreateOperation): OcfCreateData { const { type } = operation; - return buildOcfCreateDataWith(type, () => convertOperationToDaml(operation)); -} - -function buildOcfEditDataWith(type: OcfEntityType, convert: () => unknown): OcfEditData { - if (!isOcfEditableEntityType(type)) { - throw new OcpValidationError('type', `Edit operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, - { tag: ENTITY_TAG_MAP[type].edit, value: convert() }, - 'edit', - type - ); + return buildGeneratedOperationData(type, () => convertOperationToDaml(operation), CREATE_OPERATION_BUILDER); } /** @internal Build and validate one generated DAML edit variant. */ @@ -94,12 +121,12 @@ export function buildOcfEditData( ): OcfEditDataFor; export function buildOcfEditData(...args: OcfEditArguments): OcfEditData { const [type] = args; - return buildOcfEditDataWith(type, () => convertToDaml(...args)); + return buildGeneratedOperationData(type, () => convertToDaml(...args), EDIT_OPERATION_BUILDER); } export function buildOcfEditDataFromOperation(operation: OcfEditOperation): OcfEditData { const { type } = operation; - return buildOcfEditDataWith(type, () => convertOperationToDaml(operation)); + return buildGeneratedOperationData(type, () => convertOperationToDaml(operation), EDIT_OPERATION_BUILDER); } /** @internal Build and validate one generated DAML delete variant. */ @@ -108,20 +135,10 @@ export function buildOcfDeleteData; export function buildOcfDeleteData(type: OcfDeletableEntityType, id: string): OcfDeleteData { - if (!isOcfDeletableEntityType(type)) { - throw new OcpValidationError('type', `Delete operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfDeleteData.decoder, - { tag: ENTITY_TAG_MAP[type].delete, value: id }, - 'delete', - type - ); + return buildGeneratedOperationData(type, () => id, DELETE_OPERATION_BUILDER); } export function buildOcfDeleteDataFromOperation(operation: OcfDeleteOperation): OcfDeleteData { - return buildOcfDeleteData(operation.type, operation.id); + const { type } = operation; + return buildGeneratedOperationData(type, () => operation.id, DELETE_OPERATION_BUILDER); } diff --git a/test/batch/generatedOperationConstruction.test.ts b/test/batch/generatedOperationConstruction.test.ts index 8a36b7ed..a7bfd168 100644 --- a/test/batch/generatedOperationConstruction.test.ts +++ b/test/batch/generatedOperationConstruction.test.ts @@ -14,6 +14,7 @@ import { buildOcfCreateData, buildOcfCreateDataFromOperation, buildOcfDeleteData, + buildOcfDeleteDataFromOperation, buildOcfEditData, buildOcfEditDataFromOperation, } from '../../src/functions/OpenCapTable/capTable/generatedBatchOperations'; @@ -145,6 +146,18 @@ describe('generated DAML batch operation construction', () => { type: string; data: unknown; }) => unknown; + const untypedDelete = buildOcfDeleteData as unknown as (type: string, id: string) => unknown; + const untypedDeleteOperation = buildOcfDeleteDataFromOperation as unknown as (operation: { + type: string; + readonly id: string; + }) => unknown; + const unsupportedDeleteOperation = { + type: 'issuer', + get id(): string { + payloadWasRead = true; + throw new Error('builder must not read an unsupported identifier'); + }, + }; expect(() => untypedCreate('issuer', poisonPayload)).toThrow( 'Create operation not supported for entity type: issuer' @@ -158,6 +171,12 @@ describe('generated DAML batch operation construction', () => { expect(() => untypedEditOperation({ type: 'not-real', data: poisonPayload })).toThrow( 'Edit operation not supported for entity type: not-real' ); + expect(() => untypedDelete('issuer', 'unsupported-delete-id')).toThrow( + 'Delete operation not supported for entity type: issuer' + ); + expect(() => untypedDeleteOperation(unsupportedDeleteOperation)).toThrow( + 'Delete operation not supported for entity type: issuer' + ); expect(payloadWasRead).toBe(false); }); From c267e67e65f6352c6615314cd453f97a645cefc1 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 15:12:54 -0400 Subject: [PATCH 42/47] refactor: correlate generated operation builders --- .../capTable/generatedBatchOperations.ts | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts b/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts index 6e966e1c..9c3c3cda 100644 --- a/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts +++ b/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts @@ -46,15 +46,29 @@ function decodeGeneratedOperation( } } -interface GeneratedOperationBuilder { - readonly operation: 'create' | 'edit' | 'delete'; - readonly displayName: 'Create' | 'Edit' | 'Delete'; - readonly supports: (type: string) => type is EntityType; - readonly tagFor: (type: EntityType) => string; - readonly decoder: { runWithException: (input: unknown) => Data }; +interface GeneratedOperationDataMap { + readonly create: OcfCreateData; + readonly edit: OcfEditData; + readonly delete: OcfDeleteData; } -const CREATE_OPERATION_BUILDER: GeneratedOperationBuilder = { +interface GeneratedOperationEntityTypeMap { + readonly create: OcfCreatableEntityType; + readonly edit: OcfEditableEntityType; + readonly delete: OcfDeletableEntityType; +} + +type GeneratedOperation = keyof GeneratedOperationDataMap; + +interface GeneratedOperationBuilder { + readonly operation: Operation; + readonly displayName: Capitalize; + readonly supports: (type: string) => type is GeneratedOperationEntityTypeMap[Operation]; + readonly tagFor: (type: GeneratedOperationEntityTypeMap[Operation]) => GeneratedOperationDataMap[Operation]['tag']; + readonly decoder: { runWithException: (input: unknown) => GeneratedOperationDataMap[Operation] }; +} + +const CREATE_OPERATION_BUILDER: GeneratedOperationBuilder<'create'> = { operation: 'create', displayName: 'Create', supports: isOcfCreatableEntityType, @@ -62,7 +76,7 @@ const CREATE_OPERATION_BUILDER: GeneratedOperationBuilder = { +const EDIT_OPERATION_BUILDER: GeneratedOperationBuilder<'edit'> = { operation: 'edit', displayName: 'Edit', supports: isOcfEditableEntityType, @@ -70,7 +84,7 @@ const EDIT_OPERATION_BUILDER: GeneratedOperationBuilder = { +const DELETE_OPERATION_BUILDER: GeneratedOperationBuilder<'delete'> = { operation: 'delete', displayName: 'Delete', supports: isOcfDeletableEntityType, @@ -78,11 +92,11 @@ const DELETE_OPERATION_BUILDER: GeneratedOperationBuilder( +function buildGeneratedOperationData( type: OcfEntityType, convert: () => unknown, - builder: GeneratedOperationBuilder -): Data { + builder: GeneratedOperationBuilder +): GeneratedOperationDataMap[Operation] { if (!builder.supports(type)) { throw new OcpValidationError( 'type', From 306ca74277fab09107e969e4faa435e067cb7128 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 15:58:49 -0400 Subject: [PATCH 43/47] fix: validate vesting element shapes --- .../getEquityCompensationIssuanceAsOcf.ts | 18 ++++++++++++--- .../converters/dateBoundaryValidation.test.ts | 23 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 06dad855..32d1a3e4 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -6,11 +6,11 @@ import type { OcfEquityCompensationIssuance, PeriodType, TerminationWindowReason, - Vesting, } from '../../../types/native'; import { damlMonetaryToNativeWithValidation, damlTimeToDateString, + isRecord, normalizeNumericString, nullableDamlTimeToDateString, optionalDamlTimeToDateString, @@ -64,7 +64,19 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record 0 - ? ((d.vestings as Array<{ date: string; amount?: unknown }>).map((v, index) => { + ? d.vestings.map((v, index) => { + if (!isRecord(v)) { + throw new OcpValidationError( + `equityCompensationIssuance.vestings[${index}]`, + `Must be an object, got ${v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v}`, + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: v, + } + ); + } + // Validate vesting amount if (typeof v.amount !== 'string' && typeof v.amount !== 'number') { throw new OcpValidationError( @@ -83,7 +95,7 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record { throw new Error('Expected invalid vesting amount to be rejected'); }); + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-a-vesting'], + ] as const)('rejects a %s equity-compensation vesting with an indexed structured error', (_case, invalidVesting) => { + try { + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + vestings: [{ date: '2024-01-15T00:00:00Z', amount: '1' }, invalidVesting], + }); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'equityCompensationIssuance.vestings[1]', + expectedType: 'object', + receivedValue: invalidVesting, + }); + return; + } + throw new Error('Expected malformed vesting to be rejected'); + }); + test.each([ ['undefined', undefined, OcpErrorCodes.INVALID_TYPE], ['empty', '', OcpErrorCodes.INVALID_FORMAT], From de8198cbc21d2ad6d1d5d61f1ba2061f57d4c09c Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 16:15:11 -0400 Subject: [PATCH 44/47] fix: validate warrant vesting shapes --- .../getWarrantIssuanceAsOcf.ts | 15 ++++++++++++- .../warrantIssuanceConverters.test.ts | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 1c033a4f..14c35861 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -16,6 +16,7 @@ import { damlMonetaryToNative, damlMonetaryToNativeWithValidation, damlTimeToDateString, + isRecord, mapDamlTriggerTypeToOcf, normalizeNumericString, optionalDamlTimeToDateString, @@ -403,7 +404,19 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf const vestings: VestingSimple[] | undefined = Array.isArray(d.vestings) && d.vestings.length > 0 - ? (d.vestings as Array<{ date: string; amount?: unknown }>).map((v, index) => { + ? d.vestings.map((v, index) => { + if (!isRecord(v)) { + throw new OcpValidationError( + `warrantIssuance.vestings[${index}]`, + `Must be an object, got ${v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v}`, + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: v, + } + ); + } + if (typeof v.amount !== 'string' && typeof v.amount !== 'number') { throw new OcpValidationError( `warrantIssuance.vestings[${index}].amount`, diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 8e2a3fdc..5bec4d73 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -507,6 +507,28 @@ describe('WarrantIssuance round-trip equivalence', () => { }); }); + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-a-vesting'], + ] as const)('rejects a %s warrant vesting with an indexed structured error', (_case, invalidVesting) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const error = captureError(() => + damlWarrantIssuanceDataToNative({ + ...daml, + vestings: [{ date: '2024-01-15T00:00:00Z', amount: '1' }, invalidVesting], + }) + ); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'warrantIssuance.vestings[1]', + expectedType: 'object', + receivedValue: invalidVesting, + }); + }); + test('uses identical canonical AUTOMATIC_ON_DATE semantics for outer and nested stock-class triggers', () => { const result = warrantIssuanceDataToDaml({ ...baseWarrantIssuance, From a37e1f58f47ed0349d45a03d61ddebc52a4cc74c Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 16:33:20 -0400 Subject: [PATCH 45/47] fix: harden nested DAML collections --- .../getConvertibleIssuanceAsOcf.ts | 22 ++- .../getEquityCompensationIssuanceAsOcf.ts | 151 +++++++++++++----- .../stockIssuance/getStockIssuanceAsOcf.ts | 91 +++++++++-- .../vestingTerms/getVestingTermsAsOcf.ts | 138 +++++++++++----- .../convertibleIssuanceConverters.test.ts | 51 ++++++ .../converters/dateBoundaryValidation.test.ts | 122 ++++++++++++++ .../valuationVestingConverters.test.ts | 98 ++++++++++++ .../stockIssuanceReadConversions.test.ts | 78 +++++++++ 8 files changed, 645 insertions(+), 106 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index fd0b5477..d32e91a9 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -10,6 +10,7 @@ import type { import { damlMonetaryToNativeWithValidation, damlTimeToDateString, + isRecord, mapDamlTriggerTypeToOcf, normalizeNumericString, optionalDamlTimeToDateString, @@ -347,10 +348,25 @@ const convertTriggers = (ts: unknown[] | undefined): ConvertibleConversionTrigge return mech; } case 'OcfConvMechNote': { - const interest_rates = Array.isArray(value.interest_rates) - ? value.interest_rates.map((ir: unknown, interestRateIndex: number) => { + const rawInterestRates = value.interest_rates; + if (rawInterestRates !== null && rawInterestRates !== undefined && !Array.isArray(rawInterestRates)) { + throw new OcpValidationError(mechanismField('interest_rates'), 'Interest rates must be an array', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'array | null', + receivedValue: rawInterestRates, + }); + } + const interest_rates = Array.isArray(rawInterestRates) + ? rawInterestRates.map((ir: unknown, interestRateIndex: number) => { const interestRatePath = `${mechanismPath}.interest_rates[${interestRateIndex}]`; - const irObj = ir as Record; + if (!isRecord(ir)) { + throw new OcpValidationError(interestRatePath, 'Interest rate must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: ir, + }); + } + const irObj = ir; // Validate interest rate if (irObj.rate === undefined || irObj.rate === null) { throw new OcpValidationError(`${interestRatePath}.rate`, 'Required field is missing', { diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 32d1a3e4..d6cbc07b 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -51,6 +51,54 @@ const twMapPeriodType: Partial> = { OcfPeriodYears: 'YEARS', }; +function requireCollectionRecord(value: unknown, fieldPath: string): Record { + if (!isRecord(value)) { + throw new OcpValidationError(fieldPath, 'Must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: value, + }); + } + return value; +} + +function requireCollectionString(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) { + throw new OcpValidationError(fieldPath, 'Required field is missing', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, 'Must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + if (value.length === 0) { + throw new OcpValidationError(fieldPath, 'Must be a non-empty string', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + return value; +} + +function optionalCollection(value: unknown, fieldPath: string): unknown[] | undefined { + if (value === null || value === undefined) return undefined; + if (!Array.isArray(value)) { + throw new OcpValidationError(fieldPath, 'Must be an array', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'array | null', + receivedValue: value, + }); + } + return value.length > 0 ? value : undefined; +} + /** * Converts DAML equity compensation issuance data to native OCF format. * Used by both getEquityCompensationIssuanceAsOcf and the damlToOcf dispatcher. @@ -98,46 +146,54 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record 0 - ? (d.termination_exercise_windows as Array<{ reason: string; period: string | number; period_type: string }>).map( - (w) => { - const reason = twMapReason[w.reason]; - if (!reason) { - throw new OcpValidationError('termination_exercise_window.reason', `Unknown reason: ${w.reason}`, { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - receivedValue: w.reason, + const terminationWindows = optionalCollection( + d.termination_exercise_windows, + 'equityCompensationIssuance.termination_exercise_windows' + ); + const termination_exercise_windows = terminationWindows + ? terminationWindows.map((rawWindow, index) => { + const windowPath = `equityCompensationIssuance.termination_exercise_windows[${index}]`; + const window = requireCollectionRecord(rawWindow, windowPath); + const reasonValue = requireCollectionString(window.reason, `${windowPath}.reason`); + const reason = twMapReason[reasonValue]; + if (!reason) { + throw new OcpValidationError(`${windowPath}.reason`, `Unknown reason: ${reasonValue}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + receivedValue: reasonValue, + }); + } + const periodTypeValue = requireCollectionString(window.period_type, `${windowPath}.period_type`); + const periodType = twMapPeriodType[periodTypeValue]; + if (!periodType) { + throw new OcpValidationError(`${windowPath}.period_type`, `Unknown period_type: ${periodTypeValue}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + receivedValue: periodTypeValue, + }); + } + return { + reason, + period: (() => { + if (typeof window.period !== 'string' && typeof window.period !== 'number') { + throw new OcpValidationError(`${windowPath}.period`, 'Must be a string or number', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'finite number', + receivedValue: window.period, }); } - const periodType = twMapPeriodType[w.period_type]; - if (!periodType) { - throw new OcpValidationError( - 'termination_exercise_window.period_type', - `Unknown period_type: ${w.period_type}`, - { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - receivedValue: w.period_type, - } - ); + const p = typeof window.period === 'string' ? Number(window.period) : window.period; + if (!Number.isFinite(p)) { + throw new OcpValidationError(`${windowPath}.period`, `Invalid period: ${String(window.period)}`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'finite number', + receivedValue: window.period, + }); } - return { - reason, - period: (() => { - const p = typeof w.period === 'string' ? Number(w.period) : w.period; - if (!Number.isFinite(p)) { - throw new OcpValidationError('termination_exercise_window.period', `Invalid period: ${w.period}`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'finite number', - receivedValue: w.period, - }); - } - return p; - })(), - period_type: periodType, - }; - } - ) - : undefined; + return p; + })(), + period_type: periodType, + }; + }) + : undefined; const comments = Array.isArray(d.comments) && d.comments.length > 0 ? (d.comments as string[]) : undefined; @@ -206,13 +262,20 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record 0 - ? (d.security_law_exemptions as Array<{ description: string; jurisdiction: string }>).map((ex) => ({ - description: ex.description, - jurisdiction: ex.jurisdiction, - })) - : undefined; + const securityLawExemptions = optionalCollection( + d.security_law_exemptions, + 'equityCompensationIssuance.security_law_exemptions' + ); + const security_law_exemptions = securityLawExemptions + ? securityLawExemptions.map((rawExemption, index) => { + const exemptionPath = `equityCompensationIssuance.security_law_exemptions[${index}]`; + const exemption = requireCollectionRecord(rawExemption, exemptionPath); + return { + description: requireCollectionString(exemption.description, `${exemptionPath}.description`), + jurisdiction: requireCollectionString(exemption.jurisdiction, `${exemptionPath}.jurisdiction`), + }; + }) + : undefined; const boardApprovalDate = optionalDamlTimeToDateString( d.board_approval_date, diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index 0ffe29da..ef99f3b3 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -12,14 +12,75 @@ import { } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; -function damlSecurityExemptionToNative(e: Fairmint.OpenCapTable.Types.Stock.OcfSecurityExemption): SecurityExemption { - return { description: e.description, jurisdiction: e.jurisdiction }; +function requireStockIssuanceCollectionRecord(value: unknown, fieldPath: string): Record { + if (!isRecord(value)) { + throw new OcpValidationError(fieldPath, 'Must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: value, + }); + } + return value; } -function damlShareNumberRangeToNative(r: Fairmint.OpenCapTable.Types.Stock.OcfShareNumberRange): ShareNumberRange { +function requireStockIssuanceCollectionString(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) { + throw new OcpValidationError(fieldPath, 'Required field is missing', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, 'Must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + if (value.length === 0) { + throw new OcpValidationError(fieldPath, 'Must be a non-empty string', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + return value; +} + +function stockIssuanceCollection(value: unknown, fieldPath: string): unknown[] { + if (value === undefined) return []; + if (!Array.isArray(value)) { + throw new OcpValidationError(fieldPath, 'Must be an array', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'array', + receivedValue: value, + }); + } + return value; +} + +function damlSecurityExemptionToNative(value: unknown, index: number): SecurityExemption { + const fieldPath = `stockIssuance.security_law_exemptions[${index}]`; + const exemption = requireStockIssuanceCollectionRecord(value, fieldPath); return { - starting_share_number: r.starting_share_number, - ending_share_number: r.ending_share_number, + description: requireStockIssuanceCollectionString(exemption.description, `${fieldPath}.description`), + jurisdiction: requireStockIssuanceCollectionString(exemption.jurisdiction, `${fieldPath}.jurisdiction`), + }; +} + +function damlShareNumberRangeToNative(value: unknown, index: number): ShareNumberRange { + const fieldPath = `stockIssuance.share_numbers_issued[${index}]`; + const range = requireStockIssuanceCollectionRecord(value, fieldPath); + return { + starting_share_number: requireStockIssuanceCollectionString( + range.starting_share_number, + `${fieldPath}.starting_share_number` + ), + ending_share_number: requireStockIssuanceCollectionString( + range.ending_share_number, + `${fieldPath}.ending_share_number` + ), }; } @@ -116,6 +177,14 @@ export function damlStockIssuanceDataToNative( d.stockholder_approval_date, 'stockIssuance.stockholder_approval_date' ); + const securityLawExemptions = stockIssuanceCollection( + anyD.security_law_exemptions, + 'stockIssuance.security_law_exemptions' + ).map(damlSecurityExemptionToNative); + const shareNumbersIssued = stockIssuanceCollection( + anyD.share_numbers_issued, + 'stockIssuance.share_numbers_issued' + ).map(damlShareNumberRangeToNative); return { object_type: 'TX_STOCK_ISSUANCE', @@ -127,18 +196,10 @@ export function damlStockIssuanceDataToNative( ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), ...(d.consideration_text && { consideration_text: d.consideration_text }), - security_law_exemptions: (Array.isArray((anyD as { security_law_exemptions?: unknown }).security_law_exemptions) - ? (anyD as { security_law_exemptions: Fairmint.OpenCapTable.Types.Stock.OcfSecurityExemption[] }) - .security_law_exemptions - : [] - ).map(damlSecurityExemptionToNative), + security_law_exemptions: securityLawExemptions, stock_class_id: stockClassId, ...(d.stock_plan_id && { stock_plan_id: d.stock_plan_id }), - share_numbers_issued: Array.isArray((anyD as { share_numbers_issued?: unknown }).share_numbers_issued) - ? ( - anyD as { share_numbers_issued: Fairmint.OpenCapTable.Types.Stock.OcfShareNumberRange[] } - ).share_numbers_issued.map(damlShareNumberRangeToNative) - : [], + share_numbers_issued: shareNumbersIssued, share_price: damlMonetaryToNative(d.share_price), quantity: normalizeNumericString(d.quantity), ...(d.vesting_terms_id && { vesting_terms_id: d.vesting_terms_id }), diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index 82f63df1..bc419832 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -11,7 +11,7 @@ import type { VestingPeriod, VestingTrigger, } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, isRecord, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; function damlAllocationTypeToNative(t: Fairmint.OpenCapTable.OCF.VestingTerms.OcfAllocationType): AllocationType { @@ -190,11 +190,17 @@ function damlVestingPeriodToNative(p: { tag: string; value?: Record }, - fieldPath: string -): VestingTrigger { - const tag: string | undefined = typeof t === 'string' ? t : t.tag; +function damlVestingTriggerToNative(value: unknown, fieldPath: string): VestingTrigger { + const trigger = typeof value === 'string' ? undefined : isRecord(value) ? value : undefined; + if (typeof value !== 'string' && trigger === undefined) { + throw new OcpValidationError(fieldPath, 'Vesting trigger must be a string or object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string | object', + receivedValue: value, + }); + } + const tag: string | undefined = + typeof value === 'string' ? value : typeof trigger?.tag === 'string' ? trigger.tag : undefined; if (tag === 'OcfVestingStartTrigger') { return { type: 'VESTING_START_DATE' }; @@ -205,27 +211,27 @@ function damlVestingTriggerToNative( } if (tag === 'OcfVestingScheduleAbsoluteTrigger') { - const value = typeof t === 'string' ? undefined : t.value; - if (!value || typeof value !== 'object') + const triggerValue = trigger?.value; + if (!isRecord(triggerValue)) throw new OcpValidationError(`${fieldPath}.value`, 'Missing value for OcfVestingScheduleAbsoluteTrigger', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: value, + receivedValue: triggerValue, }); return { type: 'VESTING_SCHEDULE_ABSOLUTE', - date: damlTimeToDateString(value.date, `${fieldPath}.date`), + date: damlTimeToDateString(triggerValue.date, `${fieldPath}.date`), }; } if (tag === 'OcfVestingScheduleRelativeTrigger') { - const value = typeof t === 'string' ? undefined : t.value; - if (!value || typeof value !== 'object') { + const triggerValue = trigger?.value; + if (!isRecord(triggerValue)) { throw new OcpValidationError(`${fieldPath}.value`, 'Invalid value for OcfVestingScheduleRelativeTrigger', { code: OcpErrorCodes.INVALID_TYPE, - receivedValue: value, + receivedValue: triggerValue, }); } - const periodValue = (value as { period?: unknown }).period; + const periodValue = triggerValue.period; if ( !periodValue || typeof periodValue !== 'object' || @@ -237,7 +243,7 @@ function damlVestingTriggerToNative( receivedValue: periodValue, }); } - const relativeToConditionId = value.relative_to_condition_id; + const relativeToConditionId = triggerValue.relative_to_condition_id; if (typeof relativeToConditionId !== 'string' || relativeToConditionId.length === 0) { throw new OcpValidationError( `${fieldPath}.relative_to_condition_id`, @@ -259,22 +265,49 @@ function damlVestingTriggerToNative( }); } -function damlVestingConditionPortionToNative( - p: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion -): VestingConditionPortion { +function damlVestingConditionPortionToNative(value: unknown, fieldPath: string): VestingConditionPortion { + if (!isRecord(value)) { + throw new OcpValidationError(fieldPath, 'Vesting condition portion must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: value, + }); + } + if (value.remainder !== null && value.remainder !== undefined && typeof value.remainder !== 'boolean') { + throw new OcpValidationError(`${fieldPath}.remainder`, 'Vesting condition remainder must be a boolean', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'boolean', + receivedValue: value.remainder, + }); + } + const normalizePortionValue = (raw: unknown, path: string): string => { + if (typeof raw !== 'string' && typeof raw !== 'number') { + throw new OcpValidationError(path, 'Vesting condition portion must be a string or number', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string | number', + receivedValue: raw, + }); + } + return normalizeNumericString(raw, path); + }; return { - numerator: normalizeNumericString(p.numerator), - denominator: normalizeNumericString(p.denominator), - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- DAML Optional may serialize as undefined; include false - ...(p.remainder != null ? { remainder: p.remainder } : {}), + numerator: normalizePortionValue(value.numerator, `${fieldPath}.numerator`), + denominator: normalizePortionValue(value.denominator, `${fieldPath}.denominator`), + ...(value.remainder != null ? { remainder: value.remainder } : {}), }; } -function damlVestingConditionToNative( - c: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition, - index: number -): VestingCondition { - const conditionWithId = c as unknown as { id?: string }; +function damlVestingConditionToNative(value: unknown, index: number): VestingCondition { + const fieldPath = `vestingTerms.vesting_conditions[${index}]`; + if (!isRecord(value)) { + throw new OcpValidationError(fieldPath, 'Vesting condition must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: value, + }); + } + const c = value as unknown as Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition; + const conditionWithId = value as { id?: string }; const native: VestingCondition = { id: conditionWithId.id ?? '', ...(c.description && { description: c.description }), @@ -283,28 +316,43 @@ function damlVestingConditionToNative( next_condition_ids: c.next_condition_ids, }; const portionUnknown = c.portion as unknown; - if (portionUnknown) { + if (portionUnknown !== null && portionUnknown !== undefined) { if ( typeof portionUnknown === 'object' && 'tag' in portionUnknown && portionUnknown.tag === 'Some' && 'value' in portionUnknown ) { - const { value } = portionUnknown as { value: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion }; - native.portion = damlVestingConditionPortionToNative(value); + const { value: portionValue } = portionUnknown as { + value: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion; + }; + native.portion = damlVestingConditionPortionToNative(portionValue, `${fieldPath}.portion.value`); } else if (typeof portionUnknown === 'object') { - native.portion = damlVestingConditionPortionToNative( - portionUnknown as Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion - ); + native.portion = damlVestingConditionPortionToNative(portionUnknown, `${fieldPath}.portion`); + } else { + throw new OcpValidationError(`${fieldPath}.portion`, 'Vesting condition portion must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: portionUnknown, + }); } } return native; } export function damlVestingTermsDataToNative( - d: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData -): OcfVestingTerms { - const dataWithId = d as unknown as { id?: string }; + value: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData +): OcfVestingTerms; +export function damlVestingTermsDataToNative(value: unknown): OcfVestingTerms { + if (!isRecord(value)) { + throw new OcpValidationError('vestingTerms', 'Vesting terms data must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: value, + }); + } + const d = value as unknown as Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData; + const dataWithId = value as { id?: string }; // Validate required fields - fail fast if missing if (typeof dataWithId.id !== 'string' || dataWithId.id.length === 0) { @@ -329,6 +377,14 @@ export function damlVestingTermsDataToNative( const comments = Array.isArray((d as unknown as { comments?: unknown }).comments) ? (d as unknown as { comments: string[] }).comments : []; + const vestingConditions = value.vesting_conditions; + if (!Array.isArray(vestingConditions)) { + throw new OcpValidationError('vestingTerms.vesting_conditions', 'Vesting conditions must be an array', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'array', + receivedValue: vestingConditions, + }); + } return { object_type: 'VESTING_TERMS', @@ -336,7 +392,7 @@ export function damlVestingTermsDataToNative( name: d.name, description: d.description, allocation_type: damlAllocationTypeToNative(d.allocation_type), - vesting_conditions: d.vesting_conditions.map(damlVestingConditionToNative), + vesting_conditions: vestingConditions.map(damlVestingConditionToNative), ...(comments.length > 0 ? { comments } : {}), }; } @@ -365,13 +421,7 @@ export async function getVestingTermsAsOcf( function hasData( arg: unknown ): arg is { vesting_terms_data: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData } { - const record = arg as Record; - return ( - typeof arg === 'object' && - arg !== null && - 'vesting_terms_data' in record && - typeof record.vesting_terms_data === 'object' - ); + return isRecord(arg) && isRecord(arg.vesting_terms_data); } if (!hasData(createArgument)) { throw new OcpParseError('Vesting terms data not found in contract create argument', { diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 10ffb92e..d37e8874 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -796,6 +796,57 @@ describe('convertible issuance approval-date read boundaries', () => { ); }); + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-an-interest-rate'], + ] as const)('rejects a %s interest-rate element with an indexed structured error', (_case, invalidRate) => { + const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); + const mechanism = trigger.conversion_right.OcfRightConvertible.conversion_mechanism; + (mechanism.value.interest_rates as unknown[]).push(invalidRate); + + const error = captureError(() => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [trigger], + }) + ); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: noteInterestRatePath(0, 1), + expectedType: 'object', + receivedValue: invalidRate, + }); + }); + + test.each([ + ['record', { bad: true }], + ['primitive', 'not-interest-rates'], + ['number', 42], + ] as const)('rejects a present %s interest_rates collection', (_case, invalidRates) => { + const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); + const mechanism = trigger.conversion_right.OcfRightConvertible.conversion_mechanism; + mechanism.value.interest_rates = invalidRates as never; + + const error = captureError(() => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [trigger], + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `${conversionMechanismPath()}.interest_rates`, + expectedType: 'array | null', + receivedValue: invalidRates, + }); + }); + test('omits a null note accrual_end_date on readback', () => { const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); const mechanism = trigger.conversion_right.OcfRightConvertible.conversion_mechanism; diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index df402ab5..a68b0842 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -60,6 +60,15 @@ const EQUITY_COMPENSATION_WRITE_BASE = { security_law_exemptions: [], }; +function captureError(action: () => unknown): unknown { + try { + action(); + } catch (error) { + return error; + } + throw new Error('Expected conversion to fail'); +} + const STOCK_ISSUANCE_WRITE_BASE: Parameters[0] = { object_type: 'TX_STOCK_ISSUANCE', id: 'stock-issuance-1', @@ -451,6 +460,119 @@ describe('OCF write converter optional date boundaries', () => { throw new Error('Expected malformed vesting to be rejected'); }); + test.each(['termination_exercise_windows', 'security_law_exemptions'] as const)( + 'rejects a present non-array %s collection', + (field) => { + const invalidValue = { not: 'an array' }; + const error = captureError(() => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + [field]: invalidValue, + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `equityCompensationIssuance.${field}`, + expectedType: 'array | null', + receivedValue: invalidValue, + }); + } + ); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-a-window'], + ] as const)('rejects a %s termination window with an indexed structured error', (_case, invalidWindow) => { + const error = captureError(() => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + termination_exercise_windows: [ + { reason: 'OcfTermVoluntaryOther', period: '1', period_type: 'OcfPeriodDays' }, + invalidWindow, + ], + }) + ); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'equityCompensationIssuance.termination_exercise_windows[1]', + expectedType: 'object', + receivedValue: invalidWindow, + }); + }); + + test.each([ + ['reason', 'OcfTermUnknown', OcpErrorCodes.UNKNOWN_ENUM_VALUE], + ['period_type', 'OcfPeriodUnknown', OcpErrorCodes.UNKNOWN_ENUM_VALUE], + ['period', {}, OcpErrorCodes.INVALID_TYPE], + ] as const)('reports the indexed termination-window %s field', (field, invalidValue, code) => { + const error = captureError(() => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + termination_exercise_windows: [ + { reason: 'OcfTermVoluntaryOther', period: '1', period_type: 'OcfPeriodDays' }, + { + reason: 'OcfTermVoluntaryOther', + period: '1', + period_type: 'OcfPeriodDays', + [field]: invalidValue, + }, + ], + }) + ); + + expect(error).toMatchObject({ + code, + fieldPath: `equityCompensationIssuance.termination_exercise_windows[1].${field}`, + receivedValue: invalidValue, + }); + }); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-an-exemption'], + ] as const)('rejects a %s security exemption with an indexed structured error', (_case, invalidExemption) => { + const error = captureError(() => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + security_law_exemptions: [{ description: 'Rule 701', jurisdiction: 'US' }, invalidExemption], + }) + ); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'equityCompensationIssuance.security_law_exemptions[1]', + expectedType: 'object', + receivedValue: invalidExemption, + }); + }); + + test.each([ + ['description', 42, OcpErrorCodes.INVALID_TYPE], + ['jurisdiction', '', OcpErrorCodes.INVALID_FORMAT], + ] as const)('reports the indexed security-exemption %s field', (field, invalidValue, code) => { + const error = captureError(() => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + security_law_exemptions: [ + { description: 'Rule 701', jurisdiction: 'US' }, + { description: 'Regulation D', jurisdiction: 'US', [field]: invalidValue }, + ], + }) + ); + + expect(error).toMatchObject({ + code, + fieldPath: `equityCompensationIssuance.security_law_exemptions[1].${field}`, + receivedValue: invalidValue, + }); + }); + test.each([ ['undefined', undefined, OcpErrorCodes.INVALID_TYPE], ['empty', '', OcpErrorCodes.INVALID_FORMAT], diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 12357f8a..da7e368b 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -674,6 +674,104 @@ describe('VestingTerms drift regression', () => { expect(result.vesting_conditions[0].portion!.remainder).toBe(false); }); + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-a-condition'], + ] as const)('rejects a %s vesting condition with an indexed structured error', (_case, invalidCondition) => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push(invalidCondition); + + try { + damlVestingTermsDataToNative(daml); + throw new Error('Expected malformed vesting condition to be rejected'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'vestingTerms.vesting_conditions[1]', + expectedType: 'object', + receivedValue: invalidCondition, + }); + } + }); + + test.each([ + ['null Some value', { tag: 'Some', value: null }, 'vestingTerms.vesting_conditions[1].portion.value', null], + ['array', [], 'vestingTerms.vesting_conditions[1].portion', []], + ['primitive', 'not-a-portion', 'vestingTerms.vesting_conditions[1].portion', 'not-a-portion'], + ['false', false, 'vestingTerms.vesting_conditions[1].portion', false], + ['zero', 0, 'vestingTerms.vesting_conditions[1].portion', 0], + ['empty string', '', 'vestingTerms.vesting_conditions[1].portion', ''], + ] as const)('rejects a %s with a structured portion error', (_case, invalidPortion, fieldPath, receivedValue) => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'invalid-portion', + description: null, + quantity: null, + portion: invalidPortion, + trigger: 'OcfVestingStartTrigger', + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_TYPE, + fieldPath, + expectedType: 'object', + receivedValue, + }) + ); + }); + + test.each([ + ['null', null], + ['record', {}], + ['primitive', 'not-conditions'], + ] as const)('rejects a %s vesting_conditions collection with a structured error', (_case, invalidConditions) => { + const daml = makeDamlVestingTerms({ vesting_conditions: invalidConditions }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'vestingTerms.vesting_conditions', + expectedType: 'array', + receivedValue: invalidConditions, + }) + ); + }); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 42], + ] as const)('rejects a %s vesting trigger with an indexed structured error', (_case, invalidTrigger) => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'invalid-trigger', + description: null, + quantity: null, + portion: null, + trigger: invalidTrigger, + next_condition_ids: [], + }); + + try { + damlVestingTermsDataToNative(daml); + throw new Error('Expected malformed vesting trigger to be rejected'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'vestingTerms.vesting_conditions[1].trigger', + expectedType: 'string | object', + receivedValue: invalidTrigger, + }); + } + }); + test('reports the exact vesting-condition index for an invalid absolute date on readback', () => { const daml = makeDamlVestingTerms(); (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ diff --git a/test/createOcf/stockIssuanceReadConversions.test.ts b/test/createOcf/stockIssuanceReadConversions.test.ts index e3ad1982..80b502d7 100644 --- a/test/createOcf/stockIssuanceReadConversions.test.ts +++ b/test/createOcf/stockIssuanceReadConversions.test.ts @@ -34,6 +34,15 @@ function makeMinimalDamlStockIssuance(overrides: Record = {}): }; } +function captureError(action: () => unknown): unknown { + try { + action(); + } catch (error) { + return error; + } + throw new Error('Expected stock issuance conversion to fail'); +} + describe('damlStockIssuanceDataToNative', () => { test('rejects a non-object payload with a controlled schema mismatch', () => { const convert = () => @@ -250,6 +259,75 @@ describe('damlStockIssuanceDataToNative', () => { expect(result.security_law_exemptions).toEqual([]); }); + test.each([ + ['security_law_exemptions', { description: 'Rule 701', jurisdiction: 'US' }], + ['share_numbers_issued', { starting_share_number: '1', ending_share_number: '100' }], + ] as const)('rejects malformed %s elements with indexed structured errors', (field, validElement) => { + for (const invalidElement of [null, [], 'not-an-object']) { + const error = captureError(() => + damlStockIssuanceDataToNative( + makeMinimalDamlStockIssuance({ + [field]: [validElement, invalidElement], + }) as Parameters[0] + ) + ); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `stockIssuance.${field}[1]`, + expectedType: 'object', + receivedValue: invalidElement, + }); + } + }); + + test.each([ + ['security_law_exemptions', 'description', 42, OcpErrorCodes.INVALID_TYPE], + ['security_law_exemptions', 'jurisdiction', '', OcpErrorCodes.INVALID_FORMAT], + ['share_numbers_issued', 'starting_share_number', 42, OcpErrorCodes.INVALID_TYPE], + ['share_numbers_issued', 'ending_share_number', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ] as const)('reports the indexed %s.%s field', (collection, field, invalidValue, code) => { + const validElement = + collection === 'security_law_exemptions' + ? { description: 'Rule 701', jurisdiction: 'US' } + : { starting_share_number: '1', ending_share_number: '100' }; + const error = captureError(() => + damlStockIssuanceDataToNative( + makeMinimalDamlStockIssuance({ + [collection]: [validElement, { ...validElement, [field]: invalidValue }], + }) as Parameters[0] + ) + ); + + expect(error).toMatchObject({ + code, + fieldPath: `stockIssuance.${collection}[1].${field}`, + receivedValue: invalidValue, + }); + }); + + test.each(['security_law_exemptions', 'share_numbers_issued'] as const)( + 'rejects a present non-array %s collection', + (field) => { + const invalidValue = { not: 'an array' }; + const error = captureError(() => + damlStockIssuanceDataToNative( + makeMinimalDamlStockIssuance({ [field]: invalidValue }) as Parameters< + typeof damlStockIssuanceDataToNative + >[0] + ) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `stockIssuance.${field}`, + expectedType: 'array', + receivedValue: invalidValue, + }); + } + ); + test('handles stock_legend_ids array', () => { const daml = makeMinimalDamlStockIssuance({ stock_legend_ids: ['leg-1', 'leg-2'] }); const result = damlStockIssuanceDataToNative(daml as Parameters[0]); From 9d459b27f3894265e49bb90f9ddc945d8e18bcc3 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 16:51:24 -0400 Subject: [PATCH 46/47] fix: validate nested write records --- .../createConvertibleIssuance.ts | 24 +++++++++++-- src/functions/OpenCapTable/shared/vesting.ts | 13 +++++-- .../convertibleIssuanceConverters.test.ts | 35 +++++++++++++++++-- test/utils/vesting.test.ts | 22 ++++++++++++ 4 files changed, 87 insertions(+), 7 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index d9ff73a1..0858b634 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -4,6 +4,7 @@ import type { ConversionTriggerFor, ConversionTriggerType, Monetary } from '../. import { cleanComments, dateStringToDAMLTime, + isRecord, monetaryToDaml, normalizeNumericString, optionalDateStringToDAMLTime, @@ -188,7 +189,15 @@ function mechanismInputToDamlEnum( Array.isArray(arr) ? arr.map((ir, interestRateIndex) => { const interestRatePath = `${mechanismPath}.interest_rates[${interestRateIndex}]`; - const accrualStartDate: unknown = ir?.accrual_start_date; + if (!isRecord(ir)) { + throw new OcpValidationError(interestRatePath, 'Interest rate must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: ir, + }); + } + + const accrualStartDate = ir.accrual_start_date; if (accrualStartDate === null || accrualStartDate === undefined) { throw new OcpValidationError( `${interestRatePath}.accrual_start_date`, @@ -200,11 +209,20 @@ function mechanismInputToDamlEnum( ); } + const { rate } = ir; + if (rate !== null && rate !== undefined && typeof rate !== 'string' && typeof rate !== 'number') { + throw new OcpValidationError(`${interestRatePath}.rate`, 'Interest rate must be a string or number', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string | number', + receivedValue: rate, + }); + } + return { - rate: ir?.rate != null ? normalizeNumericString(String(ir.rate), `${interestRatePath}.rate`) : null, + rate: rate != null ? normalizeNumericString(rate, `${interestRatePath}.rate`) : null, accrual_start_date: dateStringToDAMLTime(accrualStartDate, `${interestRatePath}.accrual_start_date`), accrual_end_date: optionalDateStringToDAMLTime( - ir?.accrual_end_date, + ir.accrual_end_date, `${interestRatePath}.accrual_end_date` ), }; diff --git a/src/functions/OpenCapTable/shared/vesting.ts b/src/functions/OpenCapTable/shared/vesting.ts index a81e2750..4b43b541 100644 --- a/src/functions/OpenCapTable/shared/vesting.ts +++ b/src/functions/OpenCapTable/shared/vesting.ts @@ -1,5 +1,5 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -import { dateStringToDAMLTime, normalizeNumericString } from '../../../utils/typeConversions'; +import { dateStringToDAMLTime, isRecord, normalizeNumericString } from '../../../utils/typeConversions'; interface VestingInput { date: string; @@ -18,8 +18,17 @@ export function filterAndMapVestingsToDaml( ): DamlVesting[] { return (vestings ?? []) .map((vesting, index) => { + const vestingPath = `${basePath}[${index}]`; + if (!isRecord(vesting)) { + throw new OcpValidationError(vestingPath, 'Vesting must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: vesting, + }); + } + const amountPath = `${basePath}[${index}].amount`; - const date = dateStringToDAMLTime(vesting.date, `${basePath}[${index}].date`); + const date = dateStringToDAMLTime(vesting.date, `${vestingPath}.date`); const amount = normalizeNumericString(vesting.amount, amountPath); if (Number(amount) < 0) { diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index d37e8874..1a1d5b40 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -63,7 +63,7 @@ function captureError(action: () => unknown): unknown { throw new Error('Expected conversion mechanism validation to fail'); } -function buildConvertibleNoteTrigger(triggerId: string, interestRates: Array>) { +function buildConvertibleNoteTrigger(triggerId: string, interestRates: unknown[]) { return { ...SAFE_TRIGGER_BASE, trigger_id: triggerId, @@ -81,7 +81,7 @@ function buildConvertibleNoteTrigger(triggerId: string, interestRates: Array) { +function buildConvertibleNoteInput(interestRate: unknown) { return { ...BASE_INPUT, convertible_type: 'NOTE', @@ -1009,6 +1009,37 @@ describe('convertible issuance write date boundaries', () => { ); }); + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-an-interest-rate'], + ] as const)('rejects a %s interest-rate element on write with an indexed structured error', (_case, invalidRate) => { + const error = captureError(() => convertibleIssuanceDataToDaml(buildConvertibleNoteInput(invalidRate))); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: noteInterestRatePath(), + expectedType: 'object', + receivedValue: invalidRate, + }); + }); + + test('rejects a non-numeric-shaped interest rate with its indexed field path', () => { + const invalidRate = { value: '0.05' }; + const error = captureError(() => + convertibleIssuanceDataToDaml(buildConvertibleNoteInput({ rate: invalidRate, accrual_start_date: '2024-01-15' })) + ); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `${noteInterestRatePath()}.rate`, + expectedType: 'string | number', + receivedValue: invalidRate, + }); + }); + test.each([ ['empty', '', OcpErrorCodes.INVALID_FORMAT], ['non-string', { seconds: 1 }, OcpErrorCodes.INVALID_TYPE], diff --git a/test/utils/vesting.test.ts b/test/utils/vesting.test.ts index f6c435c5..2a2f8b82 100644 --- a/test/utils/vesting.test.ts +++ b/test/utils/vesting.test.ts @@ -72,4 +72,26 @@ describe('shared vesting write boundary', () => { receivedValue: '1e2', }); }); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-a-vesting'], + ] as const)('rejects a %s vesting with an indexed structured error', (_case, invalidVesting) => { + const error = captureError(() => + filterAndMapVestingsToDaml( + [{ date: '2026-01-01', amount: '0' }, invalidVesting] as unknown as Parameters< + typeof filterAndMapVestingsToDaml + >[0], + PATH + ) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `${PATH}[1]`, + expectedType: 'object', + receivedValue: invalidVesting, + }); + }); }); From 05ad2a30b6d656f83753b1121d0e27f13d5a2d1e Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Mon, 13 Jul 2026 18:18:17 -0400 Subject: [PATCH 47/47] refactor: curate public sdk declarations --- package.json | 9 + scripts/check-declarations.ts | 43 ++- src/OcpClient.ts | 59 ++-- .../OpenCapTable/capTable/CapTableBatch.ts | 127 +------- .../OpenCapTable/capTable/archiveCapTable.ts | 2 +- .../capTable/archiveFullCapTable.ts | 2 +- .../OpenCapTable/capTable/batchTypes.ts | 308 ++---------------- .../capTable/buildCapTableCommand.ts | 2 +- .../OpenCapTable/capTable/entityTypes.ts | 278 ++++++++++++++++ .../capTable/generatedBatchOperations.ts | 158 +++++++++ .../OpenCapTable/capTable/getCapTableState.ts | 4 +- src/functions/OpenCapTable/capTable/index.ts | 7 +- src/functions/OpenCapTable/issuer/api.ts | 12 + .../OpenCapTable/issuer/createIssuer.ts | 34 +- src/functions/OpenCapTable/issuer/index.ts | 3 +- src/functions/OpenCapTable/issuer/types.ts | 18 + .../issuerAuthorization/authorizeIssuer.ts | 18 +- .../OpenCapTable/issuerAuthorization/index.ts | 1 + .../OpenCapTable/issuerAuthorization/types.ts | 29 ++ .../withdrawAuthorization.ts | 14 +- .../stockIssuance/createStockIssuance.ts | 8 +- src/index.ts | 47 ++- src/types/common.ts | 6 + src/types/index.ts | 12 - src/utils/cantonOcfExtractor.ts | 2 +- src/utils/replicationHelpers.ts | 6 +- test/batch/batchTypes.test.ts | 21 +- .../generatedOperationConstruction.test.ts | 16 +- test/client/OcpClient.test.ts | 10 +- .../coreObjectReadValidation.test.ts | 4 +- test/converters/financingConverters.test.ts | 12 +- test/declarations/damlReadDispatch.types.ts | 10 +- .../generatedOperationConstruction.types.ts | 14 +- test/declarations/normalization.types.ts | 6 +- test/declarations/publicApi.types.ts | 128 ++++---- test/integration/utils/setupTestData.ts | 2 +- test/publicApi/rootExports.test.ts | 71 ++++ test/types/capTableBatch.types.ts | 2 +- test/types/damlReadDispatch.types.ts | 4 +- .../generatedOperationConstruction.types.ts | 21 +- test/types/normalization.types.ts | 6 +- 41 files changed, 887 insertions(+), 649 deletions(-) create mode 100644 src/functions/OpenCapTable/capTable/entityTypes.ts create mode 100644 src/functions/OpenCapTable/capTable/generatedBatchOperations.ts create mode 100644 src/functions/OpenCapTable/issuer/api.ts create mode 100644 src/functions/OpenCapTable/issuer/types.ts create mode 100644 src/functions/OpenCapTable/issuerAuthorization/types.ts create mode 100644 test/publicApi/rootExports.test.ts diff --git a/package.json b/package.json index 051625c8..dc2a333f 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,15 @@ "url": "https://github.com/Fairmint/ocp-canton-sdk.git" }, "license": "GPL-3.0-only", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.js", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ diff --git a/scripts/check-declarations.ts b/scripts/check-declarations.ts index 0788895f..07cac7d1 100644 --- a/scripts/check-declarations.ts +++ b/scripts/check-declarations.ts @@ -5,7 +5,11 @@ import ts from 'typescript'; const projectRoot = process.cwd(); const configPath = path.join(projectRoot, 'tsconfig.json'); const declarationEntryPoint = path.join(projectRoot, 'dist', 'index.d.ts'); +const strictConsumerEntryPoint = path.join(projectRoot, 'test', 'declarations', 'publicApi.types.ts'); const declarationRoot = `${path.dirname(declarationEntryPoint)}${path.sep}`; +const generatedDamlPackage = '@fairmint/open-captable-protocol-daml-js'; +const cantonTransactionTreeOperationsModule = '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; +const commonTypesDeclaration = path.join(declarationRoot, 'types', 'common.d.ts'); const diagnosticHost: ts.FormatDiagnosticsHost = { getCanonicalFileName: (fileName) => fileName, getCurrentDirectory: () => projectRoot, @@ -30,16 +34,41 @@ if (parsedConfig.errors.length > 0) { } const program = ts.createProgram({ - rootNames: [declarationEntryPoint], - options: parsedConfig.options, + rootNames: [declarationEntryPoint, strictConsumerEntryPoint], + options: { ...parsedConfig.options, rootDir: projectRoot }, }); -const sdkDiagnostics = ts - .getPreEmitDiagnostics(program) - .filter((diagnostic) => diagnostic.file?.fileName.startsWith(declarationRoot)); +const diagnostics = ts.getPreEmitDiagnostics(program); -if (sdkDiagnostics.length > 0) { +if (diagnostics.length > 0) { throw new Error( - `SDK declaration validation failed:\n${ts.formatDiagnosticsWithColorAndContext(sdkDiagnostics, diagnosticHost)}` + `Strict consumer declaration validation failed:\n${ts.formatDiagnosticsWithColorAndContext(diagnostics, diagnosticHost)}` + ); +} + +const generatedDamlLeaks = program + .getSourceFiles() + .filter((sourceFile) => sourceFile.fileName.startsWith(declarationRoot)) + .filter((sourceFile) => sourceFile.text.includes(generatedDamlPackage)) + .map((sourceFile) => path.relative(projectRoot, sourceFile.fileName)); + +if (generatedDamlLeaks.length > 0) { + throw new Error( + `Public declaration graph references ${generatedDamlPackage}:\n${generatedDamlLeaks.map((file) => `- ${file}`).join('\n')}` + ); +} + +const duplicatedTransactionTreeResponseImports = program + .getSourceFiles() + .filter((sourceFile) => sourceFile.fileName.startsWith(declarationRoot)) + .filter((sourceFile) => sourceFile.fileName !== commonTypesDeclaration) + .filter((sourceFile) => sourceFile.text.includes(cantonTransactionTreeOperationsModule)) + .map((sourceFile) => path.relative(projectRoot, sourceFile.fileName)); + +if (duplicatedTransactionTreeResponseImports.length > 0) { + throw new Error( + `Public declarations must import transaction-tree response types through src/types/common:\n${duplicatedTransactionTreeResponseImports + .map((file) => `- ${file}`) + .join('\n')}` ); } diff --git a/src/OcpClient.ts b/src/OcpClient.ts index 6117e055..c3e91060 100644 --- a/src/OcpClient.ts +++ b/src/OcpClient.ts @@ -68,35 +68,38 @@ import { type OcpEnvironment, } from './environment'; import { OcpErrorCodes, OcpValidationError } from './errors'; -import { - authorizeIssuer, - buildCreateIssuerCommand, - getEntityAsOcf, - withdrawAuthorization, - type AuthorizeIssuerParams, - type AuthorizeIssuerResult, - type CreateIssuerParams, - type WithdrawAuthorizationParams, - type WithdrawAuthorizationResult, -} from './functions'; import { archiveCapTable, - CapTableBatch, - classifyIssuerCapTables, - getCapTableState, - mapOcfObjectTypeToEntityType, type ArchiveCapTableParams, type ArchiveCapTableResult, - type CapTableState, - type IssuerCapTableClassification, +} from './functions/OpenCapTable/capTable/archiveCapTable'; +import { CapTableBatch, type CapTableBatchParams } from './functions/OpenCapTable/capTable/CapTableBatch'; +import { getEntityAsOcf } from './functions/OpenCapTable/capTable/damlToOcf'; +import { + mapOcfObjectTypeToEntityType, type OcfDataTypeFor, type OcfEntityType, type OcfReadableDataForObjectType, type OcfReadableObjectType, -} from './functions/OpenCapTable/capTable'; +} from './functions/OpenCapTable/capTable/entityTypes'; +import { + classifyIssuerCapTables, + getCapTableState, + type CapTableState, + type IssuerCapTableClassification, +} from './functions/OpenCapTable/capTable/getCapTableState'; +import { buildCreateIssuerCommand } from './functions/OpenCapTable/issuer/api'; +import type { CreateIssuerParams } from './functions/OpenCapTable/issuer/types'; +import { authorizeIssuer } from './functions/OpenCapTable/issuerAuthorization/authorizeIssuer'; +import type { + AuthorizeIssuerParams, + AuthorizeIssuerResult, + WithdrawAuthorizationParams, + WithdrawAuthorizationResult, +} from './functions/OpenCapTable/issuerAuthorization/types'; +import { withdrawAuthorization } from './functions/OpenCapTable/issuerAuthorization/withdrawAuthorization'; import { mergeCommandContext, type CommandObservabilityOptions, type OcpObservabilityOptions } from './observability'; -import type { CommandWithDisclosedContracts } from './types'; -import type { ContractResult, GetByContractIdParams } from './types/common'; +import type { CommandWithDisclosedContracts, ContractResult, GetByContractIdParams } from './types/common'; import type { OcfConvertibleAcceptanceOutput, OcfConvertibleCancellationOutput, @@ -827,21 +830,7 @@ export interface GetByObjectTypeParams< } /** Parameters for creating a batch cap table update */ -interface CapTableUpdateParams extends CommandObservabilityOptions { - /** The contract ID of the CapTable to update */ - capTableContractId: string; - /** Optional contract details for the CapTable (used to get correct templateId from ledger) */ - capTableContractDetails?: { templateId: string }; - /** - * Optional deterministic command ID for idempotent retry handling. - * Takes precedence over `defaultContext.commandId` and `context.commandId`. - */ - commandId?: string; - /** Party IDs to act as (signatories) */ - actAs: string[]; - /** Optional additional party IDs for read access */ - readAs?: string[]; -} +type CapTableUpdateParams = CapTableBatchParams; /** Entity namespace with a single `get()` method */ interface EntityReader { diff --git a/src/functions/OpenCapTable/capTable/CapTableBatch.ts b/src/functions/OpenCapTable/capTable/CapTableBatch.ts index fb870967..9d9f43ab 100644 --- a/src/functions/OpenCapTable/capTable/CapTableBatch.ts +++ b/src/functions/OpenCapTable/capTable/CapTableBatch.ts @@ -7,38 +7,35 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api'; import type { Command } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/commands'; -import { Fairmint, OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; +import { OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; import { OcpContractError, OcpErrorCodes, OcpValidationError } from '../../../errors'; import { mergeCommandContext, submitObservedTransactionTree, type CommandObservabilityOptions, } from '../../../observability'; -import type { CommandWithDisclosedContracts } from '../../../types'; +import type { CommandWithDisclosedContracts } from '../../../types/common'; +import { type OcfCreateData, type OcfDeleteData, type OcfEditData, type UpdateCapTableResult } from './batchTypes'; import { - ENTITY_TAG_MAP, - isOcfCreatableEntityType, isOcfDeletableEntityType, - isOcfEditableEntityType, type CapTableBatchExecuteResult, type CapTableBatchOperations, type OcfCreateArguments, - type OcfCreateData, - type OcfCreateDataFor, type OcfCreateOperation, type OcfDataTypeFor, type OcfDeletableEntityType, - type OcfDeleteData, - type OcfDeleteDataFor, type OcfDeleteOperation, type OcfEditArguments, - type OcfEditData, - type OcfEditDataFor, type OcfEditOperation, type OcfEntityType, - type UpdateCapTableResult, -} from './batchTypes'; -import { convertOperationToDaml, convertToDaml } from './ocfToDaml'; +} from './entityTypes'; +import { + buildOcfCreateData, + buildOcfCreateDataFromOperation, + buildOcfDeleteData, + buildOcfEditData, + buildOcfEditDataFromOperation, +} from './generatedBatchOperations'; /** Parameters for initializing a batch update. */ export interface CapTableBatchParams extends CommandObservabilityOptions { @@ -71,108 +68,6 @@ export interface BatchItemMeta { securityId?: string; } -function decodeGeneratedOperation( - decoder: { runWithException: (input: unknown) => T }, - input: unknown, - operation: 'create' | 'edit' | 'delete', - entityType: OcfEntityType -): T { - try { - return decoder.runWithException(input); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new OcpValidationError( - `batch.${operation}.${entityType}`, - `Converter output does not match the generated DAML ${operation} variant: ${message}`, - { - code: OcpErrorCodes.INVALID_FORMAT, - context: { operation, entityType }, - } - ); - } -} - -function buildOcfCreateDataWith(type: OcfEntityType, convert: () => unknown): OcfCreateData { - if (!isOcfCreatableEntityType(type)) { - throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - - const tag = ENTITY_TAG_MAP[type].create; - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, - { tag, value: convert() }, - 'create', - type - ); -} - -/** Build and validate one generated DAML create variant from a correlated entity-kind/data pair. */ -export function buildOcfCreateData( - ...args: Arguments -): OcfCreateDataFor; -export function buildOcfCreateData(...args: OcfCreateArguments): OcfCreateData { - const [type] = args; - return buildOcfCreateDataWith(type, () => convertToDaml(...args)); -} - -function buildOcfCreateDataFromOperation(operation: OcfCreateOperation): OcfCreateData { - const { type } = operation; - return buildOcfCreateDataWith(type, () => convertOperationToDaml(operation)); -} - -function buildOcfEditDataWith(type: OcfEntityType, convert: () => unknown): OcfEditData { - if (!isOcfEditableEntityType(type)) { - throw new OcpValidationError('type', `Edit operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - - const tag = ENTITY_TAG_MAP[type].edit; - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, - { tag, value: convert() }, - 'edit', - type - ); -} - -/** Build and validate one generated DAML edit variant from a correlated entity-kind/data pair. */ -export function buildOcfEditData( - ...args: Arguments -): OcfEditDataFor; -export function buildOcfEditData(...args: OcfEditArguments): OcfEditData { - const [type] = args; - return buildOcfEditDataWith(type, () => convertToDaml(...args)); -} - -function buildOcfEditDataFromOperation(operation: OcfEditOperation): OcfEditData { - const { type } = operation; - return buildOcfEditDataWith(type, () => convertOperationToDaml(operation)); -} - -/** Build and validate one generated DAML delete variant. */ -export function buildOcfDeleteData( - type: EntityType, - id: string -): OcfDeleteDataFor; -export function buildOcfDeleteData(type: OcfDeletableEntityType, id: string): OcfDeleteData { - if (!isOcfDeletableEntityType(type)) { - throw new OcpValidationError('type', `Delete operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - - const tag = ENTITY_TAG_MAP[type].delete; - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfDeleteData.decoder, - { tag, value: id }, - 'delete', - type - ); -} - /** * Fluent batch builder for cap table updates. * diff --git a/src/functions/OpenCapTable/capTable/archiveCapTable.ts b/src/functions/OpenCapTable/capTable/archiveCapTable.ts index 07d031cd..5996b618 100644 --- a/src/functions/OpenCapTable/capTable/archiveCapTable.ts +++ b/src/functions/OpenCapTable/capTable/archiveCapTable.ts @@ -11,7 +11,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import { submitObservedTransactionTree, type CommandObservabilityOptions } from '../../../observability'; -import type { CommandWithDisclosedContracts } from '../../../types'; +import type { CommandWithDisclosedContracts } from '../../../types/common'; import { buildCapTableCommand } from './buildCapTableCommand'; /** Parameters for archiving a CapTable contract. */ diff --git a/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts b/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts index 14d80d7b..51a21c66 100644 --- a/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts +++ b/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts @@ -17,8 +17,8 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk/build/src/cl import { OcpErrorCodes } from '../../../errors/codes'; import { OcpContractError } from '../../../errors/OcpContractError'; import { archiveCapTable } from './archiveCapTable'; -import type { OcfEntityType } from './batchTypes'; import { CapTableBatch } from './CapTableBatch'; +import type { OcfEntityType } from './entityTypes'; import { classifyIssuerCapTables, type CapTableWithArchiveContext } from './getCapTableState'; /** Minimal contract state needed for the archive operation. */ diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index e24fd022..81554aec 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -6,58 +6,41 @@ */ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import type { OcfObjectType } from '../../../types/native'; import type { - OcfConvertibleAcceptance, - OcfConvertibleCancellation, - OcfConvertibleConversion, - OcfConvertibleIssuance, - OcfConvertibleRetraction, - OcfConvertibleTransfer, - OcfDocument, - OcfEquityCompensationAcceptance, - OcfEquityCompensationCancellation, - OcfEquityCompensationExercise, - OcfEquityCompensationIssuance, - OcfEquityCompensationRelease, - OcfEquityCompensationRepricing, - OcfEquityCompensationRetraction, - OcfEquityCompensationTransfer, - OcfFinancing, - OcfIssuer, - OcfIssuerAuthorizedSharesAdjustment, - OcfObjectType, - OcfStakeholder, - OcfStakeholderRelationshipChangeEvent, - OcfStakeholderStatusChangeEvent, - OcfStockAcceptance, - OcfStockCancellation, - OcfStockClass, - OcfStockClassAuthorizedSharesAdjustment, - OcfStockClassConversionRatioAdjustment, - OcfStockClassSplit, - OcfStockConsolidation, - OcfStockConversion, - OcfStockIssuance, - OcfStockLegendTemplate, - OcfStockPlan, - OcfStockPlanPoolAdjustment, - OcfStockPlanReturnToPool, - OcfStockReissuance, - OcfStockRepurchase, - OcfStockRetraction, - OcfStockTransfer, - OcfValuation, - OcfVestingAcceleration, - OcfVestingEvent, - OcfVestingStart, - OcfVestingTerms, - OcfWarrantAcceptance, - OcfWarrantCancellation, - OcfWarrantExercise, - OcfWarrantIssuance, - OcfWarrantRetraction, - OcfWarrantTransfer, -} from '../../../types'; + OcfCreatableEntityType, + OcfDataTypeFor, + OcfDeletableEntityType, + OcfEditableEntityType, + OcfEntityDataMap, + OcfEntityType, +} from './entityTypes'; + +export { + isOcfCreatableEntityType, + isOcfDeletableEntityType, + isOcfEditableEntityType, + isOcfEntityType, + isOcfReadableObjectType, + mapOcfObjectTypeToEntityType, + OCF_OBJECT_TYPE_TO_ENTITY_TYPE, + type CapTableBatchExecuteResult, + type CapTableBatchOperations, + type OcfCreatableEntityType, + type OcfCreateArguments, + type OcfCreateOperation, + type OcfDataTypeFor, + type OcfDeletableEntityType, + type OcfDeleteOperation, + type OcfEditableEntityType, + type OcfEditArguments, + type OcfEditOperation, + type OcfEntityDataMap, + type OcfEntityType, + type OcfEntityTypeForObjectType, + type OcfReadableDataForObjectType, + type OcfReadableObjectType, +} from './entityTypes'; // Re-export DAML types for convenience export type OcfCreateData = Fairmint.OpenCapTable.CapTable.OcfCreateData; @@ -65,87 +48,6 @@ export type OcfEditData = Fairmint.OpenCapTable.CapTable.OcfEditData; export type OcfDeleteData = Fairmint.OpenCapTable.CapTable.OcfDeleteData; export type UpdateCapTableResult = Fairmint.OpenCapTable.CapTable.UpdateCapTableResult; -/** - * Result of executing a CapTableBatch. - * - * Extends the DAML choice result with transaction metadata from the ledger. - * This includes all properties from UpdateCapTableResult plus the updateId. - */ -export type CapTableBatchExecuteResult = UpdateCapTableResult & { - /** The update ID (transaction ID) from the Canton ledger */ - updateId: string; -}; - -/** - * Type mapping from entity type string to native OCF data type. - */ -// A closed alias prevents module augmentation from widening OcfEntityType beyond ENTITY_REGISTRY. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type OcfEntityDataMap = { - convertibleAcceptance: OcfConvertibleAcceptance; - convertibleCancellation: OcfConvertibleCancellation; - convertibleConversion: OcfConvertibleConversion; - convertibleIssuance: OcfConvertibleIssuance; - convertibleRetraction: OcfConvertibleRetraction; - convertibleTransfer: OcfConvertibleTransfer; - document: OcfDocument; - equityCompensationAcceptance: OcfEquityCompensationAcceptance; - equityCompensationCancellation: OcfEquityCompensationCancellation; - equityCompensationExercise: OcfEquityCompensationExercise; - equityCompensationIssuance: OcfEquityCompensationIssuance; - equityCompensationRelease: OcfEquityCompensationRelease; - equityCompensationRepricing: OcfEquityCompensationRepricing; - equityCompensationRetraction: OcfEquityCompensationRetraction; - equityCompensationTransfer: OcfEquityCompensationTransfer; - financing: OcfFinancing; - /** Issuer is edit-only (no create/delete) - created with CapTable via IssuerAuthorization */ - issuer: OcfIssuer; - issuerAuthorizedSharesAdjustment: OcfIssuerAuthorizedSharesAdjustment; - stakeholder: OcfStakeholder; - stakeholderRelationshipChangeEvent: OcfStakeholderRelationshipChangeEvent; - stakeholderStatusChangeEvent: OcfStakeholderStatusChangeEvent; - stockAcceptance: OcfStockAcceptance; - stockCancellation: OcfStockCancellation; - stockClass: OcfStockClass; - stockClassAuthorizedSharesAdjustment: OcfStockClassAuthorizedSharesAdjustment; - stockClassConversionRatioAdjustment: OcfStockClassConversionRatioAdjustment; - stockClassSplit: OcfStockClassSplit; - stockConsolidation: OcfStockConsolidation; - stockConversion: OcfStockConversion; - stockIssuance: OcfStockIssuance; - stockLegendTemplate: OcfStockLegendTemplate; - stockPlan: OcfStockPlan; - stockPlanPoolAdjustment: OcfStockPlanPoolAdjustment; - stockPlanReturnToPool: OcfStockPlanReturnToPool; - stockReissuance: OcfStockReissuance; - stockRepurchase: OcfStockRepurchase; - stockRetraction: OcfStockRetraction; - stockTransfer: OcfStockTransfer; - valuation: OcfValuation; - vestingAcceleration: OcfVestingAcceleration; - vestingEvent: OcfVestingEvent; - vestingStart: OcfVestingStart; - vestingTerms: OcfVestingTerms; - warrantAcceptance: OcfWarrantAcceptance; - warrantCancellation: OcfWarrantCancellation; - warrantExercise: OcfWarrantExercise; - warrantIssuance: OcfWarrantIssuance; - warrantRetraction: OcfWarrantRetraction; - warrantTransfer: OcfWarrantTransfer; -}; - -/** - * All supported OCF entity types for batch operations. - * - * Derived from {@link OcfEntityDataMap} so an entity kind cannot be added to the - * data model without also becoming part of the SDK's canonical entity-kind union. - * Operation-specific subsets are derived from {@link ENTITY_REGISTRY} below. - */ -export type OcfEntityType = keyof OcfEntityDataMap; - -/** Helper type to get the native OCF data type for a given entity type. */ -export type OcfDataTypeFor = OcfEntityDataMap[T]; - type OcfCreateTag = OcfCreateData['tag']; type OcfEditTag = OcfEditData['tag']; type OcfDeleteTag = OcfDeleteData['tag']; @@ -576,21 +478,6 @@ export const ENTITY_REGISTRY = { }, } as const satisfies OcfEntityRegistry; -type EntityTypeWithCapability = { - [EntityType in OcfEntityType]: (typeof ENTITY_REGISTRY)[EntityType]['operations'] extends Record - ? EntityType - : never; -}[OcfEntityType]; - -/** Entity kinds that can be created through UpdateCapTable. */ -export type OcfCreatableEntityType = EntityTypeWithCapability<'create'>; - -/** Entity kinds that can be edited through UpdateCapTable. */ -export type OcfEditableEntityType = EntityTypeWithCapability<'edit'>; - -/** Entity kinds that can be deleted through UpdateCapTable. */ -export type OcfDeletableEntityType = EntityTypeWithCapability<'delete'>; - type OcfOperationTagFor = (typeof ENTITY_REGISTRY)[EntityType]['operations'] extends Readonly> ? Tag @@ -630,133 +517,6 @@ export type OcfEntityArguments = { [EntityType in OcfEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; }[OcfEntityType]; -/** Correlated argument tuples accepted by {@link CapTableBatch.create}. */ -export type OcfCreateArguments = { - [EntityType in OcfCreatableEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; -}[OcfCreatableEntityType]; - -/** Correlated argument tuples accepted by {@link CapTableBatch.edit}. */ -export type OcfEditArguments = { - [EntityType in OcfEditableEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; -}[OcfEditableEntityType]; - -/** A create operation whose entity kind and payload type are correlated. */ -export type OcfCreateOperation = - EntityType extends OcfCreatableEntityType - ? Readonly<{ - type: EntityType; - data: OcfDataTypeFor; - }> - : never; - -/** An edit operation whose entity kind and payload type are correlated. */ -export type OcfEditOperation = - EntityType extends OcfEditableEntityType - ? Readonly<{ - type: EntityType; - data: OcfDataTypeFor; - }> - : never; - -/** A delete operation limited to entity kinds that support deletion. */ -export type OcfDeleteOperation = - EntityType extends OcfDeletableEntityType - ? Readonly<{ - type: EntityType; - id: string; - }> - : never; - -/** Operations accepted by {@link buildUpdateCapTableCommand}. */ -export interface CapTableBatchOperations { - readonly creates?: readonly OcfCreateOperation[]; - readonly edits?: readonly OcfEditOperation[]; - readonly deletes?: readonly OcfDeleteOperation[]; -} - -/** Runtime guard for SDK entity kinds. */ -export function isOcfEntityType(entityType: string): entityType is OcfEntityType { - return Object.prototype.hasOwnProperty.call(ENTITY_REGISTRY, entityType); -} - -/** Runtime guard for entity kinds that support create operations. */ -export function isOcfCreatableEntityType(entityType: string): entityType is OcfCreatableEntityType { - if (!isOcfEntityType(entityType)) return false; - const entry: OcfEntityRegistryEntry = ENTITY_REGISTRY[entityType]; - return entry.operations.create !== undefined; -} - -/** Runtime guard for entity kinds that support edit operations. */ -export function isOcfEditableEntityType(entityType: string): entityType is OcfEditableEntityType { - if (!isOcfEntityType(entityType)) return false; - const entry: OcfEntityRegistryEntry = ENTITY_REGISTRY[entityType]; - return entry.operations.edit !== undefined; -} - -/** Runtime guard for entity kinds that support delete operations. */ -export function isOcfDeletableEntityType(entityType: string): entityType is OcfDeletableEntityType { - if (!isOcfEntityType(entityType)) return false; - const entry: OcfEntityRegistryEntry = ENTITY_REGISTRY[entityType]; - return entry.operations.delete !== undefined; -} - -type OcfLedgerBackedObjectType = (typeof ENTITY_REGISTRY)[OcfEntityType]['objectType']; - -type OcfEntityTypeForRegistryObjectType = { - [EntityType in OcfEntityType]: (typeof ENTITY_REGISTRY)[EntityType]['objectType'] extends ObjectType - ? EntityType - : never; -}[OcfEntityType]; - -type OcfObjectTypeToEntityTypeMap = { - readonly [ObjectType in OcfLedgerBackedObjectType]: OcfEntityTypeForRegistryObjectType; -}; - -function buildOcfObjectTypeToEntityTypeMap(): OcfObjectTypeToEntityTypeMap { - const result: Partial> = {}; - for (const entityType of Object.keys(ENTITY_REGISTRY) as OcfEntityType[]) { - const { objectType } = ENTITY_REGISTRY[entityType]; - if (Object.prototype.hasOwnProperty.call(result, objectType)) { - throw new Error(`Duplicate OCF object type ${objectType} in ENTITY_REGISTRY`); - } - result[objectType] = entityType; - } - return Object.freeze(result) as OcfObjectTypeToEntityTypeMap; -} - -/** - * Canonical ledger-backed OCF `object_type` to SDK entity reader mapping. - * - * This is derived from {@link ENTITY_REGISTRY}; it must not become a second - * hand-maintained entity registry. Schema-supported PlanSecurity wrappers normalize - * to EquityCompensation before lookup. - */ -export const OCF_OBJECT_TYPE_TO_ENTITY_TYPE = buildOcfObjectTypeToEntityTypeMap(); - -/** OCF object types that can be read through OpenCapTable entity readers. */ -export type OcfReadableObjectType = keyof typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE; - -/** SDK entity reader name for a given OCF object type. */ -export type OcfEntityTypeForObjectType = (typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE)[T]; - -/** Canonical entity data returned for a readable OCF object type. */ -export type OcfReadableDataForObjectType = OcfDataTypeFor< - OcfEntityTypeForObjectType ->; - -export function mapOcfObjectTypeToEntityType( - objectType: T -): OcfEntityTypeForObjectType; -export function mapOcfObjectTypeToEntityType(objectType: string): OcfEntityType | null; -export function mapOcfObjectTypeToEntityType(objectType: string): OcfEntityType | null { - return isOcfReadableObjectType(objectType) ? OCF_OBJECT_TYPE_TO_ENTITY_TYPE[objectType] : null; -} - -/** Runtime guard for OCF object types supported by OpenCapTable readers. */ -export function isOcfReadableObjectType(objectType: string): objectType is OcfReadableObjectType { - return Object.prototype.hasOwnProperty.call(OCF_OBJECT_TYPE_TO_ENTITY_TYPE, objectType); -} - function entityRegistryEntries(): Array<[OcfEntityType, OcfEntityRegistryEntry]> { return Object.entries(ENTITY_REGISTRY) as Array<[OcfEntityType, OcfEntityRegistryEntry]>; } diff --git a/src/functions/OpenCapTable/capTable/buildCapTableCommand.ts b/src/functions/OpenCapTable/capTable/buildCapTableCommand.ts index 8ccaa25e..95e61686 100644 --- a/src/functions/OpenCapTable/capTable/buildCapTableCommand.ts +++ b/src/functions/OpenCapTable/capTable/buildCapTableCommand.ts @@ -1,6 +1,6 @@ import type { Command } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/commands'; import { OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; -import type { CommandWithDisclosedContracts } from '../../../types'; +import type { CommandWithDisclosedContracts } from '../../../types/common'; /** * Interface for JSON API choice arguments - matches what the SDK's Zod schema accepts. diff --git a/src/functions/OpenCapTable/capTable/entityTypes.ts b/src/functions/OpenCapTable/capTable/entityTypes.ts new file mode 100644 index 00000000..95f767e9 --- /dev/null +++ b/src/functions/OpenCapTable/capTable/entityTypes.ts @@ -0,0 +1,278 @@ +/** + * Public, protocol-native entity and batch types. + * + * This module deliberately depends only on the SDK's canonical OCF model. The + * generated DAML codecs are an implementation detail and must not become part + * of the package's root declaration graph. + */ + +import type { + OcfConvertibleAcceptance, + OcfConvertibleCancellation, + OcfConvertibleConversion, + OcfConvertibleIssuance, + OcfConvertibleRetraction, + OcfConvertibleTransfer, + OcfDocument, + OcfEquityCompensationAcceptance, + OcfEquityCompensationCancellation, + OcfEquityCompensationExercise, + OcfEquityCompensationIssuance, + OcfEquityCompensationRelease, + OcfEquityCompensationRepricing, + OcfEquityCompensationRetraction, + OcfEquityCompensationTransfer, + OcfFinancing, + OcfIssuer, + OcfIssuerAuthorizedSharesAdjustment, + OcfStakeholder, + OcfStakeholderRelationshipChangeEvent, + OcfStakeholderStatusChangeEvent, + OcfStockAcceptance, + OcfStockCancellation, + OcfStockClass, + OcfStockClassAuthorizedSharesAdjustment, + OcfStockClassConversionRatioAdjustment, + OcfStockClassSplit, + OcfStockConsolidation, + OcfStockConversion, + OcfStockIssuance, + OcfStockLegendTemplate, + OcfStockPlan, + OcfStockPlanPoolAdjustment, + OcfStockPlanReturnToPool, + OcfStockReissuance, + OcfStockRepurchase, + OcfStockRetraction, + OcfStockTransfer, + OcfValuation, + OcfVestingAcceleration, + OcfVestingEvent, + OcfVestingStart, + OcfVestingTerms, + OcfWarrantAcceptance, + OcfWarrantCancellation, + OcfWarrantExercise, + OcfWarrantIssuance, + OcfWarrantRetraction, + OcfWarrantTransfer, +} from '../../../types/native'; + +/** Canonical native OCF data for every entity handled by the SDK. */ +// A closed alias prevents module augmentation from widening the supported kinds. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type OcfEntityDataMap = { + convertibleAcceptance: OcfConvertibleAcceptance; + convertibleCancellation: OcfConvertibleCancellation; + convertibleConversion: OcfConvertibleConversion; + convertibleIssuance: OcfConvertibleIssuance; + convertibleRetraction: OcfConvertibleRetraction; + convertibleTransfer: OcfConvertibleTransfer; + document: OcfDocument; + equityCompensationAcceptance: OcfEquityCompensationAcceptance; + equityCompensationCancellation: OcfEquityCompensationCancellation; + equityCompensationExercise: OcfEquityCompensationExercise; + equityCompensationIssuance: OcfEquityCompensationIssuance; + equityCompensationRelease: OcfEquityCompensationRelease; + equityCompensationRepricing: OcfEquityCompensationRepricing; + equityCompensationRetraction: OcfEquityCompensationRetraction; + equityCompensationTransfer: OcfEquityCompensationTransfer; + financing: OcfFinancing; + /** Issuer is edit-only; it is created with the CapTable. */ + issuer: OcfIssuer; + issuerAuthorizedSharesAdjustment: OcfIssuerAuthorizedSharesAdjustment; + stakeholder: OcfStakeholder; + stakeholderRelationshipChangeEvent: OcfStakeholderRelationshipChangeEvent; + stakeholderStatusChangeEvent: OcfStakeholderStatusChangeEvent; + stockAcceptance: OcfStockAcceptance; + stockCancellation: OcfStockCancellation; + stockClass: OcfStockClass; + stockClassAuthorizedSharesAdjustment: OcfStockClassAuthorizedSharesAdjustment; + stockClassConversionRatioAdjustment: OcfStockClassConversionRatioAdjustment; + stockClassSplit: OcfStockClassSplit; + stockConsolidation: OcfStockConsolidation; + stockConversion: OcfStockConversion; + stockIssuance: OcfStockIssuance; + stockLegendTemplate: OcfStockLegendTemplate; + stockPlan: OcfStockPlan; + stockPlanPoolAdjustment: OcfStockPlanPoolAdjustment; + stockPlanReturnToPool: OcfStockPlanReturnToPool; + stockReissuance: OcfStockReissuance; + stockRepurchase: OcfStockRepurchase; + stockRetraction: OcfStockRetraction; + stockTransfer: OcfStockTransfer; + valuation: OcfValuation; + vestingAcceleration: OcfVestingAcceleration; + vestingEvent: OcfVestingEvent; + vestingStart: OcfVestingStart; + vestingTerms: OcfVestingTerms; + warrantAcceptance: OcfWarrantAcceptance; + warrantCancellation: OcfWarrantCancellation; + warrantExercise: OcfWarrantExercise; + warrantIssuance: OcfWarrantIssuance; + warrantRetraction: OcfWarrantRetraction; + warrantTransfer: OcfWarrantTransfer; +}; + +/** All canonical entity kinds supported by batch operations and readers. */ +export type OcfEntityType = keyof OcfEntityDataMap; + +/** Canonical OCF data for one entity kind. */ +export type OcfDataTypeFor = OcfEntityDataMap[T]; + +/** Entity kinds that can be created through UpdateCapTable. */ +export type OcfCreatableEntityType = Exclude; + +/** Every canonical entity can be edited through UpdateCapTable. */ +export type OcfEditableEntityType = OcfEntityType; + +/** Entity kinds that can be deleted through UpdateCapTable. */ +export type OcfDeletableEntityType = Exclude; + +/** Correlated argument tuples accepted by {@link import('./CapTableBatch').CapTableBatch.create}. */ +export type OcfCreateArguments = { + [EntityType in OcfCreatableEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; +}[OcfCreatableEntityType]; + +/** Correlated argument tuples accepted by {@link import('./CapTableBatch').CapTableBatch.edit}. */ +export type OcfEditArguments = { + [EntityType in OcfEditableEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; +}[OcfEditableEntityType]; + +/** A create operation whose kind and payload remain correlated. */ +export type OcfCreateOperation = + EntityType extends OcfCreatableEntityType ? Readonly<{ type: EntityType; data: OcfDataTypeFor }> : never; + +/** An edit operation whose kind and payload remain correlated. */ +export type OcfEditOperation = + EntityType extends OcfEditableEntityType ? Readonly<{ type: EntityType; data: OcfDataTypeFor }> : never; + +/** A delete operation limited to deletable entity kinds. */ +export type OcfDeleteOperation = + EntityType extends OcfDeletableEntityType ? Readonly<{ type: EntityType; id: string }> : never; + +/** Native operations accepted by {@link import('./CapTableBatch').buildUpdateCapTableCommand}. */ +export interface CapTableBatchOperations { + readonly creates?: readonly OcfCreateOperation[]; + readonly edits?: readonly OcfEditOperation[]; + readonly deletes?: readonly OcfDeleteOperation[]; +} + +/** Contract ID returned for one OCF entity created or edited by a batch. */ +export type OcfContractId = { + [EntityType in OcfEntityType]: Readonly<{ + tag: `Cid${Capitalize}`; + value: string; + }>; +}[OcfEntityType]; + +/** Result of executing a fluent cap-table batch. */ +export interface CapTableBatchExecuteResult { + readonly updatedCapTableCid: string; + readonly createdCids: readonly OcfContractId[]; + readonly editedCids: readonly OcfContractId[]; + readonly updateId: string; +} + +/** Canonical OCF `object_type` to typed reader namespace. */ +export const OCF_OBJECT_TYPE_TO_ENTITY_TYPE = Object.freeze({ + CE_STAKEHOLDER_RELATIONSHIP: 'stakeholderRelationshipChangeEvent', + CE_STAKEHOLDER_STATUS: 'stakeholderStatusChangeEvent', + DOCUMENT: 'document', + FINANCING: 'financing', + ISSUER: 'issuer', + STAKEHOLDER: 'stakeholder', + STOCK_CLASS: 'stockClass', + STOCK_LEGEND_TEMPLATE: 'stockLegendTemplate', + STOCK_PLAN: 'stockPlan', + TX_CONVERTIBLE_ACCEPTANCE: 'convertibleAcceptance', + TX_CONVERTIBLE_CANCELLATION: 'convertibleCancellation', + TX_CONVERTIBLE_CONVERSION: 'convertibleConversion', + TX_CONVERTIBLE_ISSUANCE: 'convertibleIssuance', + TX_CONVERTIBLE_RETRACTION: 'convertibleRetraction', + TX_CONVERTIBLE_TRANSFER: 'convertibleTransfer', + TX_EQUITY_COMPENSATION_ACCEPTANCE: 'equityCompensationAcceptance', + TX_EQUITY_COMPENSATION_CANCELLATION: 'equityCompensationCancellation', + TX_EQUITY_COMPENSATION_EXERCISE: 'equityCompensationExercise', + TX_EQUITY_COMPENSATION_ISSUANCE: 'equityCompensationIssuance', + TX_EQUITY_COMPENSATION_RELEASE: 'equityCompensationRelease', + TX_EQUITY_COMPENSATION_REPRICING: 'equityCompensationRepricing', + TX_EQUITY_COMPENSATION_RETRACTION: 'equityCompensationRetraction', + TX_EQUITY_COMPENSATION_TRANSFER: 'equityCompensationTransfer', + TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT: 'issuerAuthorizedSharesAdjustment', + TX_STOCK_ACCEPTANCE: 'stockAcceptance', + TX_STOCK_CANCELLATION: 'stockCancellation', + TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT: 'stockClassAuthorizedSharesAdjustment', + TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT: 'stockClassConversionRatioAdjustment', + TX_STOCK_CLASS_SPLIT: 'stockClassSplit', + TX_STOCK_CONSOLIDATION: 'stockConsolidation', + TX_STOCK_CONVERSION: 'stockConversion', + TX_STOCK_ISSUANCE: 'stockIssuance', + TX_STOCK_PLAN_POOL_ADJUSTMENT: 'stockPlanPoolAdjustment', + TX_STOCK_PLAN_RETURN_TO_POOL: 'stockPlanReturnToPool', + TX_STOCK_REISSUANCE: 'stockReissuance', + TX_STOCK_REPURCHASE: 'stockRepurchase', + TX_STOCK_RETRACTION: 'stockRetraction', + TX_STOCK_TRANSFER: 'stockTransfer', + TX_VESTING_ACCELERATION: 'vestingAcceleration', + TX_VESTING_EVENT: 'vestingEvent', + TX_VESTING_START: 'vestingStart', + TX_WARRANT_ACCEPTANCE: 'warrantAcceptance', + TX_WARRANT_CANCELLATION: 'warrantCancellation', + TX_WARRANT_EXERCISE: 'warrantExercise', + TX_WARRANT_ISSUANCE: 'warrantIssuance', + TX_WARRANT_RETRACTION: 'warrantRetraction', + TX_WARRANT_TRANSFER: 'warrantTransfer', + VALUATION: 'valuation', + VESTING_TERMS: 'vestingTerms', +} as const satisfies Record); + +type MappedOcfEntityType = (typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE)[keyof typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE]; +const ALL_OCF_ENTITY_TYPES_ARE_MAPPED: [OcfEntityType] extends [MappedOcfEntityType] ? true : never = true; +void ALL_OCF_ENTITY_TYPES_ARE_MAPPED; + +/** OCF object types supported by the high-level entity readers. */ +export type OcfReadableObjectType = keyof typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE; + +/** Reader namespace for one canonical OCF object type. */ +export type OcfEntityTypeForObjectType = (typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE)[T]; + +/** Canonical data returned by one object-type reader. */ +export type OcfReadableDataForObjectType = OcfDataTypeFor< + OcfEntityTypeForObjectType +>; + +const OCF_ENTITY_TYPES: ReadonlySet = new Set(Object.values(OCF_OBJECT_TYPE_TO_ENTITY_TYPE)); + +/** Runtime guard for canonical SDK entity kinds. */ +export function isOcfEntityType(entityType: string): entityType is OcfEntityType { + return OCF_ENTITY_TYPES.has(entityType); +} + +/** Runtime guard for entity kinds that support creation. */ +export function isOcfCreatableEntityType(entityType: string): entityType is OcfCreatableEntityType { + return entityType !== 'issuer' && isOcfEntityType(entityType); +} + +/** Runtime guard for entity kinds that support edits. */ +export function isOcfEditableEntityType(entityType: string): entityType is OcfEditableEntityType { + return isOcfEntityType(entityType); +} + +/** Runtime guard for entity kinds that support deletion. */ +export function isOcfDeletableEntityType(entityType: string): entityType is OcfDeletableEntityType { + return entityType !== 'issuer' && isOcfEntityType(entityType); +} + +export function mapOcfObjectTypeToEntityType( + objectType: T +): OcfEntityTypeForObjectType; +export function mapOcfObjectTypeToEntityType(objectType: string): OcfEntityType | null; +export function mapOcfObjectTypeToEntityType(objectType: string): OcfEntityType | null { + return isOcfReadableObjectType(objectType) ? OCF_OBJECT_TYPE_TO_ENTITY_TYPE[objectType] : null; +} + +/** Runtime guard for OCF object types supported by high-level readers. */ +export function isOcfReadableObjectType(objectType: string): objectType is OcfReadableObjectType { + return Object.prototype.hasOwnProperty.call(OCF_OBJECT_TYPE_TO_ENTITY_TYPE, objectType); +} diff --git a/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts b/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts new file mode 100644 index 00000000..c0e66753 --- /dev/null +++ b/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts @@ -0,0 +1,158 @@ +/** @internal Generated DAML operation construction for CapTableBatch. */ + +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { + ENTITY_TAG_MAP, + type OcfCreateData, + type OcfCreateDataFor, + type OcfDeleteData, + type OcfDeleteDataFor, + type OcfEditData, + type OcfEditDataFor, +} from './batchTypes'; +import type { + OcfCreatableEntityType, + OcfCreateArguments, + OcfCreateOperation, + OcfDeletableEntityType, + OcfDeleteOperation, + OcfEditableEntityType, + OcfEditArguments, + OcfEditOperation, + OcfEntityType, +} from './entityTypes'; +import { isOcfCreatableEntityType, isOcfDeletableEntityType, isOcfEditableEntityType } from './entityTypes'; +import { convertOperationToDaml, convertToDaml } from './ocfToDaml'; + +function decodeGeneratedOperation( + decoder: { runWithException: (input: unknown) => T }, + input: unknown, + operation: 'create' | 'edit' | 'delete', + entityType: OcfEntityType +): T { + try { + return decoder.runWithException(input); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new OcpValidationError( + `batch.${operation}.${entityType}`, + `Converter output does not match the generated DAML ${operation} variant: ${message}`, + { + code: OcpErrorCodes.INVALID_FORMAT, + context: { operation, entityType }, + } + ); + } +} + +interface GeneratedOperationDataMap { + readonly create: OcfCreateData; + readonly edit: OcfEditData; + readonly delete: OcfDeleteData; +} + +interface GeneratedOperationEntityTypeMap { + readonly create: OcfCreatableEntityType; + readonly edit: OcfEditableEntityType; + readonly delete: OcfDeletableEntityType; +} + +type GeneratedOperation = keyof GeneratedOperationDataMap; + +interface GeneratedOperationBuilder { + readonly operation: Operation; + readonly displayName: Capitalize; + readonly supports: (type: string) => type is GeneratedOperationEntityTypeMap[Operation]; + readonly tagFor: (type: GeneratedOperationEntityTypeMap[Operation]) => GeneratedOperationDataMap[Operation]['tag']; + readonly decoder: { runWithException: (input: unknown) => GeneratedOperationDataMap[Operation] }; +} + +const CREATE_OPERATION_BUILDER: GeneratedOperationBuilder<'create'> = { + operation: 'create', + displayName: 'Create', + supports: isOcfCreatableEntityType, + tagFor: (type) => ENTITY_TAG_MAP[type].create, + decoder: Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, +}; + +const EDIT_OPERATION_BUILDER: GeneratedOperationBuilder<'edit'> = { + operation: 'edit', + displayName: 'Edit', + supports: isOcfEditableEntityType, + tagFor: (type) => ENTITY_TAG_MAP[type].edit, + decoder: Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, +}; + +const DELETE_OPERATION_BUILDER: GeneratedOperationBuilder<'delete'> = { + operation: 'delete', + displayName: 'Delete', + supports: isOcfDeletableEntityType, + tagFor: (type) => ENTITY_TAG_MAP[type].delete, + decoder: Fairmint.OpenCapTable.CapTable.OcfDeleteData.decoder, +}; + +function buildGeneratedOperationData( + type: OcfEntityType, + convert: () => unknown, + builder: GeneratedOperationBuilder +): GeneratedOperationDataMap[Operation] { + if (!builder.supports(type)) { + throw new OcpValidationError( + 'type', + `${builder.displayName} operation not supported for entity type: ${String(type)}`, + { + code: OcpErrorCodes.INVALID_TYPE, + } + ); + } + + return decodeGeneratedOperation( + builder.decoder, + { tag: builder.tagFor(type), value: convert() }, + builder.operation, + type + ); +} + +/** @internal Build and validate one generated DAML create variant. */ +export function buildOcfCreateData( + ...args: Arguments +): OcfCreateDataFor; +export function buildOcfCreateData(...args: OcfCreateArguments): OcfCreateData { + const [type] = args; + return buildGeneratedOperationData(type, () => convertToDaml(...args), CREATE_OPERATION_BUILDER); +} + +export function buildOcfCreateDataFromOperation(operation: OcfCreateOperation): OcfCreateData { + const { type } = operation; + return buildGeneratedOperationData(type, () => convertOperationToDaml(operation), CREATE_OPERATION_BUILDER); +} + +/** @internal Build and validate one generated DAML edit variant. */ +export function buildOcfEditData( + ...args: Arguments +): OcfEditDataFor; +export function buildOcfEditData(...args: OcfEditArguments): OcfEditData { + const [type] = args; + return buildGeneratedOperationData(type, () => convertToDaml(...args), EDIT_OPERATION_BUILDER); +} + +export function buildOcfEditDataFromOperation(operation: OcfEditOperation): OcfEditData { + const { type } = operation; + return buildGeneratedOperationData(type, () => convertOperationToDaml(operation), EDIT_OPERATION_BUILDER); +} + +/** @internal Build and validate one generated DAML delete variant. */ +export function buildOcfDeleteData( + type: EntityType, + id: string +): OcfDeleteDataFor; +export function buildOcfDeleteData(type: OcfDeletableEntityType, id: string): OcfDeleteData { + return buildGeneratedOperationData(type, () => id, DELETE_OPERATION_BUILDER); +} + +export function buildOcfDeleteDataFromOperation(operation: OcfDeleteOperation): OcfDeleteData { + const { type } = operation; + return buildGeneratedOperationData(type, () => operation.id, DELETE_OPERATION_BUILDER); +} diff --git a/src/functions/OpenCapTable/capTable/getCapTableState.ts b/src/functions/OpenCapTable/capTable/getCapTableState.ts index aa13c41f..889478f6 100644 --- a/src/functions/OpenCapTable/capTable/getCapTableState.ts +++ b/src/functions/OpenCapTable/capTable/getCapTableState.ts @@ -35,8 +35,8 @@ import { } from '../../../utils/contractReadDiagnostics'; import { ledgerReadScope } from '../../../utils/readScope'; import { parseDamlMap } from '../../../utils/typeConversions'; -import { FIELD_TO_ENTITY_TYPE, SECURITY_ID_FIELD_TO_ENTITY_TYPE, type OcfEntityType } from './batchTypes'; -export { FIELD_TO_ENTITY_TYPE, SECURITY_ID_FIELD_TO_ENTITY_TYPE } from './batchTypes'; +import { FIELD_TO_ENTITY_TYPE, SECURITY_ID_FIELD_TO_ENTITY_TYPE } from './batchTypes'; +import type { OcfEntityType } from './entityTypes'; /** CapTable template ID this SDK treats as current (package-name symbolic form; used for ledger queries). */ const CURRENT_CAP_TABLE_TEMPLATE_ID = OCP_TEMPLATES.capTable; diff --git a/src/functions/OpenCapTable/capTable/index.ts b/src/functions/OpenCapTable/capTable/index.ts index 6e8e8892..13d6f1e4 100644 --- a/src/functions/OpenCapTable/capTable/index.ts +++ b/src/functions/OpenCapTable/capTable/index.ts @@ -20,14 +20,12 @@ export { export * from './batchTypes'; export { CapTableBatch, - buildOcfCreateData, - buildOcfDeleteData, - buildOcfEditData, buildUpdateCapTableCommand, type BatchItemDetails, type BatchItemMeta, type CapTableBatchParams, } from './CapTableBatch'; +export { buildOcfCreateData, buildOcfDeleteData, buildOcfEditData } from './generatedBatchOperations'; export { convertToDaml } from './ocfToDaml'; // DAML to OCF conversion (read operations) @@ -43,9 +41,8 @@ export { } from './damlToOcf'; // CapTable state reader (for replication) +export { FIELD_TO_ENTITY_TYPE, SECURITY_ID_FIELD_TO_ENTITY_TYPE } from './batchTypes'; export { - FIELD_TO_ENTITY_TYPE, - SECURITY_ID_FIELD_TO_ENTITY_TYPE, classifyIssuerCapTables, getCapTableState, type CapTableState, diff --git a/src/functions/OpenCapTable/issuer/api.ts b/src/functions/OpenCapTable/issuer/api.ts new file mode 100644 index 00000000..e1f75716 --- /dev/null +++ b/src/functions/OpenCapTable/issuer/api.ts @@ -0,0 +1,12 @@ +/** Public CreateIssuer API, isolated from generated DAML converter declarations. */ + +import type { CommandWithDisclosedContracts } from '../../../types/common'; +import { buildCreateIssuerCommand as buildGeneratedCreateIssuerCommand } from './createIssuer'; +import type { CreateIssuerParams } from './types'; + +export type { CreateIssuerParams, IssuerDataInput } from './types'; + +/** Build the CreateCapTable command that creates an issuer and its CapTable. */ +export function buildCreateIssuerCommand(params: CreateIssuerParams): CommandWithDisclosedContracts { + return buildGeneratedCreateIssuerCommand(params); +} diff --git a/src/functions/OpenCapTable/issuer/createIssuer.ts b/src/functions/OpenCapTable/issuer/createIssuer.ts index 956351cb..77c914a4 100644 --- a/src/functions/OpenCapTable/issuer/createIssuer.ts +++ b/src/functions/OpenCapTable/issuer/createIssuer.ts @@ -3,7 +3,8 @@ import type { DisclosedContract, } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/commands'; import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import type { CommandWithDisclosedContracts, OcfIssuer } from '../../../types'; +import type { CommandWithDisclosedContracts } from '../../../types/common'; +import type { OcfIssuer } from '../../../types/native'; import { validateIssuerData } from '../../../utils/entityValidators'; import { emailTypeToDaml, phoneTypeToDaml } from '../../../utils/enumConversions'; import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; @@ -15,6 +16,7 @@ import { initialSharesAuthorizedToDaml, optionalString, } from '../../../utils/typeConversions'; +import type { CreateIssuerParams, IssuerDataInput } from './types'; function emailToDaml(email: OcfIssuer['email']): Fairmint.OpenCapTable.Types.Contact.OcfEmail | null { if (!email) return null; @@ -36,10 +38,7 @@ function phoneToDaml(phone: OcfIssuer['phone']): Fairmint.OpenCapTable.Types.Con * Input type for issuer data that may have missing array fields. * The SDK normalizes these to empty arrays automatically. */ -export type IssuerDataInput = Omit & { - /** Tax IDs - normalized to empty array if null/undefined */ - tax_ids?: OcfIssuer['tax_ids'] | null; -}; +export type { CreateIssuerParams, IssuerDataInput } from './types'; /** * Normalize issuer data by ensuring array fields are arrays (not null/undefined). @@ -122,31 +121,6 @@ function issuerDataToDamlInternal( }; } -export interface CreateIssuerParams { - /** Details of the IssuerAuthorization contract for disclosed contracts */ - issuerAuthorizationContractDetails: DisclosedContract; - issuerParty: string; - /** - * Issuer data to create - * - * Schema: https://schema.opencaptablecoalition.com/v/1.2.0/objects/Issuer.schema.json - * - * - Legal_name: Legal name of the issuer - * - Formation_date: Date of formation (YYYY-MM-DD) - * - Country_of_formation: Country of formation (ISO 3166-1 alpha-2) - * - Dba (optional): Doing Business As name - * - Country_subdivision_of_formation (optional): Subdivision code of formation (ISO 3166-2) - * - Country_subdivision_name_of_formation (optional): Text name of subdivision of formation - * - Tax_ids (optional): Issuer tax IDs (normalized to empty array if null/undefined) - * - Email (optional): Work email - * - Phone (optional): Phone number in ITU E.123 format - * - Address (optional): Headquarters address - * - Initial_shares_authorized (optional): Initial authorized shares (enum or numeric) - * - Comments (optional): Additional comments - */ - issuerData: IssuerDataInput; -} - /** * Build the ledger command to exercise **CreateCapTable** on an IssuerAuthorization contract. * diff --git a/src/functions/OpenCapTable/issuer/index.ts b/src/functions/OpenCapTable/issuer/index.ts index 2f332fc6..61ea4947 100644 --- a/src/functions/OpenCapTable/issuer/index.ts +++ b/src/functions/OpenCapTable/issuer/index.ts @@ -1,2 +1,3 @@ -export * from './createIssuer'; +export * from './api'; +export { issuerDataToDaml, normalizeIssuerData } from './createIssuer'; export * from './getIssuerAsOcf'; diff --git a/src/functions/OpenCapTable/issuer/types.ts b/src/functions/OpenCapTable/issuer/types.ts new file mode 100644 index 00000000..d970c16b --- /dev/null +++ b/src/functions/OpenCapTable/issuer/types.ts @@ -0,0 +1,18 @@ +import type { DisclosedContract } from '../../../types/common'; +import type { OcfIssuer } from '../../../types/native'; + +/** Issuer input accepted by the high-level CreateCapTable command builder. */ +export type IssuerDataInput = Omit & { + /** Tax IDs are normalized to an empty array when omitted or null. */ + tax_ids?: OcfIssuer['tax_ids'] | null; +}; + +/** Parameters for creating the issuer and its CapTable. */ +export interface CreateIssuerParams { + /** IssuerAuthorization contract disclosed to the CreateCapTable choice. */ + issuerAuthorizationContractDetails: DisclosedContract; + /** Issuer party authorizing creation. */ + issuerParty: string; + /** Canonical OCF issuer payload. */ + issuerData: IssuerDataInput; +} diff --git a/src/functions/OpenCapTable/issuerAuthorization/authorizeIssuer.ts b/src/functions/OpenCapTable/issuerAuthorization/authorizeIssuer.ts index db65d91f..57bedc9e 100644 --- a/src/functions/OpenCapTable/issuerAuthorization/authorizeIssuer.ts +++ b/src/functions/OpenCapTable/issuerAuthorization/authorizeIssuer.ts @@ -1,24 +1,12 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; -import type { DisclosedContract } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas'; import { findCreatedEventByTemplateId } from '@fairmint/canton-node-sdk/build/src/utils/contracts/findCreatedEvent'; import { OCP_TEMPLATES, type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import factoryContractIdData from '@fairmint/open-captable-protocol-daml-js/ocp-factory-contract-id.json'; import { OcpContractError, OcpErrorCodes, OcpValidationError } from '../../../errors'; -import { submitObservedTransactionTree, type CommandObservabilityOptions } from '../../../observability'; +import { submitObservedTransactionTree } from '../../../observability'; +import type { AuthorizeIssuerParams, AuthorizeIssuerResult } from './types'; -export interface AuthorizeIssuerParams extends CommandObservabilityOptions { - issuer: string; // Party ID of the issuer to authorize - /** Override: factory contract ID (e.g. for staging). Requires factoryTemplateId. */ - factoryContractId?: string; - /** Override: factory template ID (e.g. for staging). Required when factoryContractId is set. */ - factoryTemplateId?: string; -} - -export interface AuthorizeIssuerResult extends DisclosedContract { - updateId: string; - response: SubmitAndWaitForTransactionTreeResponse; -} +export type { AuthorizeIssuerParams, AuthorizeIssuerResult } from './types'; /** * Authorize an issuer using the OCP Factory contract diff --git a/src/functions/OpenCapTable/issuerAuthorization/index.ts b/src/functions/OpenCapTable/issuerAuthorization/index.ts index 60f79215..e6223834 100644 --- a/src/functions/OpenCapTable/issuerAuthorization/index.ts +++ b/src/functions/OpenCapTable/issuerAuthorization/index.ts @@ -1,2 +1,3 @@ export * from './authorizeIssuer'; +export * from './types'; export * from './withdrawAuthorization'; diff --git a/src/functions/OpenCapTable/issuerAuthorization/types.ts b/src/functions/OpenCapTable/issuerAuthorization/types.ts new file mode 100644 index 00000000..1b03dd92 --- /dev/null +++ b/src/functions/OpenCapTable/issuerAuthorization/types.ts @@ -0,0 +1,29 @@ +import type { CommandObservabilityOptions } from '../../../observability'; +import type { DisclosedContract, SubmitAndWaitForTransactionTreeResponse } from '../../../types/common'; + +/** Parameters for authorizing an issuer through the OCP Factory. */ +export interface AuthorizeIssuerParams extends CommandObservabilityOptions { + issuer: string; + /** Factory contract override. Must be paired with `factoryTemplateId`. */ + factoryContractId?: string; + /** Factory template override. Must be paired with `factoryContractId`. */ + factoryTemplateId?: string; +} + +/** Result of authorizing an issuer. */ +export interface AuthorizeIssuerResult extends DisclosedContract { + updateId: string; + response: SubmitAndWaitForTransactionTreeResponse; +} + +/** Parameters for withdrawing an issuer authorization. */ +export interface WithdrawAuthorizationParams extends CommandObservabilityOptions { + issuerAuthorizationContractId: string; + systemOperatorParty: string; +} + +/** Result of withdrawing an issuer authorization. */ +export interface WithdrawAuthorizationResult { + updateId: string; + response: SubmitAndWaitForTransactionTreeResponse; +} diff --git a/src/functions/OpenCapTable/issuerAuthorization/withdrawAuthorization.ts b/src/functions/OpenCapTable/issuerAuthorization/withdrawAuthorization.ts index 11cf56f1..3d8346ad 100644 --- a/src/functions/OpenCapTable/issuerAuthorization/withdrawAuthorization.ts +++ b/src/functions/OpenCapTable/issuerAuthorization/withdrawAuthorization.ts @@ -1,17 +1,9 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; import { OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; -import { submitObservedTransactionTree, type CommandObservabilityOptions } from '../../../observability'; +import { submitObservedTransactionTree } from '../../../observability'; +import type { WithdrawAuthorizationParams, WithdrawAuthorizationResult } from './types'; -export interface WithdrawAuthorizationParams extends CommandObservabilityOptions { - issuerAuthorizationContractId: string; - systemOperatorParty: string; -} - -export interface WithdrawAuthorizationResult { - updateId: string; - response: SubmitAndWaitForTransactionTreeResponse; -} +export type { WithdrawAuthorizationParams, WithdrawAuthorizationResult } from './types'; export async function withdrawAuthorization( client: LedgerJsonApiClient, diff --git a/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts b/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts index 876d8d3d..3d7c7930 100644 --- a/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts +++ b/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts @@ -1,10 +1,6 @@ import { OcpErrorCodes, OcpParseError } from '../../../errors'; -import type { - OcfStockIssuance, - PkgStockIssuanceOcfData, - PkgStockIssuanceType, - StockIssuanceType, -} from '../../../types'; +import type { PkgStockIssuanceOcfData, PkgStockIssuanceType } from '../../../types/daml'; +import type { OcfStockIssuance, StockIssuanceType } from '../../../types/native'; import { validateStockIssuanceData } from '../../../utils/entityValidators'; import { cleanComments, diff --git a/src/index.ts b/src/index.ts index f26ec0e8..09ad1a62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,48 @@ +/** + * Curated public API for the OCP Canton SDK. + * + * Generated DAML codecs and low-level conversion/read helpers intentionally stay + * behind this boundary. Public declarations are expressed only in canonical OCF + * and Canton client types so strict consumers do not inherit code-generator + * implementation details. + */ + export * from './environment'; export * from './errors'; -export * from './functions'; export * from './observability'; export * from './OcpClient'; -export * from './types'; -export * from './utils'; + +export * from './types/branded'; +export * from './types/common'; +export * from './types/native'; +export * from './types/output'; + +export type { ArchiveCapTableParams, ArchiveCapTableResult } from './functions/OpenCapTable/capTable/archiveCapTable'; +export { + CapTableBatch, + buildUpdateCapTableCommand, + type BatchItemDetails, + type BatchItemMeta, + type CapTableBatchParams, +} from './functions/OpenCapTable/capTable/CapTableBatch'; +export * from './functions/OpenCapTable/capTable/entityTypes'; +export type { + CapTableState, + CapTableWithArchiveContext, + IssuerCapTableClassification, + IssuerCapTableStatus, +} from './functions/OpenCapTable/capTable/getCapTableState'; + +export { + buildCreateIssuerCommand, + type CreateIssuerParams, + type IssuerDataInput, +} from './functions/OpenCapTable/issuer/api'; +export { authorizeIssuer } from './functions/OpenCapTable/issuerAuthorization/authorizeIssuer'; +export type { + AuthorizeIssuerParams, + AuthorizeIssuerResult, + WithdrawAuthorizationParams, + WithdrawAuthorizationResult, +} from './functions/OpenCapTable/issuerAuthorization/types'; +export { withdrawAuthorization } from './functions/OpenCapTable/issuerAuthorization/withdrawAuthorization'; diff --git a/src/types/common.ts b/src/types/common.ts index f199aebb..087f8c9b 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -22,6 +22,12 @@ export type { ClientConfig, LedgerJsonApiClient, ValidatorApiClient } from '@fai export type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; +/** A ledger command together with the contracts disclosed for its execution. */ +export interface CommandWithDisclosedContracts { + command: Command; + disclosedContracts: DisclosedContract[]; +} + // ===== Common Params Types ===== /** diff --git a/src/types/index.ts b/src/types/index.ts index 795f9c18..31b49103 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,16 +1,4 @@ export * from './branded'; export * from './common'; -export * from './daml'; export * from './native'; export * from './output'; - -import type { Command, DisclosedContract } from './common'; - -/** - * Return type for all buildCreate*Command functions. Contains a command and its associated disclosed contracts for - * Canton cross-domain interactions. - */ -export interface CommandWithDisclosedContracts { - command: Command; - disclosedContracts: DisclosedContract[]; -} diff --git a/src/utils/cantonOcfExtractor.ts b/src/utils/cantonOcfExtractor.ts index 26d9fb45..677d1a83 100644 --- a/src/utils/cantonOcfExtractor.ts +++ b/src/utils/cantonOcfExtractor.ts @@ -13,9 +13,9 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpErrorCodes } from '../errors/codes'; import { OcpValidationError } from '../errors/OcpValidationError'; -import type { OcfEntityType } from '../functions/OpenCapTable/capTable/batchTypes'; import type { SupportedOcfReadType } from '../functions/OpenCapTable/capTable/damlToOcf'; import { getEntityAsOcf } from '../functions/OpenCapTable/capTable/damlToOcf'; +import type { OcfEntityType } from '../functions/OpenCapTable/capTable/entityTypes'; import type { CapTableState } from '../functions/OpenCapTable/capTable/getCapTableState'; import { getConvertibleIssuanceAsOcf } from '../functions/OpenCapTable/convertibleIssuance'; import { getDocumentAsOcf } from '../functions/OpenCapTable/document'; diff --git a/src/utils/replicationHelpers.ts b/src/utils/replicationHelpers.ts index 730beaa0..e5f6bd6b 100644 --- a/src/utils/replicationHelpers.ts +++ b/src/utils/replicationHelpers.ts @@ -9,14 +9,14 @@ * @module replicationHelpers */ -import type { OcfEntityType } from '../functions/OpenCapTable/capTable/batchTypes'; +import type { OcfEntityType } from '../functions/OpenCapTable/capTable/entityTypes'; import type { CapTableState } from '../functions/OpenCapTable/capTable/getCapTableState'; import type { OcfManifest } from './cantonOcfExtractor'; import { DEFAULT_DEPRECATED_FIELDS, DEFAULT_INTERNAL_FIELDS, ocfDeepEqual } from './ocfComparison'; import { normalizeObjectType, normalizeOcfData } from './planSecurityAliases'; -// Preserve the public utils import path while keeping the registry as the single implementation. -export { isOcfEntityType } from '../functions/OpenCapTable/capTable/batchTypes'; +// Preserve the public utils import path while keeping the protocol-native guard implementation centralized. +export { isOcfEntityType } from '../functions/OpenCapTable/capTable/entityTypes'; // ============================================================================ // Categorized Type Mapping diff --git a/test/batch/batchTypes.test.ts b/test/batch/batchTypes.test.ts index 6cf51ce0..1aa98dc1 100644 --- a/test/batch/batchTypes.test.ts +++ b/test/batch/batchTypes.test.ts @@ -1,15 +1,34 @@ import { - ENTITY_REGISTRY, isOcfCreatableEntityType, isOcfDeletableEntityType, isOcfEditableEntityType, isOcfEntityType, + isOcfReadableObjectType, + mapOcfObjectTypeToEntityType, + OCF_OBJECT_TYPE_TO_ENTITY_TYPE, type OcfEntityType, } from '../../src'; +import { ENTITY_REGISTRY } from '../../src/functions/OpenCapTable/capTable/batchTypes'; const entityTypes = Object.keys(ENTITY_REGISTRY) as OcfEntityType[]; describe('batch entity capabilities', () => { + it('maps every registered entity type from a canonical OCF object type', () => { + expect(new Set(Object.values(OCF_OBJECT_TYPE_TO_ENTITY_TYPE))).toEqual(new Set(entityTypes)); + }); + + it('keeps the public object-type mapping immutable at runtime', () => { + expect(Object.isFrozen(OCF_OBJECT_TYPE_TO_ENTITY_TYPE)).toBe(true); + + expect(Reflect.set(OCF_OBJECT_TYPE_TO_ENTITY_TYPE, 'STOCK_CLASS', 'issuer')).toBe(false); + expect(Reflect.set(OCF_OBJECT_TYPE_TO_ENTITY_TYPE, 'SYNTHETIC', 'stakeholder')).toBe(false); + + expect(OCF_OBJECT_TYPE_TO_ENTITY_TYPE.STOCK_CLASS).toBe('stockClass'); + expect(mapOcfObjectTypeToEntityType('STOCK_CLASS')).toBe('stockClass'); + expect(isOcfReadableObjectType('SYNTHETIC')).toBe(false); + expect(mapOcfObjectTypeToEntityType('SYNTHETIC')).toBeNull(); + }); + it.each(entityTypes)('recognizes %s as a supported entity type', (entityType) => { expect(isOcfEntityType(entityType)).toBe(true); }); diff --git a/test/batch/generatedOperationConstruction.test.ts b/test/batch/generatedOperationConstruction.test.ts index ab5ecce1..a3a4f89a 100644 --- a/test/batch/generatedOperationConstruction.test.ts +++ b/test/batch/generatedOperationConstruction.test.ts @@ -1,25 +1,25 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { - buildOcfCreateData, - buildOcfDeleteData, - buildOcfEditData, buildUpdateCapTableCommand, - ENTITY_REGISTRY, - ENTITY_TAG_MAP, - getEntityAsOcf, isOcfCreatableEntityType, isOcfDeletableEntityType, isOcfEditableEntityType, OcpClient, - parseOcfEntityInput, - parseOcfObject, type OcfCreateArguments, type OcfDataTypeFor, type OcfEditArguments, type OcfEntityType, } from '../../src'; import { OcpValidationError } from '../../src/errors'; +import { ENTITY_REGISTRY, ENTITY_TAG_MAP } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { getEntityAsOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import { + buildOcfCreateData, + buildOcfDeleteData, + buildOcfEditData, +} from '../../src/functions/OpenCapTable/capTable/generatedBatchOperations'; +import { parseOcfEntityInput, parseOcfObject } from '../../src/utils/ocfZodSchemas'; import { loadFixture, stripSourceMetadata } from '../utils/productionFixtures'; function loadEntityFixture( diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index eb764097..521a014a 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -1,7 +1,6 @@ import { Canton, type ClientConfig } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes } from '../../src/errors'; -import * as openCapTableCapTable from '../../src/functions/OpenCapTable/capTable'; import { ENTITY_REGISTRY, OCF_OBJECT_TYPE_TO_ENTITY_TYPE, @@ -9,6 +8,7 @@ import { type OcfEntityType, type OcfReadableObjectType, } from '../../src/functions/OpenCapTable/capTable'; +import * as capTableState from '../../src/functions/OpenCapTable/capTable/getCapTableState'; import { authorizeIssuer, type AuthorizeIssuerResult, @@ -338,15 +338,15 @@ describe('OcpClient OpenCapTable.issuerAuthorization.authorize', () => { describe('OcpClient OpenCapTable.capTable facade', () => { const config: ClientConfig = { network: 'devnet' }; - let classifySpy: jest.SpiedFunction; - let getStateSpy: jest.SpiedFunction; + let classifySpy: jest.SpiedFunction; + let getStateSpy: jest.SpiedFunction; beforeEach(() => { - classifySpy = jest.spyOn(openCapTableCapTable, 'classifyIssuerCapTables').mockResolvedValue({ + classifySpy = jest.spyOn(capTableState, 'classifyIssuerCapTables').mockResolvedValue({ status: 'none', current: null, }); - getStateSpy = jest.spyOn(openCapTableCapTable, 'getCapTableState').mockResolvedValue(null); + getStateSpy = jest.spyOn(capTableState, 'getCapTableState').mockResolvedValue(null); }); afterEach(() => { diff --git a/test/converters/coreObjectReadValidation.test.ts b/test/converters/coreObjectReadValidation.test.ts index cc2675c3..de562a53 100644 --- a/test/converters/coreObjectReadValidation.test.ts +++ b/test/converters/coreObjectReadValidation.test.ts @@ -1,4 +1,6 @@ -import { damlDocumentDataToNative, damlStakeholderDataToNative, OcpValidationError } from '../../src'; +import { OcpValidationError } from '../../src'; +import { damlDocumentDataToNative } from '../../src/functions/OpenCapTable/document/getDocumentAsOcf'; +import { damlStakeholderDataToNative } from '../../src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf'; const minimalStakeholder = { id: 'stakeholder-read-1', diff --git a/test/converters/financingConverters.test.ts b/test/converters/financingConverters.test.ts index e33c02ce..726c7a56 100644 --- a/test/converters/financingConverters.test.ts +++ b/test/converters/financingConverters.test.ts @@ -1,10 +1,8 @@ -import { - buildOcfCreateData, - convertToOcf, - damlFinancingToNative, - financingDataToDaml, - type OcfFinancing, -} from '../../src'; +import type { OcfFinancing } from '../../src'; +import { convertToOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import { buildOcfCreateData } from '../../src/functions/OpenCapTable/capTable/generatedBatchOperations'; +import { damlFinancingToNative } from '../../src/functions/OpenCapTable/financing/damlToOcf'; +import { financingDataToDaml } from '../../src/functions/OpenCapTable/financing/financingDataToDaml'; describe('Financing converters', () => { const financing: OcfFinancing = { diff --git a/test/declarations/damlReadDispatch.types.ts b/test/declarations/damlReadDispatch.types.ts index 2b92afef..05f6114c 100644 --- a/test/declarations/damlReadDispatch.types.ts +++ b/test/declarations/damlReadDispatch.types.ts @@ -1,13 +1,9 @@ /** Built-declaration contracts for generated DAML read dispatch. */ import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { - convertToOcf, - decodeDamlEntityData, - ENTITY_TEMPLATE_ID_MAP, - type DamlDataTypeFor, - type OcfStakeholder, -} from '../../dist'; +import { ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor } from '../../dist/functions/OpenCapTable/capTable/batchTypes'; +import { convertToOcf, decodeDamlEntityData } from '../../dist/functions/OpenCapTable/capTable/damlToOcf'; +import type { OcfStakeholder } from '../../dist/types/native'; declare const stakeholderDamlData: DamlDataTypeFor<'stakeholder'>; declare const stockClassDamlData: DamlDataTypeFor<'stockClass'>; diff --git a/test/declarations/generatedOperationConstruction.types.ts b/test/declarations/generatedOperationConstruction.types.ts index 10577e2c..1e84c1eb 100644 --- a/test/declarations/generatedOperationConstruction.types.ts +++ b/test/declarations/generatedOperationConstruction.types.ts @@ -1,17 +1,17 @@ /** Built-declaration contracts for exact generated DAML batch operation variants. */ import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import type { + OcfCreateDataFor, + OcfDeleteDataFor, + OcfEditDataFor, +} from '../../dist/functions/OpenCapTable/capTable/batchTypes'; import { buildOcfCreateData, buildOcfDeleteData, buildOcfEditData, - type OcfCreateDataFor, - type OcfDeleteDataFor, - type OcfEditDataFor, - type OcfFinancing, - type OcfIssuer, - type OcfStakeholder, -} from '../../dist'; +} from '../../dist/functions/OpenCapTable/capTable/generatedBatchOperations'; +import type { OcfFinancing, OcfIssuer, OcfStakeholder } from '../../dist/types/native'; declare const stakeholder: OcfStakeholder; declare const financing: OcfFinancing; diff --git a/test/declarations/normalization.types.ts b/test/declarations/normalization.types.ts index 55126897..3cf7264a 100644 --- a/test/declarations/normalization.types.ts +++ b/test/declarations/normalization.types.ts @@ -1,13 +1,13 @@ /** Compile-time contracts for canonicalization helpers in the built SDK declarations. */ +import type { OcfPlanSecurityIssuance } from '../../dist/types/native'; import { deepNormalizeNumericStrings, normalizeEntityType, normalizeObjectType, normalizeOcfData, - normalizeZeroUuidSentinels, - type OcfPlanSecurityIssuance, -} from '../../dist'; +} from '../../dist/utils/planSecurityAliases'; +import { normalizeZeroUuidSentinels } from '../../dist/utils/zeroUuidNormalization'; const normalizedNumericString: string = deepNormalizeNumericStrings('1.00' as const); void normalizedNumericString; diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 884a6c71..0fbda385 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -1,12 +1,18 @@ /* eslint @typescript-eslint/no-redundant-type-constituents: off */ -/** Compile-time smoke tests for declarations exported by the built SDK. */ +/** Compile-time smoke tests for the curated declarations exported by the built SDK. */ import { - convertToDaml, - normalizeZeroUuidSentinels, - ZERO_UUID, - type CapTableBatch, + authorizeIssuer, + buildCreateIssuerCommand, + CapTableBatch, + OcpClient, + OcpValidationError, + withdrawAuthorization, + type AuthorizeIssuerResult, + type CapTableBatchExecuteResult, type CapTableBatchOperations, + type CreateIssuerParams, + type OcfContractId, type OcfCreateOperation, type OcfEntityDataMap, type OcfEntityType, @@ -16,49 +22,60 @@ import { type OcfStakeholder, type OcfStockAcceptance, type OcfStockClass, - type OcfVestingEvent, type OcfVestingStart, - type OcfWarrantAcceptance, + type SubmitAndWaitForTransactionTreeResponse, + type WithdrawAuthorizationResult, } from '../../dist'; -import { isOcfEntityType as isOcfEntityTypeFromUtils } from '../../dist/utils'; -import { - normalizeZeroUuidSentinels as normalizeSourceZeroUuidSentinels, - ZERO_UUID as SOURCE_ZERO_UUID, -} from '../../src'; type Assert = T; type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type RemovedRootValue = Extract< + keyof typeof import('../../dist'), + | 'convertToDaml' + | 'convertToOcf' + | 'decodeDamlEntityData' + | 'ENTITY_REGISTRY' + | 'ENTITY_TAG_MAP' + | 'getIssuerAsOcf' + | 'getStakeholderAsOcf' +>; type IntendedCanonicalOcfObject = OcfEntityDataMap[OcfEntityType]; -type SchemaSupportedPlanSecurityObjectType = - | 'TX_PLAN_SECURITY_ACCEPTANCE' - | 'TX_PLAN_SECURITY_CANCELLATION' - | 'TX_PLAN_SECURITY_EXERCISE' - | 'TX_PLAN_SECURITY_ISSUANCE' - | 'TX_PLAN_SECURITY_RELEASE' - | 'TX_PLAN_SECURITY_RETRACTION' - | 'TX_PLAN_SECURITY_TRANSFER'; const publishedOcfObjectIsExact: Assert> = true; -const publishedOcfObjectExcludesPlanSecurityWrappers: Assert< - IsExactly, never> +const generatedValuesAreNotRootExports: Assert> = true; +const authorizeIssuerResponseUsesPublicLedgerType: Assert< + IsExactly > = true; - -void publishedOcfObjectIsExact; -void publishedOcfObjectExcludesPlanSecurityWrappers; - -const publishedZeroUuidLiteral: '00000000-0000-0000-0000-000000000000' = ZERO_UUID; -const sourceZeroUuidLiteral: typeof ZERO_UUID = SOURCE_ZERO_UUID; -const normalizedPublishedZeroUuid: string | undefined = normalizeZeroUuidSentinels(ZERO_UUID); -const normalizedSourceZeroUuid: string | undefined = normalizeSourceZeroUuidSentinels(SOURCE_ZERO_UUID); -const zeroUuidNormalizerSourceAndDistMatch: Assert< - IsExactly +const withdrawAuthorizationResponseUsesPublicLedgerType: Assert< + IsExactly > = true; -void publishedZeroUuidLiteral; -void sourceZeroUuidLiteral; -void normalizedPublishedZeroUuid; -void normalizedSourceZeroUuid; -void zeroUuidNormalizerSourceAndDistMatch; +void publishedOcfObjectIsExact; +void generatedValuesAreNotRootExports; +void authorizeIssuerResponseUsesPublicLedgerType; +void withdrawAuthorizationResponseUsesPublicLedgerType; +void authorizeIssuer; +void buildCreateIssuerCommand; +void CapTableBatch; +void OcpClient; +void OcpValidationError; +void withdrawAuthorization; + +declare const createIssuerParams: CreateIssuerParams; +buildCreateIssuerCommand(createIssuerParams); + +// @ts-expect-error generated DAML wire unions are intentionally not root exports +type RemovedGeneratedWireType = import('../../dist').OcfCreateData; +declare const removedGeneratedWireType: RemovedGeneratedWireType; +void removedGeneratedWireType; + +declare const executeResult: CapTableBatchExecuteResult; +const returnedContractIds: readonly OcfContractId[] = executeResult.editedCids; +const issuerContractId: OcfContractId = { tag: 'CidIssuer', value: 'issuer-cid' }; +const financingContractId: OcfContractId = { tag: 'CidFinancing', value: 'financing-cid' }; +void returnedContractIds; +void issuerContractId; +void financingContractId; function verifyPublishedBatchApi( batch: CapTableBatch, @@ -67,9 +84,7 @@ function verifyPublishedBatchApi( financing: OcfFinancing, issuer: OcfIssuer, stockAcceptance: OcfStockAcceptance, - warrantAcceptance: OcfWarrantAcceptance, - vestingStart: OcfVestingStart, - vestingEvent: OcfVestingEvent + vestingStart: OcfVestingStart ): void { batch.create('stakeholder', stakeholder); batch.create('stockClass', stockClass); @@ -96,21 +111,12 @@ function verifyPublishedBatchApi( // @ts-expect-error a union-valued kind cannot bypass edit payload correlation batch.edit(widenedKind, stakeholder); - // @ts-expect-error a union-valued kind cannot bypass converter payload correlation - convertToDaml(widenedKind, stakeholder); - - // @ts-expect-error published types preserve stock vs warrant identity even with identical fields + // @ts-expect-error published types preserve entity identity even with structurally similar fields batch.create('warrantAcceptance', stockAcceptance); // @ts-expect-error published types preserve vesting start vs vesting event identity batch.create('vestingEvent', vestingStart); - // @ts-expect-error converter declarations cannot reinterpret a warrant acceptance as stock - convertToDaml('stockAcceptance', warrantAcceptance); - - // @ts-expect-error converter declarations cannot reinterpret a vesting event as vesting start - convertToDaml('vestingStart', vestingEvent); - // @ts-expect-error published entity declarations require object_type const missingObjectType: OcfStockAcceptance = { id: 'acceptance-1', @@ -119,17 +125,11 @@ function verifyPublishedBatchApi( }; void missingObjectType; - const wrongObjectType: OcfStockAcceptance = { - // @ts-expect-error published literal rejects another entity discriminator - object_type: 'TX_WARRANT_ACCEPTANCE', - id: 'acceptance-2', - date: '2026-01-01', - security_id: 'security-2', - }; - void wrongObjectType; - const operations: CapTableBatchOperations = { - creates: [{ type: 'stakeholder', data: stakeholder }], + creates: [ + { type: 'stakeholder', data: stakeholder }, + { type: 'financing', data: financing }, + ], edits: [{ type: 'issuer', data: issuer }], deletes: [{ type: 'stockClass', id: stockClass.id }], }; @@ -143,12 +143,4 @@ function verifyPublishedBatchApi( void invalidIdentityOperation; } -function verifyPublishedUtilsApi(candidateEntityType: string): void { - if (isOcfEntityTypeFromUtils(candidateEntityType)) { - const narrowedEntityType: OcfEntityType = candidateEntityType; - void narrowedEntityType; - } -} - void verifyPublishedBatchApi; -void verifyPublishedUtilsApi; diff --git a/test/integration/utils/setupTestData.ts b/test/integration/utils/setupTestData.ts index 9ffe1873..aa1e4529 100644 --- a/test/integration/utils/setupTestData.ts +++ b/test/integration/utils/setupTestData.ts @@ -1007,7 +1007,7 @@ export function createTestConvertibleIssuanceData( } /** Helper to extract a contract ID from a createdCids array by type tag. */ -function extractCreatedCid(createdCids: Array>, tagPrefix: string): string { +function extractCreatedCid(createdCids: ReadonlyArray>, tagPrefix: string): string { for (const cid of createdCids) { const { tag, value: taggedValue } = cid; if (typeof tag === 'string' && tag.startsWith(tagPrefix) && typeof taggedValue === 'string') { diff --git a/test/publicApi/rootExports.test.ts b/test/publicApi/rootExports.test.ts new file mode 100644 index 00000000..4ae065d2 --- /dev/null +++ b/test/publicApi/rootExports.test.ts @@ -0,0 +1,71 @@ +import packageJson from '../../package.json'; +import * as sdk from '../../src'; + +describe('package root exports', () => { + it('exports only the SDK root and package metadata subpaths', () => { + expect(packageJson.exports).toEqual({ + '.': { + types: './dist/index.d.ts', + import: './dist/index.js', + require: './dist/index.js', + default: './dist/index.js', + }, + './package.json': './package.json', + }); + }); + + it('exposes only the curated high-level runtime surface', () => { + expect(Object.keys(sdk).sort()).toEqual([ + 'CUSTOM_PRESET', + 'CapTableBatch', + 'DEVNET_PRESET', + 'ENVIRONMENT_PRESETS', + 'LOCALNET_PRESET', + 'MAINNET_PRESET', + 'OCF_OBJECT_TYPE_TO_ENTITY_TYPE', + 'OcpClient', + 'OcpContextManager', + 'OcpContractError', + 'OcpError', + 'OcpErrorCodes', + 'OcpNetworkError', + 'OcpParseError', + 'OcpValidationError', + 'SCRATCHNET_PRESET', + 'TESTNET_PRESET', + 'applyCommandContext', + 'authorizeIssuer', + 'buildCreateIssuerCommand', + 'buildUpdateCapTableCommand', + 'createSharedSecretTokenGenerator', + 'detectEnvironment', + 'isContractId', + 'isOcfCreatableEntityType', + 'isOcfDeletableEntityType', + 'isOcfEditableEntityType', + 'isOcfEntityType', + 'isOcfId', + 'isOcfReadableObjectType', + 'isPartyId', + 'isSecurityId', + 'loadEnvironmentConfigFromEnv', + 'mapOcfObjectTypeToEntityType', + 'mergeCommandContext', + 'resolveEnvironmentConfig', + 'submitObservedTransactionTree', + 'toCantonConfig', + 'toCantonNetwork', + 'toContractId', + 'toOcfId', + 'toPartyId', + 'toResolvedCantonConfig', + 'toSecurityId', + 'unsafeToContractId', + 'unsafeToOcfId', + 'unsafeToPartyId', + 'unsafeToSecurityId', + 'validateConfig', + 'withdrawAuthorization', + ]); + }); +}); diff --git a/test/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index 002ceed7..21f195d4 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -9,7 +9,6 @@ import { type CapTableBatch, type CapTableBatchOperations, - convertToDaml, type OcfCreateOperation, type OcfEntityDataMap, type OcfEntityType, @@ -23,6 +22,7 @@ import { type OcfVestingStart, type OcfWarrantAcceptance, } from '../../src'; +import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; type Assert = T; type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; diff --git a/test/types/damlReadDispatch.types.ts b/test/types/damlReadDispatch.types.ts index 2b24a664..99391305 100644 --- a/test/types/damlReadDispatch.types.ts +++ b/test/types/damlReadDispatch.types.ts @@ -6,8 +6,8 @@ import { decodeDamlEntityData, ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor, - type OcfStakeholder, -} from '../../src'; +} from '../../src/functions/OpenCapTable/capTable'; +import type { OcfStakeholder } from '../../src/types/native'; declare const stakeholderDamlData: DamlDataTypeFor<'stakeholder'>; declare const stockClassDamlData: DamlDataTypeFor<'stockClass'>; diff --git a/test/types/generatedOperationConstruction.types.ts b/test/types/generatedOperationConstruction.types.ts index 5577583d..43ec4bc6 100644 --- a/test/types/generatedOperationConstruction.types.ts +++ b/test/types/generatedOperationConstruction.types.ts @@ -1,21 +1,20 @@ /** Compile-time contracts for cast-free generated DAML operation builders. */ import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import type { + OcfCreateData, + OcfCreateDataFor, + OcfDeleteData, + OcfDeleteDataFor, + OcfEditData, + OcfEditDataFor, +} from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { buildOcfCreateData, buildOcfDeleteData, buildOcfEditData, - type OcfCreateData, - type OcfCreateDataFor, - type OcfDeleteData, - type OcfDeleteDataFor, - type OcfEditData, - type OcfEditDataFor, - type OcfFinancing, - type OcfIssuer, - type OcfStakeholder, - type OcfStockClass, -} from '../../src'; +} from '../../src/functions/OpenCapTable/capTable/generatedBatchOperations'; +import type { OcfFinancing, OcfIssuer, OcfStakeholder, OcfStockClass } from '../../src/types/native'; function verifyGeneratedOperationBuilders( stakeholder: OcfStakeholder, diff --git a/test/types/normalization.types.ts b/test/types/normalization.types.ts index 13d06c01..58764b46 100644 --- a/test/types/normalization.types.ts +++ b/test/types/normalization.types.ts @@ -1,13 +1,13 @@ /** Compile-time contracts for canonicalization helpers exported from source. */ +import type { OcfPlanSecurityIssuance } from '../../src/types/native'; import { deepNormalizeNumericStrings, normalizeEntityType, normalizeObjectType, normalizeOcfData, - normalizeZeroUuidSentinels, - type OcfPlanSecurityIssuance, -} from '../../src'; +} from '../../src/utils/planSecurityAliases'; +import { normalizeZeroUuidSentinels } from '../../src/utils/zeroUuidNormalization'; const normalizedNumericString: string = deepNormalizeNumericStrings('1.00' as const); void normalizedNumericString;