Demo · CardDemo
A mainframe credit card system, modernized one slice at a time.
CardDemo is a COBOL mainframe application that AWS publishes for testing modernization tools. We took three of its batch jobs (transaction posting, monthly interest and the daily transaction report) and two of its online screens (viewing and updating an account), rewrote them in Java, and proved the new versions produce exactly the same results as the original. Along the way, the tests turned up business rules that deserve a second look.
- slices complete
- 3 of 6
- posting transactions tested
- 309
- interest charges tested
- 108
- report lines compared
- 1,596
- differences in output
- 0
- screens compared
- 465
- business findings
- 21
1 · The system
What CardDemo is
CardDemo simulates the back office of a credit card company. AWS built it as a realistic mainframe application for trying out analysis and migration tools, and publishes it under the Apache 2.0 license (source on GitHub).
What it does
- Online, during the day: staff sign on at green-screen terminals to view and update accounts, manage credit cards, look up and add transactions, take bill payments and request reports. Administrators manage users.
- Batch, at night: a chain of jobs posts the day's card transactions to the accounts, calculates interest, and produces statements.
How big it is
- 44 COBOL programs, about 22,900 lines
- 17 online programs behind 21 terminal screens
- 14 batch programs run by 47 JCL jobs and procedures
- 13 VSAM and sequential data files, laid out by 62 copybooks
- Optional modules that add Db2, IMS and MQ
2 · Today and the proposed design
How it is built, and where it is going
Everything runs on the mainframe today. The proposed design moves it to AWS one piece at a time, using the approach in our mainframe template.
The nightly batch cycle
- Post transactions
Job POSTTRAN checks each of the day's card transactions and posts it to the account, or rejects it.
- Calculate interest
Job INTCALC charges monthly interest by account and spending category.
- Back up
Job TRANBKP copies the transaction file and recreates it.
- Merge
Job COMBTRAN sorts the backup and the interest charges back into one file.
- Statements
Job CREASTMT writes account statements as text and HTML.
The slice plan
Batch first: a batch job reads files and writes files, so old and new can be compared record by record. The screens come once the data and test harness exist.
| Slice | What moves | Size | Status |
|---|---|---|---|
| 1 | Daily posting (POSTTRAN) | 1 program, 619 lines | Complete: proven identical |
| 2 | Interest calculation (INTCALC) | 1 program, 552 lines | Complete: proven identical |
| 3 | Transaction report (TRANREPT) | 1 program, 545 lines | Complete: proven identical |
| 4 | Statements (CREASTMT) | 2 programs, 946 lines | Next |
| 5 | Online account, card and transaction screens | 9 programs, 8,794 lines | Planned |
| 6 | Sign-on and user administration | 7 programs, 1,857 lines | Planned: replaced by Amazon Cognito |
3 · Slice 1
What we did, step by step
The daily posting job reads the day's card transactions and, for each one, either posts it to the account or rejects it with a reason code: unknown card (100), unknown account (101), over the credit limit (102) or account expired (103).
-
Inventory the system
We read every program, job and copybook, mapped which programs read and write which files, and listed 12 risks with file and line references. POSTTRAN came out as the best first slice: one 619-line program, clear inputs and outputs, and sample data included.
-
Run the original and keep its results
No mainframe needed for this step: the unchanged COBOL program runs on Linux with GnuCOBOL, an open-source COBOL compiler. The harness loads CardDemo's sample data into indexed files the way VSAM holds them, pins the clock so every run is repeatable, runs the job, and saves every output file as the reference.
-
Catch a silent error, in our own first run
The first run looked fine: 257 posted, 43 rejected, a normal end. It was wrong. Mainframes store the sign of an amount inside its last digit (a
Gthere means +7, a}means −0), and the compiler had not been told. It read $504.77 as $504.70 and a $919.00 refund as a $919.00 purchase. A newer compiler version misread it differently and rejected 299 of 300 transactions.With the sign convention set, both compiler versions agree byte for byte: 262 posted, 38 rejected. Every run is now also checked against an independent model of the posting rules written in Python, which fails on the wrong result. This is exactly the kind of error a conversion can introduce without anyone noticing.
-
Fill the gaps in the test data
The sample data only ever triggers one of the four rejection rules (over the limit). We added nine crafted transactions: an unknown card, a card pointing at a missing account, transactions dated on and after an account's expiry date, a purchase landing exactly on the credit limit, one cent over it, and a refund. All four rules and their boundaries are now tested.
-
Write the Java version
A Java 21 program that reads and writes the same record files as the COBOL. It follows the original's rules exactly, including details that are easy to miss: which rejection code wins when two rules fail, and leftover bytes the COBOL carries from one record into the next. Any change in behavior sits behind an explicit switch, off by default.
-
Compare old and new
Test data Transactions Posted Rejected Result CardDemo sample 300 262 38 Identical Edge cases 309 265 44 Identical Identical means every record in every output file (posted transactions, rejects, accounts and category balances) and every line the program prints. The only field set aside is the fraction of a second in the processing timestamp, which comes from the real clock.
-
Report what the tests found
The credit-limit check adds up the cycle's purchases and subtracts its refunds. Refunds are stored as negative amounts, so subtracting them adds them back: a refund lowers the balance but counts as extra spending against the limit. After a $50 refund, one test account had $2,070 counted against a $2,020 limit.
It already affects the published sample data: 17 of the 38 rejections happen only because of it. Switching on the fix changes 19 decisions, not 17: the 17 transactions now post, and 2 later ones on the same accounts are rejected because the extra purchases use up the limit first.
The Java version keeps the original behavior. Whether refunds should count against the limit is a business decision, so the fix stays off until it is approved, with the list of the 19 transactions it changes.
4 · Slice 2
Interest calculation
Job INTCALC charges interest each month. For every account it looks up an interest rate for each kind of balance (purchases, cash advances, credits and so on) and writes one interest charge per balance: the balance times the yearly rate, divided by 12. The total is added to the account.
-
Run it after posting, the way the nightly batch does
CardDemo's published balances are all zero, so on its own the job would only ever charge $0.00. We fed it the balances that slice 1's posting run produced, which is the order the jobs run in. It reads 100 balances across 50 accounts and writes 50 interest charges totaling $1,279.16.
-
Reuse the harness
Same compiler settings, same fixed clock, same checks. Two small stand-ins replace mainframe services: one passes the job's run date the way the mainframe does (it becomes part of every transaction ID), the other stands in for the routine that stops a job on an error. An independent Python model of the interest rules checks every charge, every account and every printed line.
-
Confirm the defects the inventory predicted
Reading the program during the inventory had flagged two defects before anything ran. The run confirmed both:
- The last account never gets its interest. Each account is updated when the next one starts, and the job ends before the last one gets its turn. Its $18.77 charge is written, but its balance never includes it, and its monthly totals are never reset.
- Interest is cut off, not rounded. $103.99 at 15% a year is $1.299875 for the month, charged as $1.29. 28 of the 50 charges are a cent short.
It also showed smaller behaviors the business should know about: a zero balance still produces a $0.00 charge, a credit balance earns negative interest, and fees are never calculated (that part of the program is empty).
-
Test the cases the sample never reaches
Every account in the sample uses the default rates. We added six accounts: one with its own rates (15% on purchases, 25% on cash advances, nothing on credits), one at 0%, a credit balance, a rate table missing a category, an unknown rate group, and the rounding case above. The expected result for each was worked out by hand before the run.
-
Compare old and new
Test data Balances Interest charges Total interest Result CardDemo, after posting 100 50 $1,279.16 Identical Edge cases 110 58 $1,323.78 Identical Identical again means every interest charge, every account record and every line the program prints.
-
Show exactly what each fix would change
Both fixes are written and switched off. Run against the same data, the last-account fix changes one account (balance up $18.77, monthly totals reset), and rounding raises 28 charges by one cent, $0.28 in total.
As with refunds in slice 1, the Java version keeps the original behavior. Correcting the last account is almost certainly wanted; rounding changes what customers are charged, so it needs a business decision. Either way, the list of affected accounts comes with it.
5 · Slice 3
Daily transaction report
Job TRANREPT sorts the day's transactions by card and prints them: one line per transaction with its type and category, a total after each card, a total at the foot of each page and a grand total at the end. It is also the report users request from the online Reports screen.
-
Run it last, on what posting and interest wrote
As in the nightly batch, the posted transactions and the interest charges are merged into the transaction file first, then the report runs on them: 312 transactions on 50 cards. The job's own dates (January to early July 2022) are older than our pinned run date, so every transaction would be filtered out; we report on the run date instead.
-
Compare a printed report, line by line
The output is a report, not data records, so every line is compared in full, all 133 characters including trailing blanks, along with everything the program prints. The job's sort step, which runs on the mainframe's sort utility, is reproduced by a small script; the report program itself runs unchanged. An independent model of the report checks every line.
-
Report what the tests found
This program had the most to show:
- The grand total is wrong. At the end of the file the program adds the last transaction a second time: $79,254.29 instead of $79,233.86. The difference, $20.43, is exactly the last transaction.
- The last card's total is never printed. 49 card totals for 50 cards.
- Pages run on. Pages are meant to be 20 lines, but 8 of the 18 pages run to 40 or 42 lines.
- "Account Total" is really a card total. An account with two cards gets two totals.
- The report can stop early and still report success. The date range is set in two places, the sort step and a date file. If they disagree, the report stops at the first transaction outside the range, with no page total and no grand total, and the job still ends normally. The online Reports screen writes the same dates into both; running the job directly does not.
-
Test the cases the data never reaches
Six extra transactions on two extra cards: a second card for an existing account, the largest amount the report can print ($123,456,789.01), a negative adjustment, a zero amount (it prints as blanks), and a transaction from the day before, which the sort step filters out. A third run widens the sort step's dates so that one transaction gets through to the program, to show the early stop.
-
Compare old and new
Test data Transactions Report lines Result Nightly data 312 519 Identical Edge cases 317 540 Identical Dates out of step 318 537, no totals Identical: Java stops at the same line Two compiler versions, GnuCOBOL 3.1.2 and 3.2, produce byte-identical reports.
-
Show exactly what each fix would change
Three fixes are written and switched off. Counting the last transaction once and printing the last card's total gives a grand total of $79,233.86, exactly posted plus interest. Breaking pages at 20 lines gives 25 even pages with the same totals. Skipping a transaction outside the dates, instead of stopping, brings the totals back.
Totals per card instead of per account stay as they are: changing that changes the report's layout and sort order, so it is a business decision. You can switch the report fixes on in the Parity Lab and see the end of both reports side by side.
6 · The data
Moving the data into a database
Up to this point the Java versions read and wrote the same record files as the mainframe. That proves the logic, but a modern system keeps its data in a database. This step moves CardDemo's data into PostgreSQL and runs the three jobs on it, with the same checks.
-
Give the data real types
Every record layout became a typed data model: money as exact decimals, dates as dates, ids as numbers. One piece of code converts between the model and the mainframe layouts. All 1,097 records in the 16 files involved, CardDemo's data, the daily input, the reference outputs and the edge cases, convert and come back byte for byte.
-
Migrate, and prove nothing was lost
The migration loads CardDemo's six data files into PostgreSQL and exports them again: all 226 records come back byte for byte. A record whose fields do not fit their types is refused and listed rather than quietly changed. On CardDemo's data, none is.
-
Report what the data shows
The migration also checks the data itself. In all 50 sample accounts the interest-rate group (
A000000000) sits in the ZIP code field and the group field is empty. That is why every account in slice 2 used the default rates. It is a question for whoever owns the data, so the migration reports it instead of fixing it. -
Run the jobs on the database
The three jobs were rewritten on the data model, with storage behind one interface, so the same code runs on the record files or on PostgreSQL. After each job the database is exported back to the record layouts and compared with the COBOL exactly as before.
Check Runs Result Each job and test case, on record files through the new data model 7 Identical Each job and test case, on PostgreSQL 7 Identical The whole nightly chain inside the database 3 jobs Identical -
Make a failed run harmless
On the mainframe, a posting run that stops part-way leaves the files half updated; the inventory flagged this before anything ran. In the database each job saves its changes in one transaction. Two tests prove it: a run that stops on a duplicate transaction id, and one where the database refuses the 262nd row after 261 have been written. Both leave the database exactly as it was.
-
Drop what the database makes unnecessary
On the mainframe a separate job merges the interest charges back into the transaction file every night. In the database each job adds its rows and keeps the history, so that step is gone. The day's incoming transactions still arrive as a file, as they would from a card network, and the report is still a printed report.
-
Run it on AWS
In the Parity Lab you can choose PostgreSQL for the Java side. It runs on Amazon Aurora Serverless, which pauses after five idle minutes and has no compute charge while paused; the first run after a pause waits about 15 seconds while it wakes. Each run gets its own schema, removed when the run ends.
All 19 checks above, the 17 comparisons and the 2 failure tests, pass on two machines with PostgreSQL 16. In the Parity Lab the same comparisons pass on Amazon Aurora PostgreSQL 16.
7 · The first online screen
From green screen to web page
CardDemo's users work on 3270 "green screens" run by CICS, the mainframe's online transaction system. This step takes the View Account screen (program COACTVWC) and gives it a Java service and a web page, with the original screen as the reference.
-
Run the original screen without a mainframe
There is no CICS outside the mainframe, so each of the program's 15 CICS commands is replaced with a call to a small stand-in: reading a file, sending the screen, receiving what was typed, returning with its saved data. The rest of the program runs unchanged under GnuCOBOL. A driver plays the terminal, one key press per task, as CICS runs it, and draws each screen from the program's own screen definition: positions, labels, colors and the cursor.
-
Record 76 screens
Eleven scenarios: every one of the 50 sample accounts; blank, non-numeric, all-zero, short and unknown account numbers; other keys; arriving from the main menu and leaving with PF3. Four test records cover what the sample data never reaches: a card whose account record is missing, an account whose customer is missing, extreme amounts, and an account with two cards.
-
Build the service, compare screen by screen
The Java service looks up the account, its card and its customer on the same data model as the batch jobs, from the record files or from PostgreSQL, and returns JSON for the web page. For the comparison its answer is laid onto the original screen layout, and every field, every color and the cursor are compared with what the COBOL sent.
Run Screens Result Java service on the record files 76 Identical, except one screen (below) Java service on PostgreSQL 76 Identical, except the same screen The one exception is the card whose account record is missing. The original fills five amount fields from storage the read never filled, and on a mainframe that storage could hold anything. The service leaves them empty, and the comparison lists them as an expected difference.
-
What the tests found
- An account with more than one card cannot be opened. The card index allows several cards per account, but the program treats the "duplicate key" answer as a file error. The sample data has one card per account, so this never shows.
- A card whose account record is missing still shows its customer, under an error message, with account amounts from leftover storage. The check meant to stop this tests for a message that is never set.
- Messages show internal codes such as
Resp:000000013 Reas:0000and are cut off at 75 characters. - Amounts of a billion or more lose their first digit on screen: a credit limit of 9,999,999,999.99 shows as 999,999,999.99.
- The full Social Security number, government id and bank account are shown to anyone who can open the screen.
Each one is a switch in the Java service, off by default: with a switch on, the comparison shows exactly which fields change and why.
-
Try it
The account screen in the Parity Lab runs both on AWS for any account number you type: the COBOL's 3270 screen on the left, the web page on the right, and the comparison underneath.
8 · The first screen that writes
Updating an account, and proving what gets saved
The Update Account screen (program COACTUPC) is CardDemo's largest program: 4,236 lines that check about forty fields, ask for confirmation with F5, and rewrite the account and its customer together. Here parity means two things: the same screens, and the same bytes saved.
-
Add the writes to the CICS stand-in
The view screen's stand-ins gain three commands: READ UPDATE, REWRITE, and SYNCPOINT with ROLLBACK. The rewrite stand-in keeps a before-image of each record it changes, so a rollback really puts the old record back. The program's date check calls an IBM Language Environment service, CEEDAYS, which gets a small stand-in of its own. The terminal stand-in now also refuses to type into protected fields, as a real 3270 does. After every scenario, the files are written out again and every changed record is kept.
-
Record 389 screens, and every write
Twenty scenarios: field checks one rule at a time (about 70 wrong values, each answered by its own message), dates and leap years, money in every form the program accepts, saving, clearing optional fields, other keys, and all 50 sample accounts. Four of them change the data behind the user's back: another user changes the record or deletes the account or the customer between Enter and F5, or the customer rewrite is made to fail.
-
Carry the rules over, then compare
The Java service takes the rules over one by one, in the program's order and with its exact messages. The way the screen reads money fields (
5000,-5,000.50,$1,000,5000CR) follows the compiler's own routine, and was checked against it on 40,000 generated inputs with no disagreement. Saving is one transaction: the database locks both rows, checks nobody changed them, and writes both or neither.Run Screens Records saved Result Java service on the record files 389 8 Identical, except one case (below) Java service on PostgreSQL 389 8 Identical, except the same case The exception is a day typed as one digit. The original saves it as
2014-11-5, which no date column can hold, so the Java side refuses the save and says why, and the comparison lists it as an expected difference. Changing a single rule in the Java (a FICO limit of 851 instead of 850) makes the comparison fail. -
What the tests found
- The account group is saved in the wrong place. The record the program writes has no zip-code field, so the group lands where the zip code belongs and the real group field is blanked. A new group never shows again, and the value that was in the zip-code position is overwritten.
- A one-digit day is accepted and saved as
2014-11-5, a date other programs cannot read. A one-digit month is rejected. - Zip codes that start with 0 can never be saved. For Connecticut, Massachusetts, Maine, New Hampshire, New Jersey, Puerto Rico, Rhode Island and Vermont, the program's state table holds the second and third digits of the zip code instead of the first two. A Hartford address, 06103, is rejected; 60103 is accepted.
- Cleared fields are written as binary zeros. PostgreSQL text cannot hold them, so the database version needs a decision; here it keeps a stand-in character and gives the zeros back on export.
- When the customer record cannot be locked, the screen says "Changes committed to database" next to the error, though nothing was written.
- Pressing Enter without changing anything counts as a change when a field is blank, so the checks run on stored data, and a save then writes the blank as binary zeros.
- The second and third parts of the SSN are checked only when the first part is a number: an IF statement is closed by a full stop, not by its END-IF.
- None of the 50 sample accounts passes the program's own checks as stored: zip codes that do not match the state, FICO scores under 300, area codes not in the program's list. Rules this strict, on data this loose, have to be settled before a migration, not after.
Seven of these are switches in the Java service, off by default. With a switch on, the comparison shows which screens and bytes change, and why.
-
Try it
The account update in the Parity Lab runs both on AWS on fresh copies of the data: pick an account and some changes, or one of the cases above. You see each step's 3270 screen, the Java service's answer, and the records both sides saved, byte by byte.
9 · Limits
What this does not prove yet
- GnuCOBOL is not the mainframe. The reference results come from GnuCOBOL. A run on z/OS or AWS Mainframe Modernization would close that gap.
- ASCII sample data. CardDemo also ships the data in mainframe encoding (EBCDIC), where sort order differs. That check is still to do.
- Some failure paths are untested: in posting, an account update that fails part-way and a duplicate transaction ID; in interest, a missing account, card or default rate, and an account with two cards. Each stops the original job, and testing it needs a broken file rather than different data.
- The sort steps are stand-ins. The jobs' mainframe sort steps are reproduced by small scripts. The report's sort does not fix the order of transactions within a card, so on the mainframe that order may vary; we keep them in transaction order.
- The CICS stand-ins are not CICS. They follow the documented behavior of each command the two account screens use, and their assumptions are listed in the engineering record. Two screens are covered so far; CardDemo has 17 online programs. Record locking is tested by changing the data between key presses, not by two users at once.
- Some compiler behavior differs. Where a field of digits holds a blank (a month typed as one digit, say), GnuCOBOL and IBM's compiler may decide differently. The reference is GnuCOBOL; the engineering record lists the cases.
- Not yet a production setup. All three slices run on AWS in the Parity Lab, on AWS Lambda and AWS Step Functions, with the Java side on record files or on Amazon Aurora. A production setup would run them on a schedule against the live database, and the database versions of the jobs, which read whole tables into memory, would page through them at production volumes.
Next
More screens, and slice 4, statements. The card list, card view and card update screens use the same stand-ins and data. The statement job writes text and HTML statements per account, on the same data and harness.
Every step, tool and check behind this page is written up in the engineering record, including how AI assistance was used.
Want this kind of evidence for your system?
A two-to-three-week assessment maps your system, finds the risks and gives you a fixed price for the first slice.