Created
March 9, 2026 11:51
-
-
Save diegomarino/253cecaca7385c47e8df560b6ebb0dcc to your computer and use it in GitHub Desktop.
C43 to CSV converter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Converts a Spanish bank Norma 43 / C43 (Cuaderno 43) file to CSV. | |
| Usage: | |
| python3 c43_to_csv.py <input.txt> [-o output.csv] [--encoding latin-1] [--sample N] | |
| Changelog: | |
| v1 - initial: 10 columns, basic parsing | |
| v2 - merged detalle2/3/4 via raw 76-char block concatenation; concepto_adicional split | |
| v3 - v2 + collapse runs of >=6 consecutive spaces in detalle (fixes dates artifact) | |
| v4 - v3 + argparse CLI · Decimal amounts · dataclass · validation with line numbers · | |
| oficina_origen / subconcepto / documento / referencia_1 fields | |
| v5 - v4 + concepto_adicional whitelist: only SEPA scheme codes (CORE, COR1, …) are | |
| split from the description; all other 2301 text goes into descripcion as-is | |
| v6 - v5 + cleanup pass after human review: | |
| · drop documento / referencia_1 (99%+ rows = 0000000000, pure noise) | |
| · drop signo (redundant with importe sign) | |
| · drop concepto_propio_desc (2-digit [24:26] does NOT map to CaixaBank CLOP codes) | |
| · strip leading "00" type-indicator from ref_inline | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import csv | |
| import re | |
| import sys | |
| from dataclasses import asdict, dataclass, field | |
| from datetime import datetime | |
| from decimal import Decimal, InvalidOperation | |
| from pathlib import Path | |
| from typing import List, Optional | |
| # --------------------------------------------------------------------------- | |
| # Concepto adicional whitelist (2301 record, positions [4:8]) | |
| # Only standardised SEPA payment scheme identifiers qualify. | |
| # All other values at that position are the start of a plain description. | |
| # --------------------------------------------------------------------------- | |
| VALID_CONCEPTO_ADICIONAL = { | |
| # SEPA Core Direct Debit scheme (COR1 retired but still circulates) | |
| "CORE", "COR1", "COR2", "COR3", | |
| # SEPA B2B Direct Debit scheme | |
| "B2B", | |
| "B2B0", "B2B1", "B2B2", | |
| # ISO 20022 sequence types (SeqTp from pain.008 — copied verbatim by most banks) | |
| "FRST", # First collection | |
| "RCUR", # Recurring collection | |
| "OOFF", # One-off collection | |
| "FNAL", # Final collection | |
| # Return / refund management | |
| "RFDD", # Recurring First Direct Debit | |
| "RETN", # Returned | |
| "RFDN", # Refund | |
| # Generic SEPA infrastructure markers (non-standard but observed in production) | |
| "SEPA", | |
| "SDD", | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Code tables | |
| # --------------------------------------------------------------------------- | |
| CONCEPTOS_COMUNES = { | |
| "01": "TALONES / REINTEGROS", | |
| "02": "ABONARES / ENTREGAS / INGRESOS", | |
| "03": "DOMICILIADOS / RECIBOS / LETRAS / PAGOS", | |
| "04": "GIROS / TRANSFERENCIAS / TRASPASOS / CHEQUES", | |
| "05": "AMORTIZACIONES PRESTAMOS / CREDITOS", | |
| "06": "REMESAS EFECTOS", | |
| "07": "SUSCRIPCIONES / DIV. PASIVOS / CANJES", | |
| "08": "DIV. CUPONES / PRIMA JUNTA / AMORTIZACIONES", | |
| "09": "OPERACIONES DE BOLSA / COMPRA-VENTA VALORES", | |
| "10": "CHEQUES GASOLINA", | |
| "11": "CAJERO AUTOMATICO", | |
| "12": "TARJETAS DE CREDITO / DEBITO", | |
| "13": "OPERACIONES EXTRANJERO", | |
| "14": "DEVOLUCIONES E IMPAGADOS", | |
| "15": "NOMINAS / SEGUROS SOCIALES", | |
| "16": "TIMBRES / CORRETAJE / POLIZA", | |
| "17": "INTERESES / COMISIONES / CUSTODIA / GASTOS E IMPUESTOS", | |
| "98": "ANULACIONES / CORRECCIONES ASIENTO", | |
| "99": "VARIOS", | |
| } | |
| CODIGOS_PROPIOS = { | |
| "00": "IMPOSICION EN EFECTIVO", | |
| "01": "COMPANIA DE AGUA", | |
| "02": "TRANSFERENCIA", | |
| "05": "COMPANIA DE ELECTRICIDAD", | |
| "06": "COMPANIA DE SERVICIOS TELEFONICOS", | |
| "07": "COMPANIA DE GAS", | |
| "12": "DEVOLUCION CHEQUE", | |
| "16": "IMPUESTOS/TRIBUTOS/EMBARGOS/RETENCION FISCAL", | |
| "17": "INTERESES", | |
| "18": "PRESTAMO/LEASING/FINANCIACION/CUENTA DE CREDITO", | |
| "19": "SEGURIDAD SOCIAL/AUTONOMOS", | |
| "20": "CORREO", | |
| "25": "VALORES", | |
| "29": "FINCAS / ALQUILERES", | |
| "30": "FACTORING", | |
| "32": "OPERACIONES EN DIVISAS", | |
| "33": "CUOTA DE ASOCIADO", | |
| "34": "ENSENANZA", | |
| "35": "ENTIDAD PREVISION / ENTIDAD FINANCIACION", | |
| "36": "PRECIO SERVICIOS", | |
| "38": "CARGO DE RECIBOS/PAGARES/PAGOS DOMICILIADOS", | |
| "39": "PUBLICACIONES", | |
| "40": "COMERCIOS/CAJEROS/TARJETAS", | |
| "41": "NOMINA", | |
| "44": "TARJETA DE CREDITO", | |
| "46": "COMPRA EN COMERCIO", | |
| "48": "ABONOS DIVERSOS", | |
| "49": "CUOTA DE ENTIDAD", | |
| "51": "OPERATIVA DE SEGUROS", | |
| "54": "FONDO DE INVERSION MOBILIARIA", | |
| "55": "ADEUDOS DOMICILIADOS / GESTION COBRO", | |
| "56": "AVALES", | |
| "61": "AHORRO A PLAZO", | |
| "64": "CHEQUE", | |
| "65": "LIQUIDACION DE FACTURAS", | |
| "67": "TRASPASO / TRANSFERENCIA URGENTE / B. DIGITAL", | |
| "68": "EMBARGO", | |
| "72": "PAGOS A TERCEROS / SERVICAIXA", | |
| "73": "SERVICIO DE PAGOS", | |
| "74": "SERVICIO CENTRALIZACION DE FONDOS", | |
| "76": "ANTICIPO ADEUDOS", | |
| "79": "CONFIRMING", | |
| "81": "INGRESO CHEQUE", | |
| "83": "RECOGIDA/ENTREGA DE FONDOS A DOMICILIO", | |
| "85": "CANCELACION", | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Errors | |
| # --------------------------------------------------------------------------- | |
| class ParseError(ValueError): | |
| """Raised when a line cannot be parsed safely.""" | |
| # --------------------------------------------------------------------------- | |
| # Data model | |
| # --------------------------------------------------------------------------- | |
| @dataclass | |
| class Movement: | |
| fecha_operacion: str | |
| fecha_valor: str | |
| importe: Decimal | |
| oficina_origen: str | |
| concepto_comun: str | |
| concepto_comun_desc: str | |
| concepto_propio: str # raw 2-digit AEB sub-concept code from record 22 [24:26] | |
| subconcepto: str | |
| ref_inline: str | |
| concepto_adicional: str = "" # 4-char code from 2301[4:8] | |
| descripcion: str = "" # free text from 2301[8:] | |
| detalle: str = "" # merged 2302+2303+2304 | |
| referencia: str = "" # from 2305 | |
| def to_csv_row(self) -> dict: | |
| d = asdict(self) | |
| d["importe"] = f"{self.importe:.2f}" | |
| return d | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| def parse_date(value: str, line_no: int, field: str) -> str: | |
| if len(value) != 6 or not value.isdigit(): | |
| raise ParseError(f"Line {line_no}: {field} invalid {value!r} — expected 6-digit YYMMDD") | |
| yy = int(value[:2]) | |
| year = 2000 + yy if yy < 60 else 1900 + yy | |
| try: | |
| dt = datetime(year, int(value[2:4]), int(value[4:6])) | |
| except ValueError as exc: | |
| raise ParseError(f"Line {line_no}: {field} invalid {value!r}: {exc}") from exc | |
| return dt.strftime("%Y-%m-%d") | |
| def parse_amount(raw: str, sign: str, line_no: int) -> Decimal: | |
| if len(raw) != 14 or not raw.isdigit(): | |
| raise ParseError(f"Line {line_no}: amount invalid {raw!r} — expected 14 digits") | |
| if sign not in {"1", "2"}: | |
| raise ParseError(f"Line {line_no}: sign invalid {sign!r} — expected '1' (debit) or '2' (credit)") | |
| amount = Decimal(raw) / Decimal("100") | |
| return -amount if sign == "1" else amount | |
| def normalize_line(raw: str, line_no: int) -> str: | |
| line = raw.rstrip("\r\n") | |
| if len(line) > 80: | |
| raise ParseError(f"Line {line_no}: length {len(line)} > 80 — not a valid Norma 43 record") | |
| return line.ljust(80) | |
| def join_raw_blocks(blocks: list[str]) -> str: | |
| """ | |
| Concatenate fixed-width 76-char blocks by direct character concatenation | |
| (no separator), then apply two cleanup passes: | |
| 1. Remove runs of EXACTLY 6 spaces surrounded by non-space characters. | |
| These are fixed-width field-padding artifacts where a value (e.g. a year | |
| digit) was split across two 76-char lines: '201 9' → '2019'. | |
| Longer runs (e.g. 19-space IBAN/reference separators) are untouched because | |
| the lookahead lands on another space, not a non-space character. | |
| 2. Strip leading/trailing whitespace from the final result. | |
| """ | |
| combined = "".join(blocks) | |
| # (?<=[^\s]) {6}(?=[^\s]) — exactly 6 spaces between two non-space chars | |
| combined = re.sub(r"(?<=[^\s]) {6}(?=[^\s])", "", combined) | |
| return combined.strip() | |
| # --------------------------------------------------------------------------- | |
| # Parser | |
| # --------------------------------------------------------------------------- | |
| def parse_c43(path: Path, encoding: str = "latin-1") -> List[Movement]: | |
| movements: List[Movement] = [] | |
| current: Optional[Movement] = None | |
| raw_blocks: List[str] = [] # accumulates 76-char detalle lines | |
| def finalize(m: Movement, blocks: list) -> Movement: | |
| m.detalle = join_raw_blocks(blocks) | |
| return m | |
| with path.open("r", encoding=encoding) as fh: | |
| for line_no, raw in enumerate(fh, start=1): | |
| line = normalize_line(raw, line_no) | |
| rec = line[0:2] | |
| if rec == "22": | |
| if current is not None: | |
| movements.append(finalize(current, raw_blocks)) | |
| raw_blocks = [] | |
| raw_ref = line[62:80].strip() | |
| current = Movement( | |
| fecha_operacion = parse_date(line[10:16], line_no, "fecha_operacion"), | |
| fecha_valor = parse_date(line[16:22], line_no, "fecha_valor"), | |
| importe = parse_amount(line[28:42], line[27], line_no), | |
| oficina_origen = line[6:10].strip(), | |
| concepto_comun = line[22:24], | |
| concepto_comun_desc = CONCEPTOS_COMUNES.get(line[22:24], ""), | |
| concepto_propio = line[24:26], | |
| subconcepto = line[26].strip(), | |
| ref_inline = raw_ref[2:] if raw_ref.startswith("00") else raw_ref, | |
| ) | |
| elif rec == "23" and current is not None: | |
| sub = line[2:4] | |
| raw76 = line[4:80] # raw 76 chars — do NOT strip yet | |
| content = raw76.strip() | |
| if sub == "01": | |
| code = line[4:8] | |
| if code in VALID_CONCEPTO_ADICIONAL: | |
| # Genuine SEPA scheme code: split code from description | |
| current.concepto_adicional = code | |
| current.descripcion = line[8:80].strip() | |
| else: | |
| # Plain description starting at position 4 (no code) | |
| current.concepto_adicional = "" | |
| current.descripcion = line[4:80].strip() | |
| elif sub == "02": | |
| raw_blocks.append(raw76) | |
| elif sub == "03": | |
| raw_blocks.append(raw76) | |
| elif sub == "04": | |
| raw_blocks.append(raw76) | |
| elif sub == "05": | |
| current.referencia = content | |
| # unknown sub-types (bank-specific extensions) are silently ignored | |
| elif rec in {"11", "33", "88", "99"}: | |
| pass # structural records — not extracted to CSV | |
| elif rec.strip() == "": | |
| pass # blank padding line | |
| # unknown record types are silently skipped (tolerant parsing) | |
| if current is not None: | |
| movements.append(finalize(current, raw_blocks)) | |
| return movements | |
| # --------------------------------------------------------------------------- | |
| # Output | |
| # --------------------------------------------------------------------------- | |
| FIELDNAMES = [ | |
| "fecha_operacion", "fecha_valor", "importe", | |
| "oficina_origen", | |
| "concepto_comun", "concepto_comun_desc", | |
| "concepto_propio", | |
| "subconcepto", | |
| "concepto_adicional", "descripcion", | |
| "detalle", | |
| "referencia", "ref_inline", | |
| ] | |
| def write_csv(path: Path, movements: List[Movement]) -> None: | |
| with path.open("w", newline="", encoding="utf-8-sig") as fh: | |
| writer = csv.DictWriter(fh, fieldnames=FIELDNAMES) | |
| writer.writeheader() | |
| writer.writerows(m.to_csv_row() for m in movements) | |
| # --------------------------------------------------------------------------- | |
| # CLI | |
| # --------------------------------------------------------------------------- | |
| def build_arg_parser() -> argparse.ArgumentParser: | |
| p = argparse.ArgumentParser( | |
| description="Convierte un fichero Norma 43 / Cuaderno 43 (AEB) a CSV" | |
| ) | |
| p.add_argument("input", type=Path, help="Fichero de entrada (.txt / .n43 / .c43)") | |
| p.add_argument("-o", "--output", type=Path, | |
| help="CSV de salida (defecto: <input>.csv)") | |
| p.add_argument("--encoding", default="latin-1", | |
| help="Codificación del fichero de entrada (defecto: latin-1)") | |
| p.add_argument("--sample", type=int, default=8, | |
| help="Filas de muestra a imprimir (defecto: 8)") | |
| return p | |
| def main() -> int: | |
| args = build_arg_parser().parse_args() | |
| input_path: Path = args.input | |
| if not input_path.exists(): | |
| print(f"ERROR: no existe el fichero {input_path}", file=sys.stderr) | |
| return 1 | |
| output_path: Path = args.output or input_path.with_suffix(".csv") | |
| try: | |
| movements = parse_c43(input_path, encoding=args.encoding) | |
| except ParseError as exc: | |
| print(f"ERROR: {exc}", file=sys.stderr) | |
| return 1 | |
| write_csv(output_path, movements) | |
| print(f"✓ {len(movements)} movimientos → {output_path}") | |
| if args.sample > 0: | |
| print(f"\nMuestra ({args.sample} filas):") | |
| for m in movements[:args.sample]: | |
| concept = m.concepto_comun_desc or m.concepto_comun | |
| print(f" {m.fecha_operacion} {m.importe:>12} " | |
| f"{concept[:30]:<30} {m.descripcion[:50]}") | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment