Every code block below is extracted from the real files. Scroll — each rule writes itself across the pipeline: mainframe, then Linux, then PHP.
Same program, three homes: a JCL job step on z/OS, a compiled binary on Linux, a script anywhere PHP runs.
//STEP05 EXEC PGM=CBACT01C //STEPLIB DD DISP=SHR, // DSN=AWS.M2.CARDDEMO.LOADLIB //ACCTFILE DD DISP=SHR, // DSN=AWS.M2.CARDDEMO.ACCTDATA.VSAM.KSDS //OUTFILE DD DSN=AWS.M2.CARDDEMO.ACCTDATA.PSCOMP, // DISP=(NEW,CATLG,DELETE),
cobc -x -std=ibm -I cpy cbl/CBACT01C.cbl -o cbact01c ACCTFILE=ACCTFILE OUTFILE=outfile.dat ./cbact01c
$in = fopen($acctData, 'r');
if (!$in) { fwrite(STDERR, "cannot open $acctData\n"); exit(1); }
$out = fopen($outPath, 'wb');
if (!$out) { fwrite(STDERR, "cannot open $outPath\n"); exit(1); }
$count = 0;
while (($line = fgets($in)) !== false) {
$r = rtrim($line, "\n");
if (strlen($r) < 295) { continue; }COBOL declares the record shape; the runtime decodes zoned decimals for free. PHP has to decode the sign-overpunch by hand - in integer sen, never floats.
01 ACCOUNT-RECORD.
05 ACCT-ID PIC 9(11).
05 ACCT-ACTIVE-STATUS PIC X(01).
05 ACCT-CURR-BAL PIC S9(10)V99.
05 ACCT-CREDIT-LIMIT PIC S9(10)V99.
05 ACCT-CASH-CREDIT-LIMIT PIC S9(10)V99.
01 ACCOUNT-RECORD.
05 ACCT-ID PIC 9(11).
05 ACCT-ACTIVE-STATUS PIC X(01).
05 ACCT-CURR-BAL PIC S9(10)V99.
05 ACCT-CREDIT-LIMIT PIC S9(10)V99.
05 ACCT-CASH-CREDIT-LIMIT PIC S9(10)V99.function zonedToSen(string $s): int {
$pos = ['{' => 0, 'A' => 1, 'B' => 2, 'C' => 3, 'D' => 4, 'E' => 5,
'F' => 6, 'G' => 7, 'H' => 8, 'I' => 9];
$neg = ['}' => 0, 'J' => 1, 'K' => 2, 'L' => 3, 'M' => 4, 'N' => 5,
'O' => 6, 'P' => 7, 'Q' => 8, 'R' => 9];
$last = substr($s, -1);
$sign = 1;
if (isset($pos[$last])) { $digits = substr($s, 0, -1) . $pos[$last]; }
elseif (isset($neg[$last])) { $digits = substr($s, 0, -1) . $neg[$last]; $sign = -1; }
else { $digits = $s; }
return $sign * (int)$digits;
CBACT01C calls COBDATFT, which is z/OS assembler. The Linux hop replaces it with a portable COBOL drop-in (same copybook interface); the PHP hop is three lines.
VALIDIN2 EQU *
* CLC COINPDT+4,C'-'
* BNE GOTOERR
CLI COOUTYPE,C'1'
BE GOTOERR
MVC COOUTDT(4),COINPDT
MVC COOUTDT+4(2),COINPDT+5
MVC COOUTDT+6(2),COINPDT+8
B EXITL WHEN CODATECN-TYPE = '2' AND CODATECN-OUTTYPE = '2'
* YYYY-MM-DD -> YYYYMMDD (asm VALIDIN2)
IF CODATECN-INP-DATE(5:1) NOT = '-' OR
CODATECN-INP-DATE(8:1) NOT = '-'
MOVE 'INVALID INPUT DATE FORMAT' TO
CODATECN-ERROR-MSG
ELSE
MOVE SPACES TO CODATECN-0UT-DATE
MOVE CODATECN-INP-DATE(1:4)
TO CODATECN-0UT-DATE(1:4)
MOVE CODATECN-INP-DATE(6:2)
TO CODATECN-0UT-DATE(5:2)
MOVE CODATECN-INP-DATE(9:2)
TO CODATECN-0UT-DATE(7:2) // COBDATFT equivalent: YYYY-MM-DD -> YYYYMMDD (10-char field, space padded)
$reissueOut = str_pad(
substr($reissue, 0, 4) . substr($reissue, 5, 2) . substr($reissue, 8, 2),
10, ' ');
Buried in paragraph 1300: a zero cycle-debit becomes 2525.00. Every conversion must carry this quirk - the oracle catches it if dropped.
IF ACCT-CURR-CYC-DEBIT EQUAL TO ZERO
MOVE 2525.00 TO OUT-ACCT-CURR-CYC-DEBIT
END-IF.
IF ACCT-CURR-CYC-DEBIT EQUAL TO ZERO
MOVE 2525.00 TO OUT-ACCT-CURR-CYC-DEBIT
END-IF.* - CURR-CYC-DEBIT == 0 -> 2525.00 (business quirk in 1300-) * - money kept as integer sen end-to-end; zoned + COMP-3 encoded by hand.
One output field is COMP-3 (packed BCD). COBOL just declares it; the PHP port must build the nibbles by hand. The oracle proved both produce identical bytes - 50 records, 5,350 bytes, byte-for-byte.
05 OUT-ACCT-CURR-CYC-CREDIT PIC S9(10)V99.
05 OUT-ACCT-CURR-CYC-DEBIT PIC S9(10)V99
USAGE IS COMP-3.
05 OUT-ACCT-GROUP-ID PIC X(10).
05 OUT-ACCT-CURR-CYC-CREDIT PIC S9(10)V99.
05 OUT-ACCT-CURR-CYC-DEBIT PIC S9(10)V99
USAGE IS COMP-3.
05 OUT-ACCT-GROUP-ID PIC X(10).function senToComp3(int $sen): string {
$sign = $sen < 0 ? 'd' : 'c';
$digits = str_pad((string)abs($sen), 12, '0', STR_PAD_LEFT);
return pack('H*', '0' . $digits . $sign); // pad nibble + 12 digits + sign