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 +39 -0
- tx_verify/services/__init__.py +0 -0
- tx_verify/services/verify_abyssinia.py +156 -0
- tx_verify/services/verify_cbe.py +177 -0
- tx_verify/services/verify_cbe_birr.py +318 -0
- tx_verify/services/verify_dashen.py +253 -0
- tx_verify/services/verify_image.py +156 -0
- tx_verify/services/verify_mpesa.py +275 -0
- tx_verify/services/verify_telebirr.py +305 -0
- tx_verify/services/verify_universal.py +138 -0
- tx_verify/utils/__init__.py +15 -0
- tx_verify/utils/error_handler.py +59 -0
- tx_verify/utils/http_client.py +355 -0
- tx_verify/utils/logger.py +74 -0
- tx_verify-0.1.0.dist-info/METADATA +381 -0
- tx_verify-0.1.0.dist-info/RECORD +17 -0
- tx_verify-0.1.0.dist-info/WHEEL +4 -0
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
|