Skip to content

Reconciliation

Parsing a bank file is only half the job — the real value is matching each incoming payment to the right open invoice, claim, or booking. The ReconciliationService in craft-easy-file-import ties the bank parsers to a PaymentMatchingService so that a BgMax or SEPA file turns into a list of matched and unmatched payments that the downstream system can act on.

The reconciliation flow

  bank file
┌──────────────┐
│    parse     │  BgMaxParser / SEPAParser
└──────────────┘
┌──────────────┐
│  normalise   │  → IncomingPayment[]
└──────────────┘
┌──────────────┐
│    match     │  PaymentMatchingService.match_batch()
└──────────────┘
┌──────────────────────┐
│  ReconciliationResult│  matched[] + unmatched[]
└──────────────────────┘

The service is agnostic to the matching strategy — whatever you plug in as PaymentMatchingService decides what counts as a match (exact OCR, fuzzy amount + date, claim number lookup, etc.). The default implementation uses OCR/reference matching against open invoices.

Using the service

from craft_easy_file_import.reconciliation import ReconciliationService

svc = ReconciliationService()

with open("incoming.bgm", "r", encoding="latin-1") as f:
    result = await svc.reconcile_bgmax(
        content=f.read(),
        tenant_id="tenant_123",
        import_batch_id="batch-2026-04-05",
    )

print(f"{result.matched_count}/{result.total_payments} matched")
print(f"Matched amount:   {result.matched_amount}")
print(f"Unmatched amount: {result.unmatched_amount}")

for err in result.errors:
    print("ERROR:", err)

For SEPA:

result = await svc.reconcile_sepa(
    content=xml_content,
    tenant_id="tenant_123",
    import_batch_id="batch-2026-04-05",
)

Both methods return a ReconciliationResult with the same shape.

The ReconciliationResult

@dataclass
class ReconciliationResult:
    file_format: str                    # "bgmax" or "sepa"
    total_payments: int
    matched_count: int
    unmatched_count: int
    total_amount: Decimal
    matched_amount: Decimal
    unmatched_amount: Decimal
    results: list[MatchResult]          # one entry per payment
    errors: list[str]
    file_date: datetime | None

Each MatchResult in results contains:

  • The original IncomingPayment
  • The matched resource (e.g. an invoice) if any
  • A confidence score
  • A reason if unmatched (no_ocr, amount_mismatch, already_paid, etc.)

Format detection

If you receive bank files from multiple providers and don't know up front which format they are, use the detection helper:

from craft_easy_file_import.parsers.banking import BgMaxParser, SEPAParser

def detect_format(content: str) -> str | None:
    if BgMaxParser._detect_format(content):
        return "bgmax"
    if content.lstrip().startswith("<?xml"):
        return "sepa"
    return None

fmt = detect_format(content)
if fmt == "bgmax":
    result = await svc.reconcile_bgmax(content, tenant_id)
elif fmt == "sepa":
    result = await svc.reconcile_sepa(content, tenant_id)
else:
    raise ValueError("Unknown bank file format")

The BgMax detector checks for the 01 header record at the start of the file; SEPA is recognisable by its XML prologue.

Callbacks: on_matched and on_unmatched

For side-effects (posting to the API, sending emails, writing to a log), use the ReconciliationPipeline wrapper, which takes callbacks:

from craft_easy_file_import import ReconciliationPipeline, SourceFile
from craft_easy_file_import import ImportApiClient

async def mark_as_paid(match_result):
    async with ImportApiClient(base_url="...", token="...", tenant_id="tenant_123") as api:
        await api.post(
            f"/invoices/{match_result.invoice_id}/payments",
            data={
                "amount": str(match_result.payment.amount),
                "reference": match_result.payment.reference,
                "paid_at": match_result.payment.payment_date.isoformat(),
            },
        )

async def flag_for_manual_review(match_result):
    async with ImportApiClient(base_url="...", token="...", tenant_id="tenant_123") as api:
        await api.post(
            "/unmatched-payments",
            data={
                "amount": str(match_result.payment.amount),
                "reference": match_result.payment.reference,
                "reason": match_result.reason,
            },
        )

pipeline = ReconciliationPipeline(
    file_format="bgmax",
    tenant_id="tenant_123",
    on_matched=mark_as_paid,
    on_unmatched=flag_for_manual_review,
)

source_file = SourceFile(
    name="incoming.bgm",
    content=open("incoming.bgm", "rb").read(),
    size=...,
)
pipeline_result = await pipeline.process(source_file)

Callbacks are awaited one at a time so your API stays under control — no thundering herd if 5 000 payments arrive at once. If a callback raises, the exception is captured in pipeline_result.errors and the next payment is processed.

IncomingPayment — the common representation

Both parsers normalise into IncomingPayment before matching:

@dataclass
class IncomingPayment:
    amount: Decimal
    currency: str                       # "SEK" for BgMax, actual currency for SEPA
    reference: str | None               # OCR / reference / end-to-end ID
    sender_name: str | None
    sender_account: str | None          # account number or IBAN
    payment_date: datetime | None
    bank_reference: str | None
    row_number: int
    import_batch_id: str | None

This is the shape the PaymentMatchingService sees, regardless of whether the file was BgMax or SEPA. If you write a custom parser for a new bank format, produce IncomingPayment objects and you can reuse the same matching and callbacks.

Plugging in your own matcher

ReconciliationService.__init__ constructs a default PaymentMatchingService with OCR-based matching. For custom logic — fuzzy matching, partial payments, claim-number lookups — subclass or replace the matcher:

from craft_easy_file_import.reconciliation.service import (
    ReconciliationService,
    PaymentMatchingService,
)

class FuzzyMatchingService(PaymentMatchingService):
    async def match_batch(self, payments, tenant_id):
        # Custom matching logic
        ...

svc = ReconciliationService()
svc._matcher = FuzzyMatchingService()

The service delegates every match decision to the matcher, so all the parsing, batching, error handling, and result aggregation keeps working unchanged.

Error handling

ReconciliationResult.errors is populated from three places:

  1. Parse errors — malformed BgMax records or invalid XML are captured per-record, not fatal.
  2. Normalisation errors — missing fields, bad amounts.
  3. Matcher errors — exceptions from match_batch are caught per batch.

A reconciliation run always returns a ReconciliationResult — it never raises to the caller. Inspect errors after every run, and decide on your own whether to retry, alert, or continue.