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:
- Parse errors — malformed BgMax records or invalid XML are captured per-record, not fatal.
- Normalisation errors — missing fields, bad amounts.
- Matcher errors — exceptions from
match_batchare 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.