tx-verify 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tx_verify/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """Payment Verification API - Python library for verifying Ethiopian payment transactions."""
2
+
3
+ from tx_verify.services.verify_abyssinia import AbyssiniaReceipt, verify_abyssinia
4
+ from tx_verify.services.verify_cbe import VerifyResult, verify_cbe
5
+ from tx_verify.services.verify_cbe_birr import CBEBirrReceipt, verify_cbe_birr
6
+ from tx_verify.services.verify_dashen import DashenVerifyResult, verify_dashen
7
+ from tx_verify.services.verify_image import verify_image
8
+ from tx_verify.services.verify_mpesa import MpesaVerifyResult, verify_mpesa
9
+ from tx_verify.services.verify_telebirr import (
10
+ TelebirrReceipt,
11
+ TelebirrVerificationError,
12
+ verify_telebirr,
13
+ )
14
+ from tx_verify.services.verify_universal import verify_universal
15
+ from tx_verify.utils.error_handler import AppError, ErrorType
16
+ from tx_verify.utils.logger import logger
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "verify_cbe",
22
+ "verify_telebirr",
23
+ "verify_dashen",
24
+ "verify_abyssinia",
25
+ "verify_cbe_birr",
26
+ "verify_mpesa",
27
+ "verify_image",
28
+ "verify_universal",
29
+ "VerifyResult",
30
+ "TelebirrReceipt",
31
+ "TelebirrVerificationError",
32
+ "DashenVerifyResult",
33
+ "AbyssiniaReceipt",
34
+ "CBEBirrReceipt",
35
+ "MpesaVerifyResult",
36
+ "AppError",
37
+ "ErrorType",
38
+ "logger",
39
+ ]
File without changes
@@ -0,0 +1,156 @@
1
+ """Bank of Abyssinia payment verification service.
2
+
3
+ Translated from src/services/verifyAbyssinia.ts
4
+ """
5
+
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+
9
+ import httpx
10
+
11
+ from tx_verify.services.verify_cbe import VerifyResult
12
+ from tx_verify.utils.http_client import get_async_client
13
+ from tx_verify.utils.logger import logger
14
+
15
+
16
+ @dataclass
17
+ class AbyssiniaReceipt:
18
+ """Raw receipt fields returned by the Abyssinia API."""
19
+
20
+ source_account_name: str = ""
21
+ vat: str = ""
22
+ transferred_amount_in_word: str = ""
23
+ address: str = ""
24
+ transaction_type: str = ""
25
+ service_charge: str = ""
26
+ source_account: str = ""
27
+ payment_reference: str = ""
28
+ tel: str = ""
29
+ payer_name: str = ""
30
+ narrative: str = ""
31
+ transferred_amount: str = ""
32
+ transaction_reference: str = ""
33
+ transaction_date: str = ""
34
+ total_amount_including_vat: str = ""
35
+
36
+
37
+ async def verify_abyssinia(
38
+ reference: str, suffix: str = "", *, proxies: str | dict[str, str] | None = None
39
+ ) -> VerifyResult:
40
+ """Verify an Abyssinia bank transaction via their public API.
41
+
42
+ Args:
43
+ reference: Transaction reference (e.g. "FT23062669JJ")
44
+ suffix: Last 5 digits of the user's account (e.g. "90172")
45
+ """
46
+ try:
47
+ logger.info(
48
+ "\U0001f3e6 Starting Abyssinia verification for reference: %s with suffix: %s",
49
+ reference,
50
+ suffix,
51
+ )
52
+
53
+ api_url = (
54
+ f"https://cs.bankofabyssinia.com/api/onlineSlip/getDetails/?id={reference}{suffix}"
55
+ )
56
+ logger.info("\U0001f4e1 Fetching from URL: %s", api_url)
57
+
58
+ async with get_async_client(timeout=30.0, proxies=proxies) as client:
59
+ response = await client.get(
60
+ api_url,
61
+ headers={
62
+ "User-Agent": (
63
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
64
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
65
+ "Chrome/91.0.4472.124 Safari/537.36"
66
+ ),
67
+ "Accept": "application/json, text/plain, */*",
68
+ "Accept-Language": "en-US,en;q=0.9",
69
+ "Cache-Control": "no-cache",
70
+ "Pragma": "no-cache",
71
+ },
72
+ )
73
+
74
+ logger.info("\u2705 Successfully fetched response with status: %s", response.status_code)
75
+
76
+ json_data = response.json()
77
+
78
+ # Validate response structure
79
+ if (
80
+ not json_data
81
+ or "header" not in json_data
82
+ or "body" not in json_data
83
+ or not isinstance(json_data["body"], list)
84
+ ):
85
+ logger.error("\u274c Invalid response structure from Abyssinia API")
86
+ return VerifyResult(
87
+ success=False, error="Invalid response structure from Abyssinia API"
88
+ )
89
+
90
+ if json_data["header"].get("status") != "success":
91
+ status = json_data["header"].get("status")
92
+ logger.error("\u274c API returned error status: %s", status)
93
+ return VerifyResult(success=False, error=f"API returned error status: {status}")
94
+
95
+ if len(json_data["body"]) == 0:
96
+ logger.error("\u274c No transaction data found in response body")
97
+ return VerifyResult(success=False, error="No transaction data found in response body")
98
+
99
+ tx = json_data["body"][0]
100
+ logger.debug("\U0001f4cb Raw transaction data from API: %s", tx)
101
+
102
+ # Extract and parse amount
103
+ transferred_amount_str = (
104
+ tx.get("Transferred Amount") or tx.get("Total Amount including VAT") or ""
105
+ )
106
+ amount: float | None = None
107
+ if transferred_amount_str:
108
+ import re
109
+
110
+ cleaned = re.sub(r"[^\d.]", "", transferred_amount_str)
111
+ if cleaned:
112
+ amount = float(cleaned)
113
+
114
+ transaction_date_str = tx.get("Transaction Date") or ""
115
+ date: datetime | None = None
116
+ if transaction_date_str:
117
+ try:
118
+ date = datetime.fromisoformat(transaction_date_str)
119
+ except ValueError:
120
+ # Try other common formats
121
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%d/%m/%Y %H:%M:%S", "%m/%d/%Y %H:%M:%S"):
122
+ try:
123
+ date = datetime.strptime(transaction_date_str, fmt)
124
+ break
125
+ except ValueError:
126
+ continue
127
+
128
+ result = VerifyResult(
129
+ success=True,
130
+ payer=tx.get("Payer's Name") or tx.get("Source Account Name") or None,
131
+ payer_account=tx.get("Source Account") or tx.get("Payer's Account") or None,
132
+ receiver=tx.get("Receiver's Name") or tx.get("Beneficiary Name") or None,
133
+ receiver_account=tx.get("Receiver's Account") or tx.get("Beneficiary Account") or None,
134
+ amount=amount,
135
+ date=date,
136
+ reference=tx.get("Transaction Reference") or tx.get("Payment Reference") or None,
137
+ reason=tx.get("Narrative") or tx.get("Transaction Type") or None,
138
+ )
139
+
140
+ logger.info(
141
+ "\u2705 Successfully parsed Abyssinia receipt for reference: %s", result.reference
142
+ )
143
+
144
+ # Validate essential fields
145
+ if not result.reference or not result.amount:
146
+ logger.error("\u274c Missing essential fields in transaction data")
147
+ return VerifyResult(success=False, error="Missing essential fields in transaction data")
148
+
149
+ return result
150
+
151
+ except httpx.HTTPError as e:
152
+ logger.error("\u274c HTTP Error fetching Abyssinia receipt: %s", str(e))
153
+ return VerifyResult(success=False, error="Failed to verify Abyssinia transaction")
154
+ except Exception as e:
155
+ logger.error("\u274c Unexpected error in verify_abyssinia: %s", str(e))
156
+ return VerifyResult(success=False, error="Failed to verify Abyssinia transaction")
@@ -0,0 +1,177 @@
1
+ """CBE (Commercial Bank of Ethiopia) payment verification service.
2
+
3
+ Translated from src/services/verifyCBE.ts
4
+ """
5
+
6
+ import io
7
+ import re
8
+ import ssl
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+
12
+ from pypdf import PdfReader
13
+
14
+ from tx_verify.utils.http_client import get_async_client
15
+ from tx_verify.utils.logger import logger
16
+
17
+
18
+ @dataclass
19
+ class VerifyResult:
20
+ """Standard verification result used across multiple services."""
21
+
22
+ success: bool
23
+ payer: str | None = None
24
+ payer_account: str | None = None
25
+ receiver: str | None = None
26
+ receiver_account: str | None = None
27
+ amount: float | None = None
28
+ date: datetime | None = None
29
+ reference: str | None = None
30
+ reason: str | None = None
31
+ error: str | None = None
32
+
33
+
34
+ def _title_case(s: str) -> str:
35
+ return s.title()
36
+
37
+
38
+ def _make_ssl_context() -> ssl.SSLContext:
39
+ """Create an SSL context that does not verify certificates (mirrors rejectUnauthorized: false)."""
40
+ ctx = ssl.create_default_context()
41
+ ctx.check_hostname = False
42
+ ctx.verify_mode = ssl.CERT_NONE
43
+ return ctx
44
+
45
+
46
+ async def verify_cbe(
47
+ reference: str, account_suffix: str = "", *, proxies: str | dict[str, str] | None = None
48
+ ) -> VerifyResult:
49
+ """Verify a CBE transaction by fetching and parsing its PDF receipt.
50
+
51
+ First attempts a direct HTTPS fetch; on failure would fall back to
52
+ a headless browser (not implemented in Python – returns error).
53
+ """
54
+ full_id = f"{reference}{account_suffix}"
55
+ url = f"https://apps.cbe.com.et:100/?id={full_id}"
56
+
57
+ try:
58
+ logger.info("\U0001f50e Attempting direct fetch: %s", url)
59
+ async with get_async_client(
60
+ verify=_make_ssl_context(), timeout=30.0, proxies=proxies
61
+ ) as client:
62
+ response = await client.get(
63
+ url,
64
+ headers={
65
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
66
+ "Accept": "application/pdf",
67
+ },
68
+ )
69
+ response.raise_for_status()
70
+
71
+ logger.info("\u2705 Direct fetch success, parsing PDF")
72
+ return _parse_cbe_receipt(response.content)
73
+
74
+ except Exception as direct_err:
75
+ logger.warning("\u26a0\ufe0f Direct fetch failed: %s", str(direct_err))
76
+ # The TS version falls back to Puppeteer here.
77
+ # We do not bundle a headless browser; return the error.
78
+ return VerifyResult(
79
+ success=False,
80
+ error=f"Direct fetch failed: {direct_err}",
81
+ )
82
+
83
+
84
+ def _parse_cbe_receipt(pdf_bytes: bytes) -> VerifyResult:
85
+ """Extract transaction fields from a CBE PDF receipt."""
86
+ try:
87
+ reader = PdfReader(io.BytesIO(pdf_bytes))
88
+ raw_text = ""
89
+ for page in reader.pages:
90
+ raw_text += page.extract_text() or ""
91
+
92
+ # Normalise whitespace
93
+ raw_text = re.sub(r"\s+", " ", raw_text).strip()
94
+
95
+ payer_match = re.search(r"Payer\s*:?\s*(.*?)\s+Account", raw_text, re.I)
96
+ payer_name = payer_match.group(1).strip() if payer_match else None
97
+
98
+ receiver_match = re.search(r"Receiver\s*:?\s*(.*?)\s+Account", raw_text, re.I)
99
+ receiver_name = receiver_match.group(1).strip() if receiver_match else None
100
+
101
+ account_matches = re.findall(r"Account\s*:?\s*([A-Z0-9]?\*{4}\d{4})", raw_text, re.I)
102
+ payer_account = account_matches[0] if len(account_matches) > 0 else None
103
+ receiver_account = account_matches[1] if len(account_matches) > 1 else None
104
+
105
+ reason_match = re.search(
106
+ r"Reason\s*/\s*Type of service\s*:?\s*(.*?)\s+Transferred Amount",
107
+ raw_text,
108
+ re.I,
109
+ )
110
+ reason = reason_match.group(1).strip() if reason_match else None
111
+
112
+ amount_match = re.search(r"Transferred Amount\s*:?\s*([\d,]+\.\d{2})\s*ETB", raw_text, re.I)
113
+ amount_text = amount_match.group(1) if amount_match else None
114
+
115
+ ref_match = re.search(
116
+ r"Reference No\.?\s*\(VAT Invoice No\)\s*:?\s*([A-Z0-9]+)",
117
+ raw_text,
118
+ re.I,
119
+ )
120
+ reference_val = ref_match.group(1).strip() if ref_match else None
121
+
122
+ date_match = re.search(r"Payment Date & Time\s*:?\s*([\d/,: ]+[APM]{2})", raw_text, re.I)
123
+ date_raw = date_match.group(1).strip() if date_match else None
124
+
125
+ amount = float(amount_text.replace(",", "")) if amount_text else None
126
+ date = _parse_date(date_raw) if date_raw else None
127
+
128
+ if payer_name:
129
+ payer_name = _title_case(payer_name)
130
+ if receiver_name:
131
+ receiver_name = _title_case(receiver_name)
132
+
133
+ if (
134
+ payer_name
135
+ and payer_account
136
+ and receiver_name
137
+ and receiver_account
138
+ and amount
139
+ and date
140
+ and reference_val
141
+ ):
142
+ return VerifyResult(
143
+ success=True,
144
+ payer=payer_name,
145
+ payer_account=payer_account,
146
+ receiver=receiver_name,
147
+ receiver_account=receiver_account,
148
+ amount=amount,
149
+ date=date,
150
+ reference=reference_val,
151
+ reason=reason,
152
+ )
153
+ else:
154
+ return VerifyResult(
155
+ success=False,
156
+ error="Could not extract all required fields from PDF.",
157
+ )
158
+
159
+ except Exception as e:
160
+ logger.error("\u274c PDF parsing failed: %s", str(e))
161
+ return VerifyResult(success=False, error="Error parsing PDF data")
162
+
163
+
164
+ def _parse_date(raw: str) -> datetime | None:
165
+ """Best-effort parse of the date string from CBE receipts."""
166
+ for fmt in (
167
+ "%m/%d/%Y, %I:%M:%S %p",
168
+ "%m/%d/%Y %I:%M:%S %p",
169
+ "%d/%m/%Y, %I:%M:%S %p",
170
+ "%d/%m/%Y %I:%M:%S %p",
171
+ "%Y-%m-%d %H:%M:%S",
172
+ ):
173
+ try:
174
+ return datetime.strptime(raw, fmt)
175
+ except ValueError:
176
+ continue
177
+ return None
@@ -0,0 +1,318 @@
1
+ """CBE Birr payment verification service.
2
+
3
+ Translated from src/services/verifyCBEBirr.ts
4
+ """
5
+
6
+ import io
7
+ import re
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime
10
+
11
+ from pypdf import PdfReader
12
+
13
+ from tx_verify.utils.http_client import get_async_client
14
+ from tx_verify.utils.logger import logger
15
+
16
+
17
+ @dataclass
18
+ class CBEBirrReceipt:
19
+ """CBE Birr receipt data.
20
+
21
+ Core fields that appear consistently on every receipt.
22
+ Variable / optional fields are collected in ``meta``.
23
+ """
24
+
25
+ customer_name: str = ""
26
+ debit_account: str = ""
27
+ credit_account: str = ""
28
+ receiver_name: str = ""
29
+ order_id: str = ""
30
+ transaction_status: str = ""
31
+ receipt_number: str = ""
32
+ transaction_date: str = ""
33
+ amount: str = ""
34
+ paid_amount: str = ""
35
+ service_charge: str = ""
36
+ vat: str = ""
37
+ total_paid_amount: str = ""
38
+ payment_reason: str = ""
39
+ payment_channel: str = ""
40
+ meta: dict = field(default_factory=dict)
41
+
42
+
43
+ @dataclass
44
+ class CBEBirrError:
45
+ success: bool = False
46
+ error: str = ""
47
+
48
+
49
+ async def verify_cbe_birr(
50
+ receipt_number: str, phone_number: str, *, proxies: str | dict[str, str] | None = None
51
+ ) -> CBEBirrReceipt | CBEBirrError:
52
+ """Verify a CBE Birr transaction by fetching and parsing its PDF receipt."""
53
+ try:
54
+ logger.info(
55
+ "[CBEBirr] Starting verification for receipt: %s, phone: %s",
56
+ receipt_number,
57
+ phone_number,
58
+ )
59
+
60
+ url = f"https://cbepay1.cbe.com.et/aureceipt?TID={receipt_number}&PH={phone_number}"
61
+ logger.info("[CBEBirr] Fetching PDF from: %s", url)
62
+
63
+ async with get_async_client(timeout=30.0, proxies=proxies) as client:
64
+ response = await client.get(
65
+ url,
66
+ headers={
67
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
68
+ },
69
+ )
70
+
71
+ logger.info("[CBEBirr] PDF response status: %s", response.status_code)
72
+ logger.info("[CBEBirr] PDF content length: %d bytes", len(response.content))
73
+
74
+ if response.status_code != 200:
75
+ logger.error("[CBEBirr] Failed to fetch PDF: HTTP %s", response.status_code)
76
+ return CBEBirrError(
77
+ success=False, error=f"Failed to fetch receipt: HTTP {response.status_code}"
78
+ )
79
+
80
+ # Parse PDF
81
+ reader = PdfReader(io.BytesIO(response.content))
82
+ pdf_text = ""
83
+ for page in reader.pages:
84
+ pdf_text += page.extract_text() or ""
85
+
86
+ logger.info("[CBEBirr] PDF text extracted (%d characters)", len(pdf_text))
87
+
88
+ receipt = _parse_cbe_birr_receipt(pdf_text)
89
+ if not receipt:
90
+ logger.error("[CBEBirr] Failed to parse receipt data from PDF")
91
+ return CBEBirrError(success=False, error="Failed to parse receipt data from PDF")
92
+
93
+ logger.info("[CBEBirr] Successfully parsed receipt data")
94
+ return receipt
95
+
96
+ except Exception as e:
97
+ logger.error("[CBEBirr] Error during verification: %s", e)
98
+ return CBEBirrError(
99
+ success=False,
100
+ error=str(e) if str(e) else "Unknown error occurred",
101
+ )
102
+
103
+
104
+ def _parse_cbe_birr_receipt(pdf_text: str) -> CBEBirrReceipt | None:
105
+ """Parse CBE Birr receipt fields from extracted PDF text.
106
+
107
+ Uses a robust line-by-line scanner that works with the sparse
108
+ layout produced by pypdf.
109
+ """
110
+ try:
111
+ logger.info("[CBEBirr] Starting PDF text parsing...")
112
+
113
+ lines = [ln.strip() for ln in pdf_text.split("\n")]
114
+
115
+ # ------------------------------------------------------------------
116
+ # Labels that are guaranteed to be structural, never receipt values.
117
+ # ------------------------------------------------------------------
118
+ known_labels = {
119
+ "Commercial Bank of Ethiopia",
120
+ "VAT Invoice/ Customer Receipt",
121
+ "CBEBirr",
122
+ "Company Address & Other Information",
123
+ "Customer Information",
124
+ "Country:",
125
+ "City:",
126
+ "Address:",
127
+ "Postal code:",
128
+ "SWIFT Code:",
129
+ "Email:",
130
+ "Tel:",
131
+ "Fax:",
132
+ "TIN",
133
+ "VAT Invoice No:",
134
+ "VAT Registration No:",
135
+ "VAT Registration Date:",
136
+ "Customer Name:",
137
+ "Region:",
138
+ "Sub city:",
139
+ "Wereda/kebele:",
140
+ "TIN (TAX ID):",
141
+ "Transaction Information",
142
+ "Debit Account",
143
+ "Credit Account",
144
+ "Receiver Name",
145
+ "Order ID",
146
+ "Transaction Status",
147
+ "Reference",
148
+ "Transaction Details",
149
+ "Receipt Number",
150
+ "Transaction Date",
151
+ "Amount",
152
+ "Paid amount",
153
+ "Service Charge",
154
+ "VAT",
155
+ "Total Paid Amount",
156
+ "Total Amount in word",
157
+ "Payment Reason",
158
+ "Payment Channel",
159
+ "Branch:",
160
+ "Tip",
161
+ "The Bank you can always rely on!",
162
+ f"© {datetime.now().year} Commercial Bank of Ethiopia. All rights reserved",
163
+ }
164
+
165
+ def _next_value(start_idx: int) -> str:
166
+ """Return the next non-empty line that is not a known label."""
167
+ j = start_idx
168
+ while j < len(lines):
169
+ val = lines[j].strip()
170
+ if val and val not in known_labels:
171
+ return val
172
+ j += 1
173
+ return ""
174
+
175
+ # ---- individual fields ------------------------------------------------
176
+ customer_name = ""
177
+ debit_account = ""
178
+ credit_account = ""
179
+ receiver_name = ""
180
+ order_id = ""
181
+ transaction_status = ""
182
+ reference = ""
183
+ receipt_number = ""
184
+ transaction_date = ""
185
+ amount = ""
186
+ paid_amount = ""
187
+ service_charge = ""
188
+ vat = ""
189
+ total_paid_amount = ""
190
+ total_amount_in_word = ""
191
+ payment_reason = ""
192
+ payment_channel = ""
193
+ branch = ""
194
+ tip = ""
195
+
196
+ i = 0
197
+ while i < len(lines):
198
+ line = lines[i]
199
+
200
+ # Customer name appears on the line immediately after "Sub city:"
201
+ if line == "Sub city:":
202
+ customer_name = _next_value(i + 1)
203
+
204
+ # Single-line labelled values
205
+ elif line == "Debit Account":
206
+ debit_account = _next_value(i + 1)
207
+
208
+ elif line == "Credit Account":
209
+ credit_account = _next_value(i + 1)
210
+
211
+ elif line == "Receiver Name":
212
+ receiver_name = _next_value(i + 1)
213
+
214
+ elif line == "Order ID":
215
+ order_id = _next_value(i + 1)
216
+
217
+ elif line == "Transaction Status":
218
+ transaction_status = _next_value(i + 1)
219
+
220
+ elif line == "Reference":
221
+ raw_ref = _next_value(i + 1)
222
+ reference = raw_ref.rstrip(":").strip() if raw_ref else ""
223
+
224
+ # Transaction Details block:
225
+ # Receipt Number Transaction Date Amount
226
+ # <receipt> <date> <amount>
227
+ # <paid> <service> <vat> <total>
228
+ # Paid amount Service Charge VAT Total Paid Amount
229
+ elif "Receipt Number" in line and "Transaction Date" in line and "Amount" in line:
230
+ j = i + 1
231
+ vals = []
232
+ while j < len(lines) and len(vals) < 3:
233
+ val = lines[j].strip()
234
+ if val and val not in known_labels:
235
+ vals.append(val)
236
+ j += 1
237
+ if len(vals) >= 3:
238
+ receipt_number = vals[0]
239
+ transaction_date = vals[1]
240
+ amount = vals[2]
241
+
242
+ # Financial breakdown: 4 consecutive numeric values
243
+ fin_vals = []
244
+ while j < len(lines) and len(fin_vals) < 4:
245
+ val = lines[j].strip()
246
+ if re.match(r"^[\d.]+$", val):
247
+ fin_vals.append(val)
248
+ elif val in known_labels:
249
+ break
250
+ j += 1
251
+ if len(fin_vals) >= 4:
252
+ paid_amount = fin_vals[0]
253
+ service_charge = fin_vals[1]
254
+ vat = fin_vals[2]
255
+ total_paid_amount = fin_vals[3]
256
+
257
+ # Bottom block: Total Amount in word, Payment Reason, Payment Channel
258
+ # Each label is followed by a value. The values are collected in order.
259
+ elif line == "Total Amount in word":
260
+ j = i + 1
261
+ vals = []
262
+ while j < len(lines) and len(vals) < 3:
263
+ val = lines[j].strip()
264
+ if val and val not in known_labels:
265
+ vals.append(val)
266
+ j += 1
267
+ if len(vals) >= 3:
268
+ total_amount_in_word = vals[0]
269
+ payment_reason = vals[1]
270
+ payment_channel = vals[2]
271
+
272
+ # Optional fields at the very bottom
273
+ elif line == "Branch:":
274
+ branch = _next_value(i + 1)
275
+
276
+ elif line == "Tip":
277
+ tip = _next_value(i + 1)
278
+
279
+ i += 1
280
+
281
+ # ---- validate we got the essentials ----------------------------------
282
+ if not customer_name and not receipt_number and not amount:
283
+ logger.warning("[CBEBirr] No essential fields found in PDF")
284
+ return None
285
+
286
+ # ---- assemble meta dict for optional/variable fields -----------------
287
+ meta: dict[str, str] = {}
288
+ if reference:
289
+ meta["reference"] = reference
290
+ if total_amount_in_word:
291
+ meta["total_amount_in_word"] = total_amount_in_word
292
+ if branch and branch != "0.00":
293
+ meta["branch"] = branch
294
+ if tip and tip != "0.00":
295
+ meta["tip"] = tip
296
+
297
+ return CBEBirrReceipt(
298
+ customer_name=customer_name,
299
+ debit_account=debit_account,
300
+ credit_account=credit_account,
301
+ receiver_name=receiver_name,
302
+ order_id=order_id,
303
+ transaction_status=transaction_status,
304
+ receipt_number=receipt_number,
305
+ transaction_date=transaction_date,
306
+ amount=amount,
307
+ paid_amount=paid_amount,
308
+ service_charge=service_charge,
309
+ vat=vat,
310
+ total_paid_amount=total_paid_amount,
311
+ payment_reason=payment_reason,
312
+ payment_channel=payment_channel,
313
+ meta=meta,
314
+ )
315
+
316
+ except Exception as e:
317
+ logger.error("[CBEBirr] Error parsing PDF text: %s", e)
318
+ return None