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() + }) +})