"""posttran.py - a Python port of CardDemo's CBTRN02C (the POSTTRAN batch step). One component, ported from the COBOL the discovery phase read, with the characterization fixture (tools/characterize.mjs) as the only oracle. The port is deliberately literal: the paragraph names of CBTRN02C are kept as comments so a reviewer can read the two side by side. Inputs are plain dicts shaped like the engine's copybook-decoded rows (CVTRA06Y daily transaction, CVACT03Y card cross-reference, CVACT01Y account, CVTRA01Y transaction-category balance); money is decimal.Decimal. Known deviations from the COBOL (documented, not hidden): * numeric overflow: an ADD without ON SIZE ERROR truncates high-order digits on the mainframe; the port raises OverflowError instead (never silent). * a duplicate TRAN-ID on the output KSDS ends the COBOL run with file status 22 and an abend; the port raises KeyError. """ from dataclasses import dataclass, field from decimal import Decimal # 1500-A/B validation reasons (WS-VALIDATION-FAIL-REASON + -DESC) REASONS = { 100: "INVALID CARD NUMBER FOUND", 101: "ACCOUNT RECORD NOT FOUND", 102: "OVERLIMIT TRANSACTION", 103: "TRANSACTION RECEIVED AFTER ACCT EXPIRATION", } # PICTURE limits the COBOL would truncate at (documented deviation: we raise) _LIMIT_S9_09_V99 = Decimal("9999999999.99") - Decimal("9000000000.00") # 9(09)V99 -> 999999999.99 _LIMIT_S9_10_V99 = Decimal("9999999999.99") def D(v): """The engine's native number (JSON number or exact decimal string) -> Decimal.""" if isinstance(v, Decimal): return v if v is None or v == "": return Decimal(0) return Decimal(str(v)) def _check(value, limit, what): if abs(value) > limit: raise OverflowError(f"{what} overflows its PICTURE ({value}); COBOL would truncate silently") return value @dataclass class Result: transactions: list = field(default_factory=list) # TRAN-RECORD rows written to TRANSACT (CVTRA05Y) rejects: list = field(default_factory=list) # (daily index, reason, description) accounts: dict = field(default_factory=dict) # ACCT-ID -> account row after the run tcatbal: dict = field(default_factory=dict) # (acct, type, cat) -> balance row after the run sysout: list = field(default_factory=list) # the DISPLAY lines, in order processed: int = 0 rejected: int = 0 return_code: int = 0 def proc_ts_from_clock(clock): """Z-GET-DB2-FORMAT-TIMESTAMP under a pinned clock 'YYYY-MM-DD HH:MM:SS' -> 'YYYY-MM-DD-HH.MM.SS.000000'.""" d, t = clock.strip().split(" ") return f"{d}-{t.replace(':', '.')}.000000" def tcat_key(acct_id, type_cd, cat_cd): """FD-TRAN-CAT-KEY: 9(11) account, X(02) type, 9(04) category - as the KSDS key string.""" return f"{int(acct_id):011d}{str(type_cd):<2.2}{int(cat_cd):04d}" def post_transactions(daily, xref, accounts, tcatbal, proc_ts): """Run the posting over `daily` (CVTRA06Y rows, in file order). xref: iterable of CVACT03Y rows (card number -> customer, account) accounts: iterable of CVACT01Y rows (the account master, I-O) tcatbal: iterable of CVTRA01Y rows (transaction-category balances, I-O) proc_ts: the TRAN-PROC-TS the run stamps (the pinned clock) """ xref_by_card = {str(r["XREF-CARD-NUM"]).strip(): r for r in xref} acct_by_id = {int(D(r["ACCT-ID"])): dict(r) for r in accounts} bal_by_key = {} for r in tcatbal: k = r.get("TRAN-CAT-KEY", r) bal_by_key[tcat_key(k["TRANCAT-ACCT-ID"], k["TRANCAT-TYPE-CD"], k["TRANCAT-CD"])] = dict(r) out = Result(accounts=acct_by_id, tcatbal=bal_by_key) out.sysout.append("START OF EXECUTION OF PROGRAM CBTRN02C") written = {} for i, tran in enumerate(daily): # PERFORM UNTIL END-OF-FILE out.processed += 1 # ADD 1 TO WS-TRANSACTION-COUNT reason = 0 amt = D(tran["DALYTRAN-AMT"]) card = str(tran["DALYTRAN-CARD-NUM"]).strip() # 1500-A-LOOKUP-XREF x = xref_by_card.get(card) if x is None: reason = 100 else: # 1500-B-LOOKUP-ACCT acct = acct_by_id.get(int(D(x["XREF-ACCT-ID"]))) if acct is None: reason = 101 else: temp_bal = D(acct["ACCT-CURR-CYC-CREDIT"]) - D(acct["ACCT-CURR-CYC-DEBIT"]) + amt if not (D(acct["ACCT-CREDIT-LIMIT"]) >= temp_bal): reason = 102 # the second IF runs after the first: 103 overrides 102 (as the COBOL does) orig_date = str(tran["DALYTRAN-ORIG-TS"])[:10] if not (str(acct["ACCT-EXPIRAION-DATE"]) >= orig_date): reason = 103 if reason == 0: # 2000-POST-TRANSACTION rec = { "TRAN-ID": tran["DALYTRAN-ID"], "TRAN-TYPE-CD": tran["DALYTRAN-TYPE-CD"], "TRAN-CAT-CD": int(D(tran["DALYTRAN-CAT-CD"])), "TRAN-SOURCE": tran["DALYTRAN-SOURCE"], "TRAN-DESC": tran["DALYTRAN-DESC"], "TRAN-AMT": amt, "TRAN-MERCHANT-ID": int(D(tran["DALYTRAN-MERCHANT-ID"])), "TRAN-MERCHANT-NAME": tran["DALYTRAN-MERCHANT-NAME"], "TRAN-MERCHANT-CITY": tran["DALYTRAN-MERCHANT-CITY"], "TRAN-MERCHANT-ZIP": tran["DALYTRAN-MERCHANT-ZIP"], "TRAN-CARD-NUM": card, "TRAN-ORIG-TS": tran["DALYTRAN-ORIG-TS"], "TRAN-PROC-TS": proc_ts, } # 2700-UPDATE-TCATBAL key = tcat_key(x["XREF-ACCT-ID"], tran["DALYTRAN-TYPE-CD"], tran["DALYTRAN-CAT-CD"]) bal = bal_by_key.get(key) if bal is None: out.sysout.append(f"TCATBAL record not found for key : {key}.. Creating.") bal = {"TRAN-CAT-KEY": {"TRANCAT-ACCT-ID": int(D(x["XREF-ACCT-ID"])), "TRANCAT-TYPE-CD": tran["DALYTRAN-TYPE-CD"], "TRANCAT-CD": int(D(tran["DALYTRAN-CAT-CD"]))}, "TRAN-CAT-BAL": Decimal(0)} bal_by_key[key] = bal bal["TRAN-CAT-BAL"] = _check(D(bal["TRAN-CAT-BAL"]) + amt, _LIMIT_S9_09_V99, "TRAN-CAT-BAL") # 2800-UPDATE-ACCOUNT-REC acct["ACCT-CURR-BAL"] = _check(D(acct["ACCT-CURR-BAL"]) + amt, _LIMIT_S9_10_V99, "ACCT-CURR-BAL") if amt >= 0: acct["ACCT-CURR-CYC-CREDIT"] = _check(D(acct["ACCT-CURR-CYC-CREDIT"]) + amt, _LIMIT_S9_10_V99, "ACCT-CURR-CYC-CREDIT") else: acct["ACCT-CURR-CYC-DEBIT"] = _check(D(acct["ACCT-CURR-CYC-DEBIT"]) + amt, _LIMIT_S9_10_V99, "ACCT-CURR-CYC-DEBIT") # 2900-WRITE-TRANSACTION-FILE (TRANSACT is opened OUTPUT: only this run's records) if rec["TRAN-ID"] in written: raise KeyError(f"duplicate TRAN-ID {rec['TRAN-ID']} (COBOL: file status 22, abend)") written[rec["TRAN-ID"]] = rec else: # 2500-WRITE-REJECT-REC out.rejected += 1 out.rejects.append((i, reason, REASONS[reason])) out.transactions = [written[k] for k in sorted(written)] # a KSDS reads back in key order out.sysout.append(f"TRANSACTIONS PROCESSED :{out.processed:09d}") out.sysout.append(f"TRANSACTIONS REJECTED :{out.rejected:09d}") if out.rejected > 0: out.return_code = 4 # MOVE 4 TO RETURN-CODE out.sysout.append("END OF EXECUTION OF PROGRAM CBTRN02C") return out def reject_record_bytes(daily_record_bytes, reason, description): """FD-REJS-RECORD (LRECL 430): the 350-byte daily record as read, then the trailer WS-VALIDATION-FAIL-REASON 9(04) + -DESC X(76), EBCDIC (CCSID 37).""" if len(daily_record_bytes) != 350: raise ValueError("a DALYTRAN record is 350 bytes") trailer = f"{reason:04d}{description:<76.76}".encode("cp037") return daily_record_bytes + trailer