A batch amortization engine running on IBM z/OS, invoked via zOSMF REST API from a Python CGI layer.
The COBOL program reads loan records from a sequential dataset, validates each record, computes a standard amortization schedule with optional extra principal payments, and emits HTML output via SYSOUT. A Python CGI script submits the job to z/OS via the zOSMF REST API, polls for completion, intercepts the spool output, injects a stylesheet, and serves the result directly to the browser.
Submits MORTWEB batch job > polls every 2s > streams amortization table
Run ->
The Python layer handles all orchestration: submitting JCL inline (no dataset indirection),
polling status == "OUTPUT",
locating the LOANOUT DD among spool files,
stripping fixed-length padding from the 256-byte COBOL output records, and splicing
a stylesheet into the COBOL-generated <head>.
| Paragraph | Responsibility | Notes |
|---|---|---|
| MAIN | PARM parsing, file open/close, read loop | PARM(1:4) selects TEXT or HTML mode |
| VALIDATE-INPUT | Numeric checks, range validation | Sets REC-OK / REC-BAD condition names |
| PROCESS-LOAN | Payment formula, amortization loop | APR=0 handled as interest-free loan |
| WRITE-HEADER | HTML/TEXT page header | Dual-format branching throughout |
| WRITE-PAYMENT-LINE | Per-payment row output | 9 edited fields; tracks cumulative interest |
| WRITE-ROI-SUMMARY | Extra principal ROI calculation | Interest saved / extra paid * 100 |
| WRITE-RUN-METADATA | User ID, date, time footer | Populated from PARM and CURRENT-DATE |
The COBOL uses CBL ARITH(EXTEND) for 31-digit intermediate
precision, which matters when computing
(1 + r) ** -N in the payment formula.
All monetary fields carry implied decimal via V99
and are edited into ZZZ,ZZZ,ZZ9.99 pictures for output,
no floating point anywhere in the pipeline.
Output mode is selected at runtime via JCL PARM:
PARM='HTML/WEBUSER'
sets positions 1-4 to the format token and positions 6+ to the user ID logged in the
run metadata footer. The Python CGI injects CSS by string-replacing
</head> in the raw spool text, no parsing required.
Extra principal payments short-circuit the amortization loop: the loop terminates when
WS-BALANCE <= 0.01 rather than at the scheduled
term end, and the ROI summary is only emitted when extra payments were actually applied.
CBL ARITH(EXTEND) bumps intermediate arithmetic precision from 18 to 31
digits. Without it, the exponentiation in the payment formula:
(1 + r) ** -N loses enough precision on long-term loans to produce
noticeably wrong figures. It costs nothing at runtime; it only affects how the
compiler emits the arithmetic instructions.
CBL ARITH(EXTEND)
IDENTIFICATION DIVISION.
PROGRAM-ID. MORTGAGE.
PIC clause to a
fixed byte offset with no delimiters. 9(7)V99 means 7 integer digits
plus 2 implied decimal places stored as pure digits (no decimal point in the file).
FILLER absorbs the 6 trailing bytes that are not used, keeping the
record exactly 80 bytes wide.
FD LOAN-FILE
RECORD CONTAINS 80 CHARACTERS
RECORDING MODE IS F.
01 LOAN-REC.
05 LOAN-TITLE PIC X(40).
05 LOAN-AMOUNT PIC 9(7)V99.
05 APR PIC 9(2)V99.
05 LOAN-TERM PIC 9(3).
05 ADDITIONAL-PRINCIPAL PIC 9(7)V99.
05 FILLER PIC X(6).
<tr> row with all nine edited numeric fields and their tags.
Each WRITE emits exactly one 256-byte fixed-length record to the spool;
the Python CGI later strips the trailing spaces that pad short rows to fill the width.
FD OUT-FILE
RECORD CONTAINS 256 CHARACTERS
RECORDING MODE IS F.
01 OUT-REC PIC X(256).
V99 implied decimal, the
decimal point exists logically but is never stored. WS-MONTHLY-RATE
uses 9V9(6) (one integer digit, six decimal places) because a monthly
rate like 0.004167 needs that precision to avoid compounding rounding errors over
360 payments. WS-BEGIN-BALANCE and WS-ACTUAL-EXTRA are
separate from the running WS-BALANCE so each output row can show both
the opening balance and the exact extra amount applied that month.
01 WS-PRINCIPAL-ORIG PIC 9(9)V99 VALUE 0.
01 WS-BALANCE PIC 9(9)V99 VALUE 0.
01 WS-MONTHLY-RATE PIC 9V9(6) VALUE 0.
01 WS-PAYMENT PIC 9(9)V99 VALUE 0.
01 WS-INTEREST PIC 9(9)V99 VALUE 0.
01 WS-PRIN-PAY PIC 9(9)V99 VALUE 0.
01 WS-EXTRA-PRINCIPAL PIC 9(9)V99 VALUE 0.
01 WS-BASE-PRIN PIC 9(9)V99 VALUE 0.
01 WS-ACTUAL-EXTRA PIC 9(9)V99 VALUE 0.
01 WS-BEGIN-BALANCE PIC 9(9)V99 VALUE 0.
ZZZ,ZZZ,ZZ9.99 exist solely for output
formatting. The Z suppresses leading zeros (replacing them with
spaces), the commas and period are inserted literally, and the trailing
9.99 guarantees at least one digit before the decimal and always two
after. You MOVE a raw numeric into an edited field and COBOL formats
it automatically: no sprintf equivalent needed.
01 WS-PAY-NO-EDIT PIC Z(4).
01 WS-BEGIN-BAL-EDIT PIC ZZZ,ZZZ,ZZ9.99.
01 WS-SCHED-PAY-EDIT PIC ZZZ,ZZZ,ZZ9.99.
01 WS-EXTRA-PAY-EDIT PIC ZZZ,ZZZ,ZZ9.99.
01 WS-TOTAL-PAY-EDIT PIC ZZZ,ZZZ,ZZ9.99.
01 WS-PRIN-PAY-EDIT PIC ZZZ,ZZZ,ZZ9.99.
01 WS-INT-EDIT PIC ZZZ,ZZZ,ZZ9.99.
01 WS-END-BAL-EDIT PIC ZZZ,ZZZ,ZZ9.99.
01 WS-CUM-INT-EDIT PIC ZZZ,ZZZ,ZZ9.99.
88 level items are condition names: essentially named boolean tests
against the parent field. SET FMT-HTML TO TRUE simply moves
'HTML' into WS-OUTPUT-FORMAT, and IF FMT-HTML
tests whether it equals 'HTML'. This is COBOL's idiomatic way of
avoiding magic string comparisons scattered throughout the code. The format is set
once from the JCL PARM and then tested in every output paragraph.
01 WS-OUTPUT-FORMAT PIC X(4) VALUE 'TEXT'.
88 FMT-TEXT VALUE 'TEXT'.
88 FMT-HTML VALUE 'HTML'.
PARM-LEN is a S9(4) COMP (signed 16-bit binary) holding
the byte count of what follows. Positions 1-4 carry the format token
(TEXT or HTML) and positions 6 onward carry the user ID
logged in the run metadata footer , the slash in PARM='HTML/WEBUSER'
sits at position 5 and is simply skipped over. The main loop is a
PERFORM UNTIL EOF that reads one loan record at a time and dispatches
to PROCESS-LOAN; the AT END clause flips the
EOF condition name to exit cleanly.
MAIN.
IF PARM-LEN > 0
MOVE PARM-TEXT(1:4) TO WS-OUTPUT-FORMAT
IF PARM-LEN > 5
MOVE PARM-TEXT(6:PARM-LEN - 5)
TO WS-USER-ID
ELSE
MOVE "UNKNOWN" TO WS-USER-ID
END-IF
ELSE
MOVE "TEXT" TO WS-OUTPUT-FORMAT
MOVE "UNKNOWN" TO WS-USER-ID
END-IF
MOVE FUNCTION CURRENT-DATE TO WS-CD
OPEN INPUT LOAN-FILE
OUTPUT OUT-FILE
PERFORM UNTIL EOF
READ LOAN-FILE
AT END
MOVE 'Y' TO EOF-LOAN-FILE
NOT AT END
PERFORM PROCESS-LOAN
END-READ
END-PERFORM
CLOSE LOAN-FILE OUT-FILE
STOP RUN.
IF REC-OK guards so a field is only
checked if all previous fields passed, this prevents misleading cascading errors
from a single bad record. IS NUMERIC is a built-in COBOL class test
that verifies every byte in a field is a valid digit. APR is allowed to be zero
(interest-free loan handled downstream). Extra principal is allowed to be zero,
which simply means no extra payments are made and no ROI summary is emitted.
VALIDATE-INPUT.
SET REC-OK TO TRUE
MOVE SPACES TO WS-ERROR-REASON
IF NOT LOAN-AMOUNT IS NUMERIC
SET REC-BAD TO TRUE
MOVE "NON-NUMERIC LOAN AMOUNT" TO WS-ERROR-REASON
ELSE
IF LOAN-AMOUNT <= 0
SET REC-BAD TO TRUE
MOVE "LOAN AMOUNT <= 0" TO WS-ERROR-REASON
END-IF
END-IF
IF REC-OK
IF NOT LOAN-TERM IS NUMERIC ...
END-IF
IF REC-OK
IF NOT APR IS NUMERIC ...
ELSE IF APR < 0 ...
END-IF
IF REC-OK
IF NOT ADDITIONAL-PRINCIPAL IS NUMERIC ...
ELSE IF ADDITIONAL-PRINCIPAL < 0 ...
END-IF.
P * r / (1 - (1 + r)^-N) where P is principal,
r is the monthly rate (APR / 1200), and N is total
payments. The zero-APR branch avoids division-by-zero: if there is no interest,
the payment is simply principal divided by the number of months.
WS-TOTAL-INT-NO-EXTRA is computed up front from the scheduled payment
and term, this is the baseline the ROI calculation later subtracts from to find
how much interest was saved.
COMPUTE WS-MONTHLY-RATE ROUNDED = APR / 1200
IF WS-MONTHLY-RATE = 0
COMPUTE WS-PAYMENT ROUNDED =
WS-PRINCIPAL-ORIG / WS-N-PAY
ELSE
COMPUTE WS-PAYMENT ROUNDED =
WS-PRINCIPAL-ORIG * WS-MONTHLY-RATE /
(1 - (1 + WS-MONTHLY-RATE) ** (-WS-N-PAY))
END-IF
COMPUTE WS-TOTAL-INT-NO-EXTRA ROUNDED =
(WS-PAYMENT * WS-N-PAY) - WS-PRINCIPAL-ORIG
OR: the balance falling
below a penny (<= 0.01) or the payment counter exceeding the
scheduled term. Without extra payments only the counter condition ever fires. With
extra principal the balance shrinks faster than scheduled, so the balance condition
fires first, the loan pays off early and the loop exits before reaching
WS-N-PAY. WS-BEGIN-BALANCE is snapshotted at the top of
each iteration so the output row shows the opening balance, while
WS-BALANCE is updated by SUBTRACT at the bottom and
shows the ending balance.
PERFORM VARYING WS-PAY-NO FROM 1 BY 1
UNTIL WS-BALANCE <= 0.01
OR WS-PAY-NO > WS-N-PAY
MOVE WS-BALANCE TO WS-BEGIN-BALANCE
COMPUTE WS-INTEREST ROUNDED =
WS-BALANCE * WS-MONTHLY-RATE
COMPUTE WS-BASE-PRIN ROUNDED =
WS-PAYMENT - WS-INTEREST
IF WS-BASE-PRIN > WS-BALANCE
MOVE WS-BALANCE TO WS-BASE-PRIN
END-IF
ADD WS-INTEREST TO WS-TOTAL-INT-WITH-EXTRA
ADD WS-INTEREST TO WS-CUM-INTEREST
SUBTRACT WS-PRIN-PAY FROM WS-BALANCE
PERFORM WRITE-PAYMENT-LINE
END-PERFORM
FUNCTION MIN clamps the extra payment to whatever principal remains
after the scheduled portion. Without this, on the final payment the extra amount
could overshoot, paying more than the remaining balance. The subsequent
IF WS-ACTUAL-EXTRA < 0 guard is belt-and-suspenders: if the
balance were somehow smaller than the scheduled principal portion,
MIN would return negative, which we zero out rather than subtract.
IF WS-EXTRA-PRINCIPAL > 0
COMPUTE WS-ACTUAL-EXTRA ROUNDED =
FUNCTION MIN(WS-EXTRA-PRINCIPAL,
WS-BALANCE - WS-BASE-PRIN)
IF WS-ACTUAL-EXTRA < 0
MOVE 0 TO WS-ACTUAL-EXTRA
END-IF
ADD WS-ACTUAL-EXTRA TO WS-PRIN-PAY
ADD WS-ACTUAL-EXTRA TO WS-TOTAL-EXTRA-PAID
END-IF
WS-ROI-PCT expresses the interest saving as a
percentage of the total extra cash put in: a genuine return-on-investment figure.
IF WS-EXTRA-PRINCIPAL > 0
AND WS-TOTAL-EXTRA-PAID > 0
COMPUTE WS-INTEREST-SAVED ROUNDED =
WS-TOTAL-INT-NO-EXTRA - WS-TOTAL-INT-WITH-EXTRA
COMPUTE WS-ROI-PCT ROUNDED =
(WS-INTEREST-SAVED / WS-TOTAL-EXTRA-PAID) * 100
PERFORM WRITE-ROI-SUMMARY
END-IF
MOVE to an edited field triggers
COBOL's built-in formatting: zero suppression, comma insertion, and decimal
alignment all happen implicitly. WS-SCHED-PAY-EDIT receives
WS-BASE-PRIN (scheduled principal only, not including extra), while
WS-TOTAL-PAY-EDIT receives the full recomputed payment. This is what
produces the separate Scheduled and Total payment columns in the output table.
WRITE-PAYMENT-LINE.
MOVE WS-PAY-NO TO WS-PAY-NO-EDIT
MOVE WS-BEGIN-BALANCE TO WS-BEGIN-BAL-EDIT
MOVE WS-BASE-PRIN TO WS-SCHED-PAY-EDIT
MOVE WS-ACTUAL-EXTRA TO WS-EXTRA-PAY-EDIT
MOVE WS-PAYMENT TO WS-TOTAL-PAY-EDIT
MOVE WS-PRIN-PAY TO WS-PRIN-PAY-EDIT
MOVE WS-INTEREST TO WS-INT-EDIT
MOVE WS-BALANCE TO WS-END-BAL-EDIT
MOVE WS-CUM-INTEREST TO WS-CUM-INT-EDIT
WS-USER-ID comes from the JCL PARM (positions 6 onward), so whoever
submits the job is recorded in the output. WS-CD is populated at
startup via MOVE FUNCTION CURRENT-DATE TO WS-CD the
CURRENT-DATE intrinsic function returns a 21-character string broken
into date, time, hundredths, and UTC offset subfields by the group item layout.
This gives each report a timestamp tied to the batch job execution rather than a
user-supplied value.
WRITE-RUN-METADATA.
STRING
"Run by: " DELIMITED BY SIZE
WS-USER-ID DELIMITED BY SIZE
" Date: " DELIMITED BY SIZE
WS-CD-DATE DELIMITED BY SIZE
" Time: " DELIMITED BY SIZE
WS-CD-TIME DELIMITED BY SIZE
INTO WS-LINE-OUT
END-STRING
MOVE WS-LINE-OUT TO OUT-REC
WRITE OUT-REC.