Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 3 additions & 12 deletions src/model/pain008.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
*/

import { z } from 'zod'
import { isValidIban } from './iban.js'
import { isSepaCharset } from './charset.js'
import {
MoneySchema,
Expand All @@ -20,9 +19,10 @@ import {
CategoryPurposeSchema,
} from './schema.js'
import { isValidCreditorId } from './creditor-id.js'
import { IBANSchema, BICSchema } from './shared.js'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warn fallow/code-duplication: Code clone group 1 (46 lines, 2 instances)


// ---------------------------------------------------------------------------
// 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})?$/
Expand Down Expand Up @@ -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
Expand Down
17 changes: 4 additions & 13 deletions src/model/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warn fallow/code-duplication: Code clone group 1 (46 lines, 2 instances)


// ---------------------------------------------------------------------------
// Internal validators (not exported from public API)
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions src/model/shared.ts
Original file line number Diff line number Diff line change
@@ -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')
101 changes: 53 additions & 48 deletions src/parser/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// ---------------------------------------------------------------------------
Expand All @@ -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) {
Expand Down Expand Up @@ -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])
Expand Down
Loading
Loading