diff --git a/src/model/pain008.ts b/src/model/pain008.ts index dc229b0..df1a361 100644 --- a/src/model/pain008.ts +++ b/src/model/pain008.ts @@ -9,7 +9,6 @@ */ import { z } from 'zod' -import { isValidIban } from './iban.js' import { isSepaCharset } from './charset.js' import { MoneySchema, @@ -20,9 +19,10 @@ import { CategoryPurposeSchema, } from './schema.js' import { isValidCreditorId } from './creditor-id.js' +import { IBANSchema, BICSchema } from './shared.js' // --------------------------------------------------------------------------- -// Internal validators (shared with pain001 but redefined for independence) +// Internal validators (local to pain008; sepaText message differs from schema.ts) // --------------------------------------------------------------------------- const ISO_DATETIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/ @@ -68,16 +68,7 @@ const ISODateSchema = z .string() .regex(ISO_DATE_PATTERN, 'Must be a valid ISO 8601 date in YYYY-MM-DD format') -const IBANSchema = z - .string() - .regex(/^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/, 'Invalid IBAN format') - .refine((v) => isValidIban(v), { - message: 'IBAN failed mod-97 checksum validation', - }) - -const BICSchema = z - .string() - .regex(/^[A-Z0-9]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$/, 'Invalid BIC/SWIFT format') +// IBANSchema and BICSchema are imported from ./shared.js (identical to schema.ts). // --------------------------------------------------------------------------- // SequenceType: SEPA direct debit sequence types diff --git a/src/model/schema.ts b/src/model/schema.ts index 8c99821..623b2d2 100644 --- a/src/model/schema.ts +++ b/src/model/schema.ts @@ -8,8 +8,9 @@ */ import { z } from 'zod' -import { isValidIban, isValidIso11649Ref } from './iban.js' +import { isValidIso11649Ref } from './iban.js' import { isSepaCharset } from './charset.js' +import { IBANSchema, BICSchema } from './shared.js' // --------------------------------------------------------------------------- // Internal validators (not exported from public API) @@ -117,18 +118,8 @@ const ISODateSchema = z .string() .regex(ISO_DATE_PATTERN, 'Must be a valid ISO 8601 date in YYYY-MM-DD format (no time component)') -/** IBAN validated by mod-97 checksum. */ -const IBANSchema = z - .string() - .regex(/^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/, 'Invalid IBAN format') - .refine((v) => isValidIban(v), { - message: 'IBAN failed mod-97 checksum validation', - }) - -/** BIC/SWIFT identifier (optional). */ -const BICSchema = z - .string() - .regex(/^[A-Z0-9]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$/, 'Invalid BIC/SWIFT format') +// IBANSchema and BICSchema are imported from ./shared.js (identical definitions in both +// schema.ts and pain008.ts, extracted to avoid duplication). // --------------------------------------------------------------------------- // Money diff --git a/src/model/shared.ts b/src/model/shared.ts new file mode 100644 index 0000000..4c4dffb --- /dev/null +++ b/src/model/shared.ts @@ -0,0 +1,32 @@ +/** + * Shared internal Zod schemas used by both schema.ts (pain.001) and pain008.ts (pain.008). + * + * These are internal to the model layer and NOT exported from the public API. + * Only include schemas that are byte-for-byte identical across both modules. + */ + +import { z } from 'zod' +import { isValidIban } from './iban.js' + +// --------------------------------------------------------------------------- +// Shared: IBAN and BIC schemas +// --------------------------------------------------------------------------- + +/** + * IBAN validated by mod-97 checksum. + * Identical in schema.ts and pain008.ts; extracted here to remove the duplication. + */ +export const IBANSchema = z + .string() + .regex(/^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/, 'Invalid IBAN format') + .refine((v) => isValidIban(v), { + message: 'IBAN failed mod-97 checksum validation', + }) + +/** + * BIC/SWIFT identifier (optional). + * Identical in schema.ts and pain008.ts; extracted here to remove the duplication. + */ +export const BICSchema = z + .string() + .regex(/^[A-Z0-9]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$/, 'Invalid BIC/SWIFT format') diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 5d6e24d..a0fa657 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -1023,6 +1023,53 @@ export function parse(xml: string): ParseResult { } } +// --------------------------------------------------------------------------- +// Shared GrpHdr extraction: common to both parsePain001 and parsePain008 +// --------------------------------------------------------------------------- + +type GrpHdrExtracted = { + ok: true + messageId: string + createdAt: string + initiatingParty: string + pmtInfArray: unknown[] +} + +/** + * Extract the common GrpHdr fields (MsgId, CreDtTm, InitgPty/Nm) and the PmtInf + * array from a document root element. Used by both parsePain001 and parsePain008. + * + * Returns either the extracted values (ok: true) or a ParseFailure (ok: false). + */ +function extractGrpHdr(root: unknown): GrpHdrExtracted | ParseFailure { + const grpHdr = nav(root, 'GrpHdr') + if (!grpHdr) { + return { ok: false, error: 'Missing GrpHdr element' } + } + + const messageId = str(nav(grpHdr, 'MsgId')) + if (!messageId) { + return { ok: false, error: 'Missing GrpHdr/MsgId' } + } + + const createdAt = str(nav(grpHdr, 'CreDtTm')) + if (!createdAt) { + return { ok: false, error: 'Missing GrpHdr/CreDtTm' } + } + + const initiatingParty = str(nav(grpHdr, 'InitgPty', 'Nm')) + if (!initiatingParty) { + return { ok: false, error: 'Missing GrpHdr/InitgPty/Nm' } + } + + const pmtInfArray = nav(root, 'PmtInf') + if (!Array.isArray(pmtInfArray) || pmtInfArray.length === 0) { + return { ok: false, error: 'Missing or empty PmtInf elements' } + } + + return { ok: true, messageId, createdAt, initiatingParty, pmtInfArray } +} + // --------------------------------------------------------------------------- // pain.001 sub-parser (handles pain.001.001.09 and pain.001.001.03) // --------------------------------------------------------------------------- @@ -1034,30 +1081,9 @@ function parsePain001(parsed: unknown, version: string): ParseResult { return { ok: false, error: 'Missing Document/CstmrCdtTrfInitn element' } } - const grpHdr = nav(root, 'GrpHdr') - if (!grpHdr) { - return { ok: false, error: 'Missing GrpHdr element' } - } - - const messageId = str(nav(grpHdr, 'MsgId')) - if (!messageId) { - return { ok: false, error: 'Missing GrpHdr/MsgId' } - } - - const createdAt = str(nav(grpHdr, 'CreDtTm')) - if (!createdAt) { - return { ok: false, error: 'Missing GrpHdr/CreDtTm' } - } - - const initiatingParty = str(nav(grpHdr, 'InitgPty', 'Nm')) - if (!initiatingParty) { - return { ok: false, error: 'Missing GrpHdr/InitgPty/Nm' } - } - - const pmtInfArray = nav(root, 'PmtInf') - if (!Array.isArray(pmtInfArray) || pmtInfArray.length === 0) { - return { ok: false, error: 'Missing or empty PmtInf elements' } - } + const hdr = extractGrpHdr(root) + if (!hdr.ok) return hdr + const { messageId, createdAt, initiatingParty, pmtInfArray } = hdr const batches: PaymentBatch[] = [] for (const pmtInfEl of pmtInfArray) { @@ -1103,30 +1129,9 @@ function parsePain008(parsed: unknown, version: string): ParseResult { return { ok: false, error: 'Missing Document/CstmrDrctDbtInitn element' } } - const grpHdr = nav(root, 'GrpHdr') - if (!grpHdr) { - return { ok: false, error: 'Missing GrpHdr element' } - } - - const messageId = str(nav(grpHdr, 'MsgId')) - if (!messageId) { - return { ok: false, error: 'Missing GrpHdr/MsgId' } - } - - const createdAt = str(nav(grpHdr, 'CreDtTm')) - if (!createdAt) { - return { ok: false, error: 'Missing GrpHdr/CreDtTm' } - } - - const initiatingParty = str(nav(grpHdr, 'InitgPty', 'Nm')) - if (!initiatingParty) { - return { ok: false, error: 'Missing GrpHdr/InitgPty/Nm' } - } - - const pmtInfArray = nav(root, 'PmtInf') - if (!Array.isArray(pmtInfArray) || pmtInfArray.length === 0) { - return { ok: false, error: 'Missing or empty PmtInf elements' } - } + const hdr = extractGrpHdr(root) + if (!hdr.ok) return hdr + const { messageId, createdAt, initiatingParty, pmtInfArray } = hdr // Extract creditor from the first PmtInf (same value is fanned out to all) const creditor = extractCreditorFromPmtInf(pmtInfArray[0]) diff --git a/src/profile/profiles.ts b/src/profile/profiles.ts index 1e5d8d5..431375f 100644 --- a/src/profile/profiles.ts +++ b/src/profile/profiles.ts @@ -84,6 +84,48 @@ function ibanBicCountriesMatch(ibanCc: string, bicCc: string): boolean { return allowed !== undefined && allowed.has(bicCc) } +/** + * Push an issue if a BIC is absent on a required agent field. + * Encapsulates the repeated "if bic undefined, push issue" pattern in requireBic. + */ +function checkBicPresent( + issues: ProfileIssue[], + path: string, + bic: string | undefined, + role: 'debtor' | 'creditor' +): void { + if (bic === undefined) { + issues.push({ + path, + message: `BIC is required by the selected bank profile but is missing on the ${role}`, + }) + } +} + +/** + * Push an issue if the IBAN and BIC country codes are inconsistent (when both are present). + * Encapsulates the repeated IBAN-BIC country match check in ibanBicCountryMatch. + */ +function checkIbanBicCountryConsistency( + issues: ProfileIssue[], + path: string, + iban: string, + bic: string | undefined, + role: 'debtor' | 'creditor' +): void { + if (bic === undefined) return + const ibanCc = iban.slice(0, 2).toUpperCase() + const bicCc = bic.slice(4, 6).toUpperCase() + if (!ibanBicCountriesMatch(ibanCc, bicCc)) { + issues.push({ + path, + message: + `IBAN country (${ibanCc}) does not match BIC country (${bicCc}) on the ${role}. ` + + 'Check for a data-entry error or use a different bank profile if this is intentional.', + }) + } +} + // --------------------------------------------------------------------------- // requireBic: mandate a BIC on every agent // --------------------------------------------------------------------------- @@ -115,21 +157,16 @@ export const requireBic: BankProfile = { for (let bi = 0; bi < doc.batches.length; bi++) { const batch = doc.batches[bi] if (batch === undefined) continue - if (batch.debtor.bic === undefined) { - issues.push({ - path: `batches.${bi}.debtor.bic`, - message: 'BIC is required by the selected bank profile but is missing on the debtor', - }) - } + checkBicPresent(issues, `batches.${bi}.debtor.bic`, batch.debtor.bic, 'debtor') for (let ti = 0; ti < batch.transfers.length; ti++) { const transfer = batch.transfers[ti] if (transfer === undefined) continue - if (transfer.creditor.bic === undefined) { - issues.push({ - path: `batches.${bi}.transfers.${ti}.creditor.bic`, - message: 'BIC is required by the selected bank profile but is missing on the creditor', - }) - } + checkBicPresent( + issues, + `batches.${bi}.transfers.${ti}.creditor.bic`, + transfer.creditor.bic, + 'creditor' + ) } } return issues @@ -137,24 +174,19 @@ export const requireBic: BankProfile = { checkDirectDebit(doc: DirectDebitDocument): ProfileIssue[] { const issues: ProfileIssue[] = [] - if (doc.creditor.bic === undefined) { - issues.push({ - path: 'creditor.bic', - message: 'BIC is required by the selected bank profile but is missing on the creditor', - }) - } + checkBicPresent(issues, 'creditor.bic', doc.creditor.bic, 'creditor') for (let bi = 0; bi < doc.batches.length; bi++) { const batch = doc.batches[bi] if (batch === undefined) continue for (let ci = 0; ci < batch.collections.length; ci++) { const collection = batch.collections[ci] if (collection === undefined) continue - if (collection.debtor.bic === undefined) { - issues.push({ - path: `batches.${bi}.collections.${ci}.debtor.bic`, - message: 'BIC is required by the selected bank profile but is missing on the debtor', - }) - } + checkBicPresent( + issues, + `batches.${bi}.collections.${ci}.debtor.bic`, + collection.debtor.bic, + 'debtor' + ) } } return issues @@ -198,34 +230,24 @@ export const ibanBicCountryMatch: BankProfile = { const batch = doc.batches[bi] if (batch === undefined) continue // Check debtor - if (batch.debtor.bic !== undefined) { - const ibanCc = batch.debtor.iban.slice(0, 2).toUpperCase() - const bicCc = batch.debtor.bic.slice(4, 6).toUpperCase() - if (!ibanBicCountriesMatch(ibanCc, bicCc)) { - issues.push({ - path: `batches.${bi}.debtor`, - message: - `IBAN country (${ibanCc}) does not match BIC country (${bicCc}) on the debtor. ` + - 'Check for a data-entry error or use a different bank profile if this is intentional.', - }) - } - } + checkIbanBicCountryConsistency( + issues, + `batches.${bi}.debtor`, + batch.debtor.iban, + batch.debtor.bic, + 'debtor' + ) // Check per-transfer creditors for (let ti = 0; ti < batch.transfers.length; ti++) { const transfer = batch.transfers[ti] if (transfer === undefined) continue - if (transfer.creditor.bic !== undefined) { - const ibanCc = transfer.creditor.iban.slice(0, 2).toUpperCase() - const bicCc = transfer.creditor.bic.slice(4, 6).toUpperCase() - if (!ibanBicCountriesMatch(ibanCc, bicCc)) { - issues.push({ - path: `batches.${bi}.transfers.${ti}.creditor`, - message: - `IBAN country (${ibanCc}) does not match BIC country (${bicCc}) on the creditor. ` + - 'Check for a data-entry error or use a different bank profile if this is intentional.', - }) - } - } + checkIbanBicCountryConsistency( + issues, + `batches.${bi}.transfers.${ti}.creditor`, + transfer.creditor.iban, + transfer.creditor.bic, + 'creditor' + ) } } return issues @@ -234,18 +256,13 @@ export const ibanBicCountryMatch: BankProfile = { checkDirectDebit(doc: DirectDebitDocument): ProfileIssue[] { const issues: ProfileIssue[] = [] // Check document-level creditor - if (doc.creditor.bic !== undefined) { - const ibanCc = doc.creditor.iban.slice(0, 2).toUpperCase() - const bicCc = doc.creditor.bic.slice(4, 6).toUpperCase() - if (!ibanBicCountriesMatch(ibanCc, bicCc)) { - issues.push({ - path: 'creditor', - message: - `IBAN country (${ibanCc}) does not match BIC country (${bicCc}) on the creditor. ` + - 'Check for a data-entry error or use a different bank profile if this is intentional.', - }) - } - } + checkIbanBicCountryConsistency( + issues, + 'creditor', + doc.creditor.iban, + doc.creditor.bic, + 'creditor' + ) // Check per-collection debtors for (let bi = 0; bi < doc.batches.length; bi++) { const batch = doc.batches[bi] @@ -253,18 +270,13 @@ export const ibanBicCountryMatch: BankProfile = { for (let ci = 0; ci < batch.collections.length; ci++) { const collection = batch.collections[ci] if (collection === undefined) continue - if (collection.debtor.bic !== undefined) { - const ibanCc = collection.debtor.iban.slice(0, 2).toUpperCase() - const bicCc = collection.debtor.bic.slice(4, 6).toUpperCase() - if (!ibanBicCountriesMatch(ibanCc, bicCc)) { - issues.push({ - path: `batches.${bi}.collections.${ci}.debtor`, - message: - `IBAN country (${ibanCc}) does not match BIC country (${bicCc}) on the debtor. ` + - 'Check for a data-entry error or use a different bank profile if this is intentional.', - }) - } - } + checkIbanBicCountryConsistency( + issues, + `batches.${bi}.collections.${ci}.debtor`, + collection.debtor.iban, + collection.debtor.bic, + 'debtor' + ) } } return issues