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
253 changes: 150 additions & 103 deletions src/model/dd-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,143 +27,190 @@
import type { DirectDebitDocument } from './pain008.js'
import type { ProfileIssue } from '../profile/profile.js'

/** Internal record of one mandate usage within a document, used for R2/R3 checks. */
interface MandateUsage {
batchIdx: number
colIdx: number
collectionDate: string
signatureDate: string
sequenceType: string
localInstrument: string
}

/**
* Check cross-field SEPA rulebook constraints on a DirectDebitDocument.
* Returns an empty array when the document passes all rules.
* Returns one ProfileIssue per violation with a precise path and message.
*
* @param doc A DirectDebitDocument that has already passed Zod schema validation.
* @returns An array of ProfileIssue describing any violations (empty when valid).
* Append a mandate usage record to the index map, creating a new entry if needed.
* This is a pure accumulator with no side effects beyond the map mutation.
*/
export function checkDirectDebitRules(doc: DirectDebitDocument): ProfileIssue[] {
function accumulateMandateUsage(
index: Map<string, MandateUsage[]>,
mandateId: string,
usage: MandateUsage
): void {
const existing = index.get(mandateId)
if (existing !== undefined) {
existing.push(usage)
} else {
index.set(mandateId, [usage])
}
}

/**
* R1: mandate signatureDate must not be after the batch collectionDate.
* Returns a ProfileIssue if violated, null otherwise.
*/
function checkR1SignatureBeforeCollection(
bIdx: number,
cIdx: number,
signatureDate: string,
collectionDate: string
): ProfileIssue | null {
if (signatureDate <= collectionDate) return null
return {
path: `batches.${bIdx}.collections.${cIdx}.mandate.signatureDate`,
message:
`R1: mandate signatureDate (${signatureDate}) is after` +
` the batch collectionDate (${collectionDate}).` +
` A debit cannot precede the mandate signature.`,
}
}

/**
* R2: a mandate id used in an OOFF batch must appear in exactly one collection
* across the whole document. Returns issues for all occurrences beyond the first.
*/
function checkR2Issues(mandateId: string, usages: MandateUsage[]): ProfileIssue[] {
const hasOoff = usages.some((u) => u.sequenceType === 'OOFF')
if (!hasOoff) return []

// The mandate appears more than once and at least one occurrence is OOFF.
// Report all occurrences beyond the first to make the error precise.
const issues: ProfileIssue[] = []
for (let i = 1; i < usages.length; i++) {
const u = usages[i]
if (u === undefined) continue
issues.push({
path: `batches.${u.batchIdx}.collections.${u.colIdx}.mandate.id`,
message:
`R2: mandate "${mandateId}" is used in an OOFF batch and appears in` +
` more than one collection in this document.` +
` An OOFF mandate authorizes exactly one collection.`,
})
}
return issues
}

/**
* R3: a mandate id must not appear under both CORE and B2B local instruments
* in the same document. Returns issues for all conflicting occurrences.
*/
function checkR3Issues(mandateId: string, usages: MandateUsage[]): ProfileIssue[] {
const instruments = new Set(usages.map((u) => u.localInstrument))
if (instruments.size <= 1) return []

// Build an index: mandateId -> list of { batchIdx, colIdx, collectionDate, sequenceType, localInstrument }
interface MandateUsage {
batchIdx: number
colIdx: number
collectionDate: string
signatureDate: string
sequenceType: string
localInstrument: string
// Mandate appears under multiple instruments (e.g. CORE and B2B). Report from second occurrence.
const issues: ProfileIssue[] = []
const first = usages[0]
if (first === undefined) return []

for (let i = 1; i < usages.length; i++) {
const u = usages[i]
if (u === undefined) continue
if (u.localInstrument !== first.localInstrument) {
issues.push({
path: `batches.${u.batchIdx}.collections.${u.colIdx}.mandate.id`,
message:
`R3: mandate "${mandateId}" is used under local instrument "${u.localInstrument}"` +
` in batch ${u.batchIdx} but under "${first.localInstrument}" in batch ${first.batchIdx}.` +
` A mandate is bound to one scheme (CORE or B2B) and cannot be reused across schemes.`,
})
}
}
return issues
}

const mandateIndex = new Map<string, MandateUsage[]>()
/**
* R4: if any collection in a batch has mandate.amendment.sameMandateNewDebtorAccount === true
* (SMNDA), the batch's sequenceType must be 'FRST'.
* Returns issues for each violating collection.
*/
function checkR4SmndaRequiresFirst(doc: DirectDebitDocument): ProfileIssue[] {

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/high-cognitive-complexity: 'checkR4SmndaRequiresFirst' has cognitive complexity 13 (threshold: 12)

const issues: ProfileIssue[] = []

for (let bIdx = 0; bIdx < doc.batches.length; bIdx++) {
const batch = doc.batches[bIdx]
// doc is validated so batch is always defined here
if (batch === undefined) continue

const effectiveLocalInstrument = batch.localInstrument ?? 'CORE'
if (batch.sequenceType === 'FRST') continue

for (let cIdx = 0; cIdx < batch.collections.length; cIdx++) {
const col = batch.collections[cIdx]
if (col === undefined) continue

const usage: MandateUsage = {
batchIdx: bIdx,
colIdx: cIdx,
collectionDate: batch.collectionDate,
signatureDate: col.mandate.signatureDate,
sequenceType: batch.sequenceType,
localInstrument: effectiveLocalInstrument,
}

// R1: signatureDate must not be after collectionDate (YYYY-MM-DD lexicographic)
if (col.mandate.signatureDate > batch.collectionDate) {
if (col.mandate.amendment?.sameMandateNewDebtorAccount === true) {
issues.push({
path: `batches.${bIdx}.collections.${cIdx}.mandate.signatureDate`,
path: `batches.${bIdx}.collections.${cIdx}.mandate.amendment.sameMandateNewDebtorAccount`,
message:
`R1: mandate signatureDate (${col.mandate.signatureDate}) is after` +
` the batch collectionDate (${batch.collectionDate}).` +
` A debit cannot precede the mandate signature.`,
`R4: batch ${bIdx} has sequenceType "${batch.sequenceType}" but collection ${cIdx}` +
` has sameMandateNewDebtorAccount=true (SMNDA).` +
` A batch containing SMNDA collections must have sequenceType FRST,` +
` because SMNDA represents the first collection under the amended mandate.`,
})
}

// Accumulate usage for R2 and R3
const existing = mandateIndex.get(col.mandate.id)
if (existing !== undefined) {
existing.push(usage)
} else {
mandateIndex.set(col.mandate.id, [usage])
}
}
}

// R2 and R3: check each mandate that appears more than once
for (const [mandateId, usages] of mandateIndex) {
if (usages.length < 2) continue

// R2: if any usage is under OOFF, the mandate may appear exactly once in total
const hasOoff = usages.some((u) => u.sequenceType === 'OOFF')
if (hasOoff) {
// The mandate appears more than once and at least one occurrence is OOFF.
// Report one issue pointing to the second (and later) OOFF or non-OOFF occurrences.
// We report all occurrences beyond the first to make the error precise.
for (let i = 1; i < usages.length; i++) {
const u = usages[i]
if (u === undefined) continue
issues.push({
path: `batches.${u.batchIdx}.collections.${u.colIdx}.mandate.id`,
message:
`R2: mandate "${mandateId}" is used in an OOFF batch and appears in` +
` more than one collection in this document.` +
` An OOFF mandate authorizes exactly one collection.`,
})
}
}
return issues
}

// R3: all usages of a mandate must share the same local instrument
const instruments = new Set(usages.map((u) => u.localInstrument))
if (instruments.size > 1) {
// Mandate appears under multiple instruments (e.g. CORE and B2B). Report from second occurrence.
for (let i = 1; i < usages.length; i++) {
const u = usages[i]
if (u === undefined) continue
const first = usages[0]
if (first === undefined) continue
if (u.localInstrument !== first.localInstrument) {
issues.push({
path: `batches.${u.batchIdx}.collections.${u.colIdx}.mandate.id`,
message:
`R3: mandate "${mandateId}" is used under local instrument "${u.localInstrument}"` +
` in batch ${u.batchIdx} but under "${first.localInstrument}" in batch ${first.batchIdx}.` +
` A mandate is bound to one scheme (CORE or B2B) and cannot be reused across schemes.`,
})
}
}
}
}
/**
* Check cross-field SEPA rulebook constraints on a DirectDebitDocument.
* Returns an empty array when the document passes all rules.
* Returns one ProfileIssue per violation with a precise path and message.
*
* @param doc A DirectDebitDocument that has already passed Zod schema validation.
* @returns An array of ProfileIssue describing any violations (empty when valid).
*/
export function checkDirectDebitRules(doc: DirectDebitDocument): ProfileIssue[] {

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/high-cognitive-complexity: 'checkDirectDebitRules' has cognitive complexity 15 (threshold: 12)

const issues: ProfileIssue[] = []
const mandateIndex = new Map<string, MandateUsage[]>()

// R4: SMNDA requires FRST sequenceType
// If any collection in a batch has mandate.amendment.sameMandateNewDebtorAccount === true,
// the batch's sequenceType must be 'FRST'.
// Build the mandate index and collect R1 issues in collection-traversal order.
for (let bIdx = 0; bIdx < doc.batches.length; bIdx++) {
const batch = doc.batches[bIdx]
if (batch === undefined) continue

if (batch.sequenceType === 'FRST') {
// Already FRST, no violation possible for this batch.
continue
}
const effectiveLocalInstrument = batch.localInstrument ?? 'CORE'

for (let cIdx = 0; cIdx < batch.collections.length; cIdx++) {
const col = batch.collections[cIdx]
if (col === undefined) continue

if (col.mandate.amendment?.sameMandateNewDebtorAccount === true) {
issues.push({
path: `batches.${bIdx}.collections.${cIdx}.mandate.amendment.sameMandateNewDebtorAccount`,
message:
`R4: batch ${bIdx} has sequenceType "${batch.sequenceType}" but collection ${cIdx}` +
` has sameMandateNewDebtorAccount=true (SMNDA).` +
` A batch containing SMNDA collections must have sequenceType FRST,` +
` because SMNDA represents the first collection under the amended mandate.`,
})
}
const r1Issue = checkR1SignatureBeforeCollection(
bIdx,
cIdx,
col.mandate.signatureDate,
batch.collectionDate
)
if (r1Issue !== null) issues.push(r1Issue)

accumulateMandateUsage(mandateIndex, col.mandate.id, {
batchIdx: bIdx,
colIdx: cIdx,
collectionDate: batch.collectionDate,
signatureDate: col.mandate.signatureDate,
sequenceType: batch.sequenceType,
localInstrument: effectiveLocalInstrument,
})
}
}

// Collect R2 and R3 issues per mandate entry (R2 before R3 within each mandate, preserving original order).
for (const [mandateId, usages] of mandateIndex) {
if (usages.length < 2) continue
issues.push(...checkR2Issues(mandateId, usages))
issues.push(...checkR3Issues(mandateId, usages))
}

// Collect R4 issues (SMNDA must be in a FRST batch).
issues.push(...checkR4SmndaRequiresFirst(doc))

return issues
}
11 changes: 2 additions & 9 deletions src/model/pain008.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export type Creditor = z.infer<typeof CreditorSchema>
* We support the Tp (code) branch only, which is XSD-valid; the Prd and PtInTm
* branches are a documented follow-up.
*/
export const FrequencyCodeSchema = z.enum([
const FrequencyCodeSchema = z.enum([
'YEAR',
'MNTH',
'QURT',
Expand All @@ -184,8 +184,6 @@ export const FrequencyCodeSchema = z.enum([
'INDA',
'FRTN',
])
export type FrequencyCode = z.infer<typeof FrequencyCodeSchema>

/**
* Original mandate setup reason (AmdmntInfDtls/OrgnlRsn), MandateSetupReason1Choice: Cd XOR Prtry.
*
Expand All @@ -200,12 +198,7 @@ const MandateReasonProprietarySchema = z.object({
/** Proprietary reason value (Prtry), max 70 chars, SEPA charset. */
proprietary: sepaText(70),
})
export const MandateSetupReasonSchema = z.union([
MandateReasonCodeSchema,
MandateReasonProprietarySchema,
])
export type MandateSetupReason = z.infer<typeof MandateSetupReasonSchema>

const MandateSetupReasonSchema = z.union([MandateReasonCodeSchema, MandateReasonProprietarySchema])
/**
* Original creditor scheme identification (AmdmntInfDtls/OrgnlCdtrSchmeId).
*
Expand Down
25 changes: 2 additions & 23 deletions src/writer/xml-emit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,7 @@ function emitCtryAdrLinesClose(lines: string[], indent: string, address: PostalA
* @param indent - leading spaces for the PstlAdr tag (one level deeper than the party tag)
* @param address - optional PostalAddress from the model; nothing emitted when absent
*/
// fallow-ignore-next-line unused-exports
export function emitPstlAdr(
lines: string[],
indent: string,
address: PostalAddress | undefined
): void {
function emitPstlAdr(lines: string[], indent: string, address: PostalAddress | undefined): void {
if (address === undefined) {
return
}
Expand Down Expand Up @@ -194,21 +189,6 @@ export function emitPartyWithAddress(
lines.push(`${indent}</${tag}>`)
}

/**
* Emit a party element containing only a Nm child.
* Used for Dbtr, Cdtr blocks that carry just the name (legacy/DK variants).
*
* @param indent - leading spaces for the outer tag (e.g. " " for 6 spaces)
* @param tag - element name, e.g. "Dbtr" or "Cdtr"
* @param name - party name (will be XML-escaped)
*/
// fallow-ignore-next-line unused-exports
export function emitNmElement(lines: string[], indent: string, tag: string, name: string): void {
lines.push(`${indent}<${tag}>`)
lines.push(`${indent} <Nm>${xe(name)}</Nm>`)
lines.push(`${indent}</${tag}>`)
}

/**
* Emit a PstlAdr element for the DK SEPA variants (pain.001.003.03 and pain.008.003.02).
*
Expand All @@ -229,8 +209,7 @@ export function emitNmElement(lines: string[], indent: string, tag: string, name
* @param address - optional PostalAddress from the model; nothing emitted when absent
* @param variant - variant name used in error messages
*/
// fallow-ignore-next-line unused-exports
export function emitPstlAdrSEPA(
function emitPstlAdrSEPA(
lines: string[],
indent: string,
address: PostalAddress | undefined,
Expand Down
Loading
Loading