From 66a3755c81c1a97e526bac7b44467ed06e841504 Mon Sep 17 00:00:00 2001 From: benjamineckstein <13351939+benjamineckstein@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:48:11 +0200 Subject: [PATCH] feat: export typed message-type and variant constants (#39) Adds MessageType, CreditTransferVariant, and DirectDebitVariant as documented const objects (as const, not enum) so consumers get autocomplete and TSDoc on each member without breaking existing raw string usage. Single source of truth lives in src/message-types.ts. The writer files and parser types now derive from it: CreditTransferVariant and DirectDebitVariant types in writer.ts / direct-debit.ts are replaced by re-exports; ParseSuccess001.type / ParseSuccess008.type use typeof MessageType.CreditTransfer / typeof MessageType.DirectDebit, which resolve to the same 'pain.001' / 'pain.008' literals. All three objects are exported from src/index.ts via a single export statement (const export exposes both value and type alias via declaration merging). No breaking change: string literals remain accepted everywhere. Variant names: SCT_V09 (pain.001.001.09, ISO version .09), SCT_Legacy (pain.001.001.03), SCT_DK (pain.001.003.03), SDD_V08 (pain.008.001.08, ISO version .08), SDD_DK (pain.008.003.02). Adds 16 new tests in test/message-types.test.ts covering value equality, discriminator narrowing, constant vs raw-string write output, and type assignability. Total: 687 tests passing. README updated to show MessageType in the parse snippet and CreditTransferVariant / DirectDebitVariant in all three variant sections. --- README.md | 51 +++++--- src/index.ts | 13 ++- src/message-types.ts | 113 ++++++++++++++++++ src/parser/parser.ts | 7 +- src/writer/direct-debit.ts | 8 +- src/writer/writer.ts | 9 +- test/message-types.test.ts | 233 +++++++++++++++++++++++++++++++++++++ 7 files changed, 400 insertions(+), 34 deletions(-) create mode 100644 src/message-types.ts create mode 100644 test/message-types.test.ts diff --git a/README.md b/README.md index 3ad514a..981930c 100644 --- a/README.md +++ b/README.md @@ -211,23 +211,27 @@ if (!result.ok) { input. ```ts -import { parse } from "sepa-xml-ts"; +import { parse, MessageType } from "sepa-xml-ts"; const parsed = parse(xml); if (!parsed.ok) { console.error(parsed.error); -} else if (parsed.type === "pain.001") { +} else if (parsed.type === MessageType.CreditTransfer) { // parsed.data is a CreditTransferDocument const total = parsed.data.batches .flatMap((b) => b.transfers) .reduce((sum, t) => sum + t.amount.minorUnits, 0n); console.log("credit transfer total:", total); } else { - // parsed.type === "pain.008" -> parsed.data is a DirectDebitDocument + // parsed.type === MessageType.DirectDebit -> parsed.data is a DirectDebitDocument console.log("collections:", parsed.data.batches.flatMap((b) => b.collections).length); } ``` +`MessageType.CreditTransfer` is `"pain.001"` and `MessageType.DirectDebit` is `"pain.008"`. Raw +string comparisons still type-check: `parsed.type === "pain.001"` is identical in behaviour and +the types are fully compatible. + The round-trip is anchored on the model: for any valid model, `parse(write(model))` deep-equals the original. This is verified as a property test over thousands of generated inputs, for both message types. @@ -568,13 +572,14 @@ variant is a different XML schema with its own element ordering and element name The legacy ISO credit transfer schema `pain.001.001.03` is supported as a write target for systems that have not yet migrated to the modern `pain.001.001.09`. Pass `variant: -'pain.001.001.03'` to emit the older namespace. The model input is the same -`CreditTransferDocument`; only the serialization differs. +'pain.001.001.03'` (or the `CreditTransferVariant.SCT_Legacy` constant) to emit the older +namespace. The model input is the same `CreditTransferDocument`; only the serialization differs. ```ts -import { euros, writeCreditTransfer, type CreditTransferDocument } from "sepa-xml-ts"; +import { euros, writeCreditTransfer, CreditTransferVariant, type CreditTransferDocument } from "sepa-xml-ts"; -const xml = writeCreditTransfer(doc, { variant: "pain.001.001.03" }); +const xml = writeCreditTransfer(doc, { variant: CreditTransferVariant.SCT_Legacy }); +// equivalent to: writeCreditTransfer(doc, { variant: "pain.001.001.03" }) // ... ``` @@ -589,15 +594,21 @@ Structural deltas from `pain.001.001.09`: ### German DK variant: pain.001.003.03 The German DK (DFU agreement Anlage 3) uses the namespace -`urn:iso:std:iso:20022:tech:xsd:pain.001.003.03`. Pass `variant: 'pain.001.003.03'` to emit and -validate against this schema. The model input is the same `CreditTransferDocument` for both -variants; only the serialization differs. +`urn:iso:std:iso:20022:tech:xsd:pain.001.003.03`. Pass `variant: 'pain.001.003.03'` (or the +`CreditTransferVariant.SCT_DK` constant) to emit and validate against this schema. The model +input is the same `CreditTransferDocument` for both variants; only the serialization differs. ```ts -import { euros, writeCreditTransfer, type CreditTransferDocument } from "sepa-xml-ts"; +import { + euros, + writeCreditTransfer, + CreditTransferVariant, + type CreditTransferDocument, +} from "sepa-xml-ts"; import { validateXsd } from "sepa-xml-ts/xsd"; -const xml = writeCreditTransfer(doc, { variant: "pain.001.003.03" }); +const xml = writeCreditTransfer(doc, { variant: CreditTransferVariant.SCT_DK }); +// equivalent to: writeCreditTransfer(doc, { variant: "pain.001.003.03" }) // ... const xsdResult = await validateXsd(xml); // validates against the DK XSD @@ -627,15 +638,21 @@ const xml = writeCreditTransfer(doc, { ### German DK variant: pain.008.003.02 The German DK direct debit variant uses the namespace -`urn:iso:std:iso:20022:tech:xsd:pain.008.003.02`. Pass `variant: 'pain.008.003.02'` to emit and -validate against this schema. The model input is the same `DirectDebitDocument` for both -variants; only the serialization differs. +`urn:iso:std:iso:20022:tech:xsd:pain.008.003.02`. Pass `variant: 'pain.008.003.02'` (or the +`DirectDebitVariant.SDD_DK` constant) to emit and validate against this schema. The model input +is the same `DirectDebitDocument` for both variants; only the serialization differs. ```ts -import { euros, writeDirectDebit, type DirectDebitDocument } from "sepa-xml-ts"; +import { + euros, + writeDirectDebit, + DirectDebitVariant, + type DirectDebitDocument, +} from "sepa-xml-ts"; import { validateXsd } from "sepa-xml-ts/xsd"; -const xml = writeDirectDebit(doc, { variant: "pain.008.003.02" }); +const xml = writeDirectDebit(doc, { variant: DirectDebitVariant.SDD_DK }); +// equivalent to: writeDirectDebit(doc, { variant: "pain.008.003.02" }) // ... const xsdResult = await validateXsd(xml); // validates against the DK SDD XSD diff --git a/src/index.ts b/src/index.ts index 5a5aa2a..b5890c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,7 +73,7 @@ export type { } from './model/schema.js' export { writeCreditTransfer } from './writer/writer.js' -export type { WriteCreditTransferOptions, CreditTransferVariant } from './writer/writer.js' +export type { WriteCreditTransferOptions } from './writer/writer.js' // --------------------------------------------------------------------------- // pain.008 exports @@ -97,7 +97,7 @@ export type { } from './model/pain008.js' export { writeDirectDebit } from './writer/direct-debit.js' -export type { WriteDirectDebitOptions, DirectDebitVariant } from './writer/direct-debit.js' +export type { WriteDirectDebitOptions } from './writer/direct-debit.js' export { checkDirectDebitRules } from './model/dd-rules.js' @@ -130,3 +130,12 @@ export type { export type { BankProfile, ProfileIssue } from './profile/profile.js' export { requireBic, ibanBicCountryMatch } from './profile/profiles.js' + +// --------------------------------------------------------------------------- +// Typed message-type and variant constants +// Single source of truth for autocomplete-friendly constants and their derived types. +// Raw string literals remain valid everywhere (no breaking change). +// --------------------------------------------------------------------------- + +// Exporting the const values also exposes the type aliases (same name, declaration merging). +export { MessageType, CreditTransferVariant, DirectDebitVariant } from './message-types.js' diff --git a/src/message-types.ts b/src/message-types.ts new file mode 100644 index 0000000..dad0114 --- /dev/null +++ b/src/message-types.ts @@ -0,0 +1,113 @@ +/** + * Typed, documented constants for SEPA message types and write variants. + * + * Use these constants instead of bare strings for autocomplete and inline documentation. + * Raw string literals remain valid everywhere (no breaking change): the derived types + * keep the same resolved string-literal union. + * + * Example - parse narrowing: + * import { MessageType } from 'sepa-xml-ts' + * if (result.type === MessageType.CreditTransfer) { ... } // same as === 'pain.001' + * + * Example - write variant: + * import { CreditTransferVariant } from 'sepa-xml-ts' + * writeCreditTransfer(doc, { variant: CreditTransferVariant.SCT_DK }) + */ + +// --------------------------------------------------------------------------- +// MessageType +// --------------------------------------------------------------------------- + +/** + * Discriminator constants returned by `parse()`. + * + * These match the `type` field on a successful `ParseResult` so you can + * narrow the discriminated union with a readable name instead of a raw string. + */ +export const MessageType = { + /** + * SEPA Credit Transfer (pain.001). You push payments to many creditors. + * Returned as `result.type` when `parse()` reads a pain.001 document. + */ + CreditTransfer: 'pain.001', + /** + * SEPA Direct Debit (pain.008). You pull payments from many debtors. + * Returned as `result.type` when `parse()` reads a pain.008 document. + */ + DirectDebit: 'pain.008', +} as const + +/** Literal union of all recognised message-type discriminators. */ +export type MessageType = (typeof MessageType)[keyof typeof MessageType] + +// --------------------------------------------------------------------------- +// CreditTransferVariant +// --------------------------------------------------------------------------- + +/** + * Output schema variant constants for `writeCreditTransfer`. + * + * Pass one of these (or the equivalent raw string) as `{ variant }` in + * `WriteCreditTransferOptions`. Defaults to `SCT_V09` when omitted. + */ +export const CreditTransferVariant = { + /** + * Modern ISO SEPA Credit Transfer: pain.001.001.09. + * V09 refers to ISO message version .09 (the current schema, published 2019). + * The default. Use this unless your bank specifically requires an older format. + */ + SCT_V09: 'pain.001.001.09', + /** + * Legacy ISO Credit Transfer: pain.001.001.03. + * Some banks still require this older format on the wire. It uses a plain + * ReqdExctnDt (no Dt wrapper), BIC element instead of BICFI, and an empty + * FinInstnId fallback for DbtrAgt when no BIC is set. + */ + SCT_Legacy: 'pain.001.001.03', + /** + * German DK national Credit Transfer: pain.001.003.03. + * Defined by the Deutsche Kreditwirtschaft (DK). Uses a different namespace, + * plain ReqdExctnDt, BIC element, and NOTPROVIDED fallback for DbtrAgt. + * Restricted to Ctry + AdrLine for postal addresses. + */ + SCT_DK: 'pain.001.003.03', +} as const + +/** + * Literal union of all supported credit-transfer write variants. + * Identical to the string union accepted by `WriteCreditTransferOptions.variant`. + */ +export type CreditTransferVariant = + (typeof CreditTransferVariant)[keyof typeof CreditTransferVariant] + +// --------------------------------------------------------------------------- +// DirectDebitVariant +// --------------------------------------------------------------------------- + +/** + * Output schema variant constants for `writeDirectDebit`. + * + * Pass one of these (or the equivalent raw string) as `{ variant }` in + * `WriteDirectDebitOptions`. Defaults to `SDD_V08` when omitted. + */ +export const DirectDebitVariant = { + /** + * Modern ISO SEPA Direct Debit: pain.008.001.08. + * V08 refers to ISO message version .08 (the current schema, published 2019). + * The default. Use this unless your bank specifically requires the DK variant. + */ + SDD_V08: 'pain.008.001.08', + /** + * German DK national Direct Debit: pain.008.003.02. + * Defined by the Deutsche Kreditwirtschaft (DK). Uses a different namespace, + * BIC element instead of BICFI, NOTPROVIDED fallback, and omits CtrlSum from + * GrpHdr. Restricted to Ctry + AdrLine for postal addresses. + */ + SDD_DK: 'pain.008.003.02', +} as const + +/** + * Literal union of all supported direct-debit write variants. + * Identical to the string union accepted by `WriteDirectDebitOptions.variant`. + */ +export type DirectDebitVariant = (typeof DirectDebitVariant)[keyof typeof DirectDebitVariant] diff --git a/src/parser/parser.ts b/src/parser/parser.ts index a0fa657..53910dd 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -18,6 +18,7 @@ import { XMLParser } from 'fast-xml-parser' import { detectSepaNamespace } from '../xmlns-detect.js' +import type { MessageType } from '../message-types.js' import { CreditTransferDocumentSchema, type CreditTransferDocument, @@ -56,14 +57,16 @@ import { export type ParseSuccess001 = { ok: true - type: 'pain.001' + /** Discriminator matching `MessageType.CreditTransfer` ("pain.001"). */ + type: typeof MessageType.CreditTransfer /** Detected schema version, e.g. "pain.001.001.09" or "pain.001.001.03". */ version?: string data: CreditTransferDocument } export type ParseSuccess008 = { ok: true - type: 'pain.008' + /** Discriminator matching `MessageType.DirectDebit` ("pain.008"). */ + type: typeof MessageType.DirectDebit /** Detected schema version, e.g. "pain.008.001.08" or "pain.008.001.02". */ version?: string data: DirectDebitDocument diff --git a/src/writer/direct-debit.ts b/src/writer/direct-debit.ts index 5f69b68..9746bec 100644 --- a/src/writer/direct-debit.ts +++ b/src/writer/direct-debit.ts @@ -50,13 +50,9 @@ import { } from './xml-emit.js' import type { BankProfile } from '../profile/profile.js' import { checkDirectDebitRules } from '../model/dd-rules.js' +import type { DirectDebitVariant } from '../message-types.js' -/** - * The output schema variant for writeDirectDebit. - * - 'pain.008.001.08': the modern SEPA SDD schema (default, unchanged behavior) - * - 'pain.008.003.02': the German DK national variant (different namespace and structure) - */ -export type DirectDebitVariant = 'pain.008.001.08' | 'pain.008.003.02' +export type { DirectDebitVariant } /** Options accepted by writeDirectDebit. */ export interface WriteDirectDebitOptions { diff --git a/src/writer/writer.ts b/src/writer/writer.ts index 573fe37..84125e7 100644 --- a/src/writer/writer.ts +++ b/src/writer/writer.ts @@ -45,14 +45,9 @@ import { emitCdtTrfTxInfHeader, } from './xml-emit.js' import type { BankProfile } from '../profile/profile.js' +import type { CreditTransferVariant } from '../message-types.js' -/** - * The output schema variant for writeCreditTransfer. - * - 'pain.001.001.09': the modern SEPA SCT schema (default, unchanged behavior) - * - 'pain.001.001.03': the legacy ISO credit transfer format (for systems still requiring .03 on the wire) - * - 'pain.001.003.03': the German DK national variant (different namespace and structure) - */ -export type CreditTransferVariant = 'pain.001.001.09' | 'pain.001.001.03' | 'pain.001.003.03' +export type { CreditTransferVariant } /** Options accepted by writeCreditTransfer. */ export interface WriteCreditTransferOptions { diff --git a/test/message-types.test.ts b/test/message-types.test.ts new file mode 100644 index 0000000..eb0ae1f --- /dev/null +++ b/test/message-types.test.ts @@ -0,0 +1,233 @@ +/** + * Tests for the typed message-type and variant constants. + * + * Covers: + * - MessageType.CreditTransfer === "pain.001" (value equality) + * - MessageType.DirectDebit === "pain.008" (value equality) + * - parse() discriminator narrows correctly when compared with MessageType constants + * - writeCreditTransfer accepts both the constant and the raw string for variant + * - writeDirectDebit accepts both the constant and the raw string for variant + * - CreditTransferVariant and DirectDebitVariant const values match their string equivalents + * - Constants work correctly in the round-trip (value === discriminator literal) + */ + +import { describe, it, expect, expectTypeOf } from 'vitest' +import { writeCreditTransfer } from '../src/writer/writer.js' +import { writeDirectDebit } from '../src/writer/direct-debit.js' +import { parse } from '../src/parser/parser.js' +import { euros } from '../src/model/schema.js' +import { MessageType, CreditTransferVariant, DirectDebitVariant } from '../src/message-types.js' +import type { CreditTransferDocument } from '../src/model/schema.js' +import type { DirectDebitDocument } from '../src/model/pain008.js' +import type { ParseSuccess001, ParseSuccess008 } from '../src/parser/parser.js' + +// --------------------------------------------------------------------------- +// Minimal fixtures +// --------------------------------------------------------------------------- + +const minimalCt: CreditTransferDocument = { + messageId: 'MSG-CT-CONST-001', + createdAt: '2026-01-01T10:00:00Z', + initiatingParty: 'Test Corp', + batches: [ + { + id: 'BATCH-001', + executionDate: '2026-01-15', + debtor: { name: 'Test Corp', iban: 'DE89370400440532013000', bic: 'COBADEFFXXX' }, + transfers: [ + { + endToEndId: 'E2E-001', + amount: euros('12.34'), + creditor: { name: 'Vendor', iban: 'NL91ABNA0417164300' }, + }, + ], + }, + ], +} + +const minimalDd: DirectDebitDocument = { + messageId: 'MSG-DD-CONST-001', + createdAt: '2026-01-01T10:00:00Z', + initiatingParty: 'Test Corp', + creditor: { + name: 'Test Corp', + iban: 'DE89370400440532013000', + bic: 'COBADEFFXXX', + creditorId: 'DE98ZZZ09999999999', + }, + batches: [ + { + id: 'BATCH-DD-001', + collectionDate: '2026-01-20', + sequenceType: 'FRST', + collections: [ + { + endToEndId: 'E2E-DD-001', + amount: euros('10.00'), + debtor: { name: 'Debtor One', iban: 'DE65200400300234567000' }, + mandate: { id: 'MND-001', signatureDate: '2025-12-01' }, + }, + ], + }, + ], +} + +// --------------------------------------------------------------------------- +// MessageType constant values +// --------------------------------------------------------------------------- + +describe('MessageType constants', () => { + it('MessageType.CreditTransfer equals the raw string "pain.001"', () => { + expect(MessageType.CreditTransfer).toBe('pain.001') + }) + + it('MessageType.DirectDebit equals the raw string "pain.008"', () => { + expect(MessageType.DirectDebit).toBe('pain.008') + }) + + it('both values are distinct', () => { + expect(MessageType.CreditTransfer).not.toBe(MessageType.DirectDebit) + }) +}) + +// --------------------------------------------------------------------------- +// CreditTransferVariant constant values +// --------------------------------------------------------------------------- + +describe('CreditTransferVariant constants', () => { + it('SCT_V09 equals "pain.001.001.09"', () => { + expect(CreditTransferVariant.SCT_V09).toBe('pain.001.001.09') + }) + + it('SCT_Legacy equals "pain.001.001.03"', () => { + expect(CreditTransferVariant.SCT_Legacy).toBe('pain.001.001.03') + }) + + it('SCT_DK equals "pain.001.003.03"', () => { + expect(CreditTransferVariant.SCT_DK).toBe('pain.001.003.03') + }) +}) + +// --------------------------------------------------------------------------- +// DirectDebitVariant constant values +// --------------------------------------------------------------------------- + +describe('DirectDebitVariant constants', () => { + it('SDD_V08 equals "pain.008.001.08"', () => { + expect(DirectDebitVariant.SDD_V08).toBe('pain.008.001.08') + }) + + it('SDD_DK equals "pain.008.003.02"', () => { + expect(DirectDebitVariant.SDD_DK).toBe('pain.008.003.02') + }) +}) + +// --------------------------------------------------------------------------- +// parse() discriminator and MessageType narrowing +// --------------------------------------------------------------------------- + +describe('parse discriminator and MessageType narrowing', () => { + it('parsed pain.001 result.type equals MessageType.CreditTransfer', () => { + const xml = writeCreditTransfer(minimalCt) + const result = parse(xml) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.type).toBe(MessageType.CreditTransfer) + // Also verify the raw string still works for comparison + expect(result.type).toBe('pain.001') + }) + + it('parsed pain.008 result.type equals MessageType.DirectDebit', () => { + const xml = writeDirectDebit(minimalDd) + const result = parse(xml) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.type).toBe(MessageType.DirectDebit) + // Also verify the raw string still works for comparison + expect(result.type).toBe('pain.008') + }) + + it('narrowing with MessageType constant gives access to CreditTransferDocument', () => { + const xml = writeCreditTransfer(minimalCt) + const result = parse(xml) + expect(result.ok).toBe(true) + if (!result.ok) return + if (result.type === MessageType.CreditTransfer) { + // TypeScript narrows to ParseSuccess001, so .data is CreditTransferDocument + expect(result.data.messageId).toBe('MSG-CT-CONST-001') + } else { + throw new Error('Expected credit transfer result') + } + }) + + it('narrowing with MessageType constant gives access to DirectDebitDocument', () => { + const xml = writeDirectDebit(minimalDd) + const result = parse(xml) + expect(result.ok).toBe(true) + if (!result.ok) return + if (result.type === MessageType.DirectDebit) { + // TypeScript narrows to ParseSuccess008, so .data is DirectDebitDocument + expect(result.data.messageId).toBe('MSG-DD-CONST-001') + } else { + throw new Error('Expected direct debit result') + } + }) +}) + +// --------------------------------------------------------------------------- +// writeCreditTransfer: constant vs raw string produce the same output +// --------------------------------------------------------------------------- + +describe('writeCreditTransfer: constant and raw string produce identical output', () => { + it('constant CreditTransferVariant.SCT_V09 produces same XML as raw string', () => { + const byConst = writeCreditTransfer(minimalCt, { variant: CreditTransferVariant.SCT_V09 }) + const byString = writeCreditTransfer(minimalCt, { variant: 'pain.001.001.09' }) + expect(byConst).toBe(byString) + }) + + it('constant CreditTransferVariant.SCT_Legacy produces same XML as raw string', () => { + const byConst = writeCreditTransfer(minimalCt, { variant: CreditTransferVariant.SCT_Legacy }) + const byString = writeCreditTransfer(minimalCt, { variant: 'pain.001.001.03' }) + expect(byConst).toBe(byString) + }) + + it('constant CreditTransferVariant.SCT_DK produces same XML as raw string', () => { + const byConst = writeCreditTransfer(minimalCt, { variant: CreditTransferVariant.SCT_DK }) + const byString = writeCreditTransfer(minimalCt, { variant: 'pain.001.003.03' }) + expect(byConst).toBe(byString) + }) +}) + +// --------------------------------------------------------------------------- +// writeDirectDebit: constant vs raw string produce the same output +// --------------------------------------------------------------------------- + +describe('writeDirectDebit: constant and raw string produce identical output', () => { + it('constant DirectDebitVariant.SDD_V08 produces same XML as raw string', () => { + const byConst = writeDirectDebit(minimalDd, { variant: DirectDebitVariant.SDD_V08 }) + const byString = writeDirectDebit(minimalDd, { variant: 'pain.008.001.08' }) + expect(byConst).toBe(byString) + }) + + it('constant DirectDebitVariant.SDD_DK produces same XML as raw string', () => { + const byConst = writeDirectDebit(minimalDd, { variant: DirectDebitVariant.SDD_DK }) + const byString = writeDirectDebit(minimalDd, { variant: 'pain.008.003.02' }) + expect(byConst).toBe(byString) + }) +}) + +// --------------------------------------------------------------------------- +// Type-level: ParseSuccess types are assignable to MessageType literal +// --------------------------------------------------------------------------- + +describe('ParseSuccess type assignability', () => { + it('ParseSuccess001.type is assignable to MessageType', () => { + // This is a compile-time check via expectTypeOf. + // If MessageType changed from 'pain.001', this assertion would fail at type-check. + expectTypeOf().toEqualTypeOf() + }) + + it('ParseSuccess008.type is assignable to MessageType', () => { + expectTypeOf().toEqualTypeOf() + }) +})