diff --git a/docs/SYSTEM_ANALYSIS.md b/docs/SYSTEM_ANALYSIS.md
new file mode 100644
index 00000000..2f76ff15
--- /dev/null
+++ b/docs/SYSTEM_ANALYSIS.md
@@ -0,0 +1,197 @@
+# CBACT01C System Analysis
+
+## 1. Program Overview
+
+| Attribute | Value |
+|---------------|--------------------------------------------------|
+| Program ID | `CBACT01C` |
+| Application | CardDemo |
+| Type | Batch COBOL program |
+| Purpose | Read the VSAM account master file and produce three derivative output files in different record formats |
+
+CBACT01C is a batch extract/transform utility. It sequentially reads every
+record from the indexed (VSAM KSDS) account master, applies light
+transformations (date reformatting, default-value substitution, array
+expansion), and fans the data out into three separate output files -- each
+demonstrating a different COBOL record format: fixed-length flat, fixed-length
+with OCCURS arrays, and variable-length (RECORDING MODE V).
+
+---
+
+## 2. Business Logic Flow
+
+```
+START
+ |
+ +-- Open all four files (ACCTFILE input, OUTFILE / ARRYFILE / VBRCFILE output)
+ | On any open failure -> display status, ABEND 999
+ |
+ +-- LOOP until EOF on ACCTFILE
+ | |
+ | +-- READ next account record into ACCOUNT-RECORD (copybook CVACT01Y)
+ | | Status '00' -> continue processing
+ | | Status '10' -> set EOF flag, exit loop
+ | | Other -> display status, ABEND 999
+ | |
+ | +-- 1100-DISPLAY-ACCT-RECORD
+ | | Display all account fields to SYSOUT (diagnostic trace)
+ | |
+ | +-- 1300-POPUL-ACCT-RECORD (flat output record)
+ | | Copy most fields verbatim from ACCOUNT-RECORD -> OUT-ACCT-REC
+ | | ACCT-REISSUE-DATE: pass through COBDATFT date formatter
+ | | input type '2' (YYYY-MM-DD) -> output type '2' (YYYYMMDD)
+ | | ACCT-CURR-CYC-DEBIT: if zero, substitute 2525.00
+ | |
+ | +-- 1350-WRITE-ACCT-RECORD -> write OUT-ACCT-REC to OUTFILE
+ | |
+ | +-- 1400-POPUL-ARRAY-RECORD (array output record, 5 OCCURS slots)
+ | | Slot 1: balance = ACCT-CURR-BAL, debit = 1005.00
+ | | Slot 2: balance = ACCT-CURR-BAL, debit = 1525.00
+ | | Slot 3: balance = -1025.00, debit = -2500.00
+ | | Slots 4-5: remain initialized to zero (from INITIALIZE)
+ | |
+ | +-- 1450-WRITE-ARRY-RECORD -> write ARR-ARRAY-REC to ARRYFILE
+ | |
+ | +-- 1500-POPUL-VBRC-RECORD (two variable-length records)
+ | | REC1 (12 bytes): ACCT-ID + ACCT-ACTIVE-STATUS
+ | | REC2 (39 bytes): ACCT-ID + CURR-BAL + CREDIT-LIMIT + REISSUE-YYYY
+ | |
+ | +-- 1550-WRITE-VB1-RECORD -> write VBR-REC (len=12) to VBRCFILE
+ | +-- 1575-WRITE-VB2-RECORD -> write VBR-REC (len=39) to VBRCFILE
+ |
+ +-- Close ACCTFILE (on failure -> ABEND 999)
+ |
+ +-- Display "END OF EXECUTION"
+ |
+ +-- GOBACK
+END
+```
+
+---
+
+## 3. Data Structures
+
+### 3.1 Copybook CVACT01Y -- Account Record (300 bytes)
+
+| COBOL Field | PIC | Bytes | Java Type | Notes |
+|--------------------------|-------------------|------:|------------------------|------------------------------------|
+| `ACCT-ID` | `9(11)` | 11 | `long` | Numeric account identifier |
+| `ACCT-ACTIVE-STATUS` | `X(01)` | 1 | `String` (1 char) | 'Y'/'N' active flag |
+| `ACCT-CURR-BAL` | `S9(10)V99` | 12 | `BigDecimal` | Signed with 2 implied decimals |
+| `ACCT-CREDIT-LIMIT` | `S9(10)V99` | 12 | `BigDecimal` | Signed with 2 implied decimals |
+| `ACCT-CASH-CREDIT-LIMIT` | `S9(10)V99` | 12 | `BigDecimal` | Signed with 2 implied decimals |
+| `ACCT-OPEN-DATE` | `X(10)` | 10 | `String` | Date as string (YYYY-MM-DD) |
+| `ACCT-EXPIRAION-DATE` | `X(10)` | 10 | `String` | Expiration date (typo preserved) |
+| `ACCT-REISSUE-DATE` | `X(10)` | 10 | `String` | Reissue date (YYYY-MM-DD) |
+| `ACCT-CURR-CYC-CREDIT` | `S9(10)V99` | 12 | `BigDecimal` | Current cycle credits |
+| `ACCT-CURR-CYC-DEBIT` | `S9(10)V99` | 12 | `BigDecimal` | Current cycle debits |
+| `ACCT-ADDR-ZIP` | `X(10)` | 10 | `String` | Not written to any output |
+| `ACCT-GROUP-ID` | `X(10)` | 10 | `String` | Account group identifier |
+| `FILLER` | `X(178)` | 178 | *(ignored)* | Padding to 300-byte record |
+
+### 3.2 Copybook CODATECN -- Date Conversion Interface
+
+| COBOL Field | PIC | Java Equivalent | Description |
+|-----------------------|----------|------------------------|-------------------------------------------|
+| `CODATECN-TYPE` | `X` | `int` enum (1 or 2) | Input format: 1=YYYYMMDD, 2=YYYY-MM-DD |
+| `CODATECN-INP-DATE` | `X(20)` | `String` | Date input string |
+| `CODATECN-OUTTYPE` | `X` | `int` enum (1 or 2) | Output format: 1=YYYY-MM-DD, 2=YYYYMMDD |
+| `CODATECN-0UT-DATE` | `X(20)` | `String` | Date output string (reformatted) |
+| `CODATECN-ERROR-MSG` | `X(38)` | `String` | Error message (unused in this program) |
+
+The assembler program `COBDATFT` converts between the two date layouts.
+In CBACT01C the conversion is always type 2 -> type 2, i.e.
+`YYYY-MM-DD` in -> `YYYYMMDD` out.
+
+### 3.3 Output Records
+
+#### OUT-ACCT-REC (flat sequential)
+
+| Field | PIC / USAGE | Java Type |
+|--------------------------------|-------------------------|----------------|
+| `OUT-ACCT-ID` | `9(11)` | `long` |
+| `OUT-ACCT-ACTIVE-STATUS` | `X(01)` | `String` |
+| `OUT-ACCT-CURR-BAL` | `S9(10)V99` | `BigDecimal` |
+| `OUT-ACCT-CREDIT-LIMIT` | `S9(10)V99` | `BigDecimal` |
+| `OUT-ACCT-CASH-CREDIT-LIMIT` | `S9(10)V99` | `BigDecimal` |
+| `OUT-ACCT-OPEN-DATE` | `X(10)` | `String` |
+| `OUT-ACCT-EXPIRAION-DATE` | `X(10)` | `String` |
+| `OUT-ACCT-REISSUE-DATE` | `X(10)` | `String` |
+| `OUT-ACCT-CURR-CYC-CREDIT` | `S9(10)V99` | `BigDecimal` |
+| `OUT-ACCT-CURR-CYC-DEBIT` | `S9(10)V99 COMP-3` | `BigDecimal` |
+| `OUT-ACCT-GROUP-ID` | `X(10)` | `String` |
+
+> Note: `OUT-ACCT-CURR-CYC-DEBIT` uses `COMP-3` (packed-decimal), unlike
+> the display-numeric source field. The value 2525.00 is substituted when the
+> source debit is zero.
+
+#### ARR-ARRAY-REC (array-structured sequential)
+
+| Field | PIC / USAGE | Occurs | Java Type |
+|--------------------------|----------------------------|--------|------------------|
+| `ARR-ACCT-ID` | `9(11)` | -- | `long` |
+| `ARR-ACCT-CURR-BAL(n)` | `S9(10)V99` | 5 | `BigDecimal[]` |
+| `ARR-ACCT-CURR-CYC-DEBIT(n)` | `S9(10)V99 COMP-3` | 5 | `BigDecimal[]` |
+| `ARR-FILLER` | `X(04)` | -- | *(padding)* |
+
+Slots 1-3 are populated with specific values; slots 4-5 remain zero.
+
+#### VBRC variable-length records (two layouts per account)
+
+**VB1 (12 bytes):** `ACCT-ID` (11) + `ACCT-ACTIVE-STATUS` (1)
+
+**VB2 (39 bytes):** `ACCT-ID` (11) + `CURR-BAL` (12) + `CREDIT-LIMIT` (12) + `REISSUE-YYYY` (4)
+
+---
+
+## 4. I/O Operations
+
+| Logical Name | DD Name | Organization | Access | Direction | Java Equivalent |
+|----------------|------------|-----------------------------|-----------|-----------|-----------------------------------------|
+| `ACCTFILE-FILE`| `ACCTFILE` | Indexed (VSAM KSDS) | Sequential| Input | `BufferedReader` / custom indexed reader |
+| `OUT-FILE` | `OUTFILE` | Sequential, fixed-length | Sequential| Output | `BufferedWriter` / `PrintWriter` |
+| `ARRY-FILE` | `ARRYFILE` | Sequential, fixed-length | Sequential| Output | `BufferedWriter` / `PrintWriter` |
+| `VBRC-FILE` | `VBRCFILE` | Sequential, variable-length | Sequential| Output | `BufferedWriter` (length-prefixed) |
+
+### File Status Codes
+
+| Status | Meaning | Program Action |
+|--------|-----------|------------------------------|
+| `00` | Success | Continue |
+| `10` | EOF | Set `END-OF-FILE = 'Y'` |
+| Other | Error | Display status, ABEND 999 |
+
+---
+
+## 5. Dependencies
+
+### External Programs
+
+| Program | Type | Purpose | Java Replacement |
+|------------|------------|-----------------------------------------------|-----------------------------------|
+| `COBDATFT` | Assembler | Date format conversion (YYYY-MM-DD <-> YYYYMMDD) | `DateConverter` utility class |
+| `CEE3ABD` | LE runtime | Abnormal program termination (ABEND) | `System.exit(999)` / throw `RuntimeException` |
+
+### Copybooks
+
+| Copybook | Purpose |
+|-------------|--------------------------------------------------------|
+| `CVACT01Y` | 300-byte account master record layout |
+| `CODATECN` | Date conversion input/output structure for `COBDATFT` |
+
+---
+
+## 6. Edge Cases and Error Handling
+
+| Scenario | COBOL Behavior | Java Equivalent |
+|---------------------------------------|--------------------------------------------------------|--------------------------------------------------|
+| File open failure (any file) | Display error + file status, ABEND 999 | Throw `IOException` / `RuntimeException` |
+| Read returns non-00/non-10 status | Display "ERROR READING ACCOUNT FILE", show status, ABEND | Throw `IOException` |
+| Write returns non-00/non-10 status | Display write error + status, ABEND | Throw `IOException` |
+| Close failure on ACCTFILE | Display error, ABEND | Throw `IOException` |
+| `ACCT-CURR-CYC-DEBIT` = 0 | Substitute `2525.00` in output record | `if (debit == 0) debit = 2525.00` |
+| Non-numeric file status (stat1 = '9') | Binary-to-decimal conversion of stat2 byte, display as `NNNN` | Translate to meaningful exception message |
+| Empty input file (immediate EOF) | Loop body never executes; close file, exit normally | Same -- process zero records gracefully |
+| Array slots 4-5 | Left at zero after `INITIALIZE` | Initialize `BigDecimal.ZERO` |
+| Variable-length record sizing | `WS-RECD-LEN` set to 12 or 39 before each WRITE | Write exactly 12 or 39 characters per record type|
+| Reissue date parse | `WS-ACCT-REISSUE-YYYY` extracted by REDEFINES overlay | `substring(0, 4)` of the reissue date string |
diff --git a/java-app/.gitignore b/java-app/.gitignore
new file mode 100644
index 00000000..2f7896d1
--- /dev/null
+++ b/java-app/.gitignore
@@ -0,0 +1 @@
+target/
diff --git a/java-app/pom.xml b/java-app/pom.xml
new file mode 100644
index 00000000..fe11e3ef
--- /dev/null
+++ b/java-app/pom.xml
@@ -0,0 +1,62 @@
+
+
+ 4.0.0
+
+ com.carddemo
+ cbact01c
+ 1.0.0
+ jar
+
+ CBACT01C - Account File Batch Processor
+ Java 17+ rewrite of the COBOL batch program CBACT01C from CardDemo
+
+
+ 17
+ 17
+ UTF-8
+ 5.10.2
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ ${junit.version}
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.11.0
+
+ 17
+ 17
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+ 3.3.0
+
+
+
+ com.carddemo.batch.AccountFileProcessor
+
+
+
+
+
+
+
diff --git a/java-app/src/main/java/com/carddemo/batch/AccountFileProcessor.java b/java-app/src/main/java/com/carddemo/batch/AccountFileProcessor.java
new file mode 100644
index 00000000..1872c5c8
--- /dev/null
+++ b/java-app/src/main/java/com/carddemo/batch/AccountFileProcessor.java
@@ -0,0 +1,237 @@
+package com.carddemo.batch;
+
+import com.carddemo.model.AccountRecord;
+import com.carddemo.model.ArrayAccountRecord;
+import com.carddemo.model.OutAccountRecord;
+import com.carddemo.model.VbrRecord1;
+import com.carddemo.model.VbrRecord2;
+import com.carddemo.util.DateConverter;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.math.BigDecimal;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * Java 17+ rewrite of COBOL batch program CBACT01C.
+ *
+ * Reads every record from the indexed account master file (ACCTFILE)
+ * and writes three derivative output files:
+ * OUTFILE -- flat fixed-length records with selected/transformed fields
+ * ARRYFILE -- array-structured records (5 balance/debit slots per account)
+ * VBRCFILE -- variable-length records (two records per account)
+ */
+public class AccountFileProcessor {
+
+ private static final BigDecimal DEFAULT_DEBIT = new BigDecimal("2525.00");
+ private static final BigDecimal DEBIT_SLOT_1 = new BigDecimal("1005.00");
+ private static final BigDecimal DEBIT_SLOT_2 = new BigDecimal("1525.00");
+ private static final BigDecimal BALANCE_SLOT_3 = new BigDecimal("-1025.00");
+ private static final BigDecimal DEBIT_SLOT_3 = new BigDecimal("-2500.00");
+
+ private final Path acctFilePath;
+ private final Path outFilePath;
+ private final Path arryFilePath;
+ private final Path vbrcFilePath;
+ private final PrintStream console;
+
+ public AccountFileProcessor(Path acctFilePath, Path outFilePath,
+ Path arryFilePath, Path vbrcFilePath) {
+ this(acctFilePath, outFilePath, arryFilePath, vbrcFilePath, System.out);
+ }
+
+ public AccountFileProcessor(Path acctFilePath, Path outFilePath,
+ Path arryFilePath, Path vbrcFilePath,
+ PrintStream console) {
+ this.acctFilePath = acctFilePath;
+ this.outFilePath = outFilePath;
+ this.arryFilePath = arryFilePath;
+ this.vbrcFilePath = vbrcFilePath;
+ this.console = console;
+ }
+
+ /**
+ * Execute the batch job. Returns the number of records processed.
+ */
+ public int execute() throws IOException {
+ console.println("START OF EXECUTION OF PROGRAM CBACT01C");
+
+ int recordCount = 0;
+
+ try (BufferedReader reader = openInputFile();
+ BufferedWriter outWriter = openOutputFile(outFilePath);
+ BufferedWriter arryWriter = openOutputFile(arryFilePath);
+ BufferedWriter vbrcWriter = openOutputFile(vbrcFilePath)) {
+
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.isBlank()) {
+ continue;
+ }
+
+ AccountRecord account = AccountRecord.parse(line);
+ displayAccountRecord(account);
+
+ OutAccountRecord outRecord = populateOutRecord(account);
+ writeRecord(outWriter, outRecord.toOutputLine(), "OUTFILE");
+
+ ArrayAccountRecord arrRecord = populateArrayRecord(account);
+ writeRecord(arryWriter, arrRecord.toOutputLine(), "ARRYFILE");
+
+ VbrRecord1 vbr1 = populateVbrRecord1(account);
+ VbrRecord2 vbr2 = populateVbrRecord2(account);
+ writeRecord(vbrcWriter, vbr1.toOutputLine(), "VBRCFILE");
+ writeRecord(vbrcWriter, vbr2.toOutputLine(), "VBRCFILE");
+
+ recordCount++;
+ }
+ }
+
+ console.println("END OF EXECUTION OF PROGRAM CBACT01C");
+ return recordCount;
+ }
+
+ private BufferedReader openInputFile() throws IOException {
+ if (!Files.exists(acctFilePath)) {
+ console.println("ERROR OPENING ACCTFILE");
+ throw new IOException("Account file not found: " + acctFilePath);
+ }
+ return Files.newBufferedReader(acctFilePath);
+ }
+
+ private BufferedWriter openOutputFile(Path path) throws IOException {
+ try {
+ return Files.newBufferedWriter(path);
+ } catch (IOException e) {
+ console.println("ERROR OPENING " + path.getFileName());
+ throw e;
+ }
+ }
+
+ private void writeRecord(BufferedWriter writer, String record,
+ String fileName) throws IOException {
+ try {
+ writer.write(record);
+ writer.newLine();
+ } catch (IOException e) {
+ console.println(fileName + " WRITE STATUS IS: ERROR");
+ throw e;
+ }
+ }
+
+ /**
+ * Mirrors paragraph 1100-DISPLAY-ACCT-RECORD.
+ */
+ private void displayAccountRecord(AccountRecord acct) {
+ console.println("ACCT-ID :" + acct.acctId());
+ console.println("ACCT-ACTIVE-STATUS :" + acct.activeStatus());
+ console.println("ACCT-CURR-BAL :" + acct.currentBalance());
+ console.println("ACCT-CREDIT-LIMIT :" + acct.creditLimit());
+ console.println("ACCT-CASH-CREDIT-LIMIT :" + acct.cashCreditLimit());
+ console.println("ACCT-OPEN-DATE :" + acct.openDate());
+ console.println("ACCT-EXPIRAION-DATE :" + acct.expirationDate());
+ console.println("ACCT-REISSUE-DATE :" + acct.reissueDate());
+ console.println("ACCT-CURR-CYC-CREDIT :" + acct.currentCycleCredit());
+ console.println("ACCT-CURR-CYC-DEBIT :" + acct.currentCycleDebit());
+ console.println("ACCT-GROUP-ID :" + acct.groupId());
+ console.println("-------------------------------------------------");
+ }
+
+ /**
+ * Mirrors paragraph 1300-POPUL-ACCT-RECORD.
+ * Applies date conversion and default-debit substitution.
+ */
+ OutAccountRecord populateOutRecord(AccountRecord acct) {
+ String reissueDate = DateConverter.convert(
+ acct.reissueDate(), DateConverter.YYYY_MM_DD, DateConverter.YYYYMMDD);
+
+ BigDecimal cycleDebit = acct.currentCycleDebit();
+ if (cycleDebit.compareTo(BigDecimal.ZERO) == 0) {
+ cycleDebit = DEFAULT_DEBIT;
+ }
+
+ return new OutAccountRecord(
+ acct.acctId(),
+ acct.activeStatus(),
+ acct.currentBalance(),
+ acct.creditLimit(),
+ acct.cashCreditLimit(),
+ acct.openDate(),
+ acct.expirationDate(),
+ reissueDate,
+ acct.currentCycleCredit(),
+ cycleDebit,
+ acct.groupId()
+ );
+ }
+
+ /**
+ * Mirrors paragraph 1400-POPUL-ARRAY-RECORD.
+ * Slots 1-3 populated with specific values; slots 4-5 remain zero.
+ */
+ ArrayAccountRecord populateArrayRecord(AccountRecord acct) {
+ BigDecimal[] balances = new BigDecimal[ArrayAccountRecord.SLOT_COUNT];
+ BigDecimal[] debits = new BigDecimal[ArrayAccountRecord.SLOT_COUNT];
+
+ for (int i = 0; i < ArrayAccountRecord.SLOT_COUNT; i++) {
+ balances[i] = BigDecimal.ZERO.setScale(2);
+ debits[i] = BigDecimal.ZERO.setScale(2);
+ }
+
+ balances[0] = acct.currentBalance();
+ debits[0] = DEBIT_SLOT_1;
+
+ balances[1] = acct.currentBalance();
+ debits[1] = DEBIT_SLOT_2;
+
+ balances[2] = BALANCE_SLOT_3;
+ debits[2] = DEBIT_SLOT_3;
+
+ return new ArrayAccountRecord(acct.acctId(), balances, debits);
+ }
+
+ /**
+ * Mirrors paragraph 1500-POPUL-VBRC-RECORD (first record).
+ */
+ VbrRecord1 populateVbrRecord1(AccountRecord acct) {
+ return new VbrRecord1(acct.acctId(), acct.activeStatus());
+ }
+
+ /**
+ * Mirrors paragraph 1500-POPUL-VBRC-RECORD (second record).
+ * Extracts just the year portion from the reissue date.
+ */
+ VbrRecord2 populateVbrRecord2(AccountRecord acct) {
+ String reissueYear = acct.reissueDate().length() >= 4
+ ? acct.reissueDate().substring(0, 4) : acct.reissueDate();
+ return new VbrRecord2(
+ acct.acctId(),
+ acct.currentBalance(),
+ acct.creditLimit(),
+ reissueYear
+ );
+ }
+
+ public static void main(String[] args) {
+ if (args.length < 4) {
+ System.err.println(
+ "Usage: AccountFileProcessor ");
+ System.exit(1);
+ }
+
+ AccountFileProcessor processor = new AccountFileProcessor(
+ Path.of(args[0]), Path.of(args[1]),
+ Path.of(args[2]), Path.of(args[3]));
+
+ try {
+ int count = processor.execute();
+ System.out.println("Processed " + count + " account records.");
+ } catch (IOException e) {
+ System.err.println("ABENDING PROGRAM: " + e.getMessage());
+ System.exit(999);
+ }
+ }
+}
diff --git a/java-app/src/main/java/com/carddemo/model/AccountRecord.java b/java-app/src/main/java/com/carddemo/model/AccountRecord.java
new file mode 100644
index 00000000..6361b8e6
--- /dev/null
+++ b/java-app/src/main/java/com/carddemo/model/AccountRecord.java
@@ -0,0 +1,119 @@
+package com.carddemo.model;
+
+import java.math.BigDecimal;
+
+/**
+ * Maps to COBOL copybook CVACT01Y -- 300-byte account master record.
+ */
+public record AccountRecord(
+ long acctId,
+ String activeStatus,
+ BigDecimal currentBalance,
+ BigDecimal creditLimit,
+ BigDecimal cashCreditLimit,
+ String openDate,
+ String expirationDate,
+ String reissueDate,
+ BigDecimal currentCycleCredit,
+ BigDecimal currentCycleDebit,
+ String addressZip,
+ String groupId
+) {
+
+ private static final int RECORD_LENGTH = 300;
+
+ /**
+ * Parse a fixed-length (300-char) account record line that mirrors the
+ * CVACT01Y copybook layout in display (zoned-decimal) format.
+ *
+ * Field offsets (0-based):
+ * ACCT-ID 0..10 PIC 9(11)
+ * ACCT-ACTIVE-STATUS 11..11 PIC X(01)
+ * ACCT-CURR-BAL 12..23 PIC S9(10)V99 (display, 12 chars)
+ * ACCT-CREDIT-LIMIT 24..35 PIC S9(10)V99
+ * ACCT-CASH-CREDIT-LIM 36..47 PIC S9(10)V99
+ * ACCT-OPEN-DATE 48..57 PIC X(10)
+ * ACCT-EXPIRAION-DATE 58..67 PIC X(10)
+ * ACCT-REISSUE-DATE 68..77 PIC X(10)
+ * ACCT-CURR-CYC-CREDIT 78..89 PIC S9(10)V99
+ * ACCT-CURR-CYC-DEBIT 90..101 PIC S9(10)V99
+ * ACCT-ADDR-ZIP 102..111 PIC X(10)
+ * ACCT-GROUP-ID 112..121 PIC X(10)
+ * FILLER 122..299 PIC X(178)
+ */
+ public static AccountRecord parse(String line) {
+ if (line.length() < RECORD_LENGTH) {
+ line = String.format("%-" + RECORD_LENGTH + "s", line);
+ }
+
+ long acctId = Long.parseLong(line.substring(0, 11).trim());
+ String activeStatus = line.substring(11, 12);
+ BigDecimal currBal = parseSignedDecimal(line.substring(12, 24));
+ BigDecimal creditLimit = parseSignedDecimal(line.substring(24, 36));
+ BigDecimal cashCreditLimit = parseSignedDecimal(line.substring(36, 48));
+ String openDate = line.substring(48, 58);
+ String expirationDate = line.substring(58, 68);
+ String reissueDate = line.substring(68, 78);
+ BigDecimal currCycCredit = parseSignedDecimal(line.substring(78, 90));
+ BigDecimal currCycDebit = parseSignedDecimal(line.substring(90, 102));
+ String addressZip = line.substring(102, 112);
+ String groupId = line.substring(112, 122);
+
+ return new AccountRecord(acctId, activeStatus, currBal, creditLimit,
+ cashCreditLimit, openDate, expirationDate, reissueDate,
+ currCycCredit, currCycDebit, addressZip, groupId);
+ }
+
+ private static final String POSITIVE_OVERPUNCH = "{ABCDEFGHI";
+ private static final String NEGATIVE_OVERPUNCH = "}JKLMNOPQR";
+
+ /**
+ * Parse a COBOL S9(10)V99 display-numeric field (12 characters) into a
+ * BigDecimal with scale 2. Handles:
+ * - COBOL zoned-decimal trailing overpunch ({,A-I positive; },J-R negative)
+ * - Explicit leading/trailing +/- signs
+ * - Plain unsigned digits
+ */
+ private static BigDecimal parseSignedDecimal(String raw) {
+ String s = raw.trim();
+ if (s.isEmpty()) {
+ return BigDecimal.ZERO.setScale(2);
+ }
+
+ boolean negative = false;
+
+ char lastChar = s.charAt(s.length() - 1);
+ int posIdx = POSITIVE_OVERPUNCH.indexOf(lastChar);
+ int negIdx = NEGATIVE_OVERPUNCH.indexOf(lastChar);
+
+ if (posIdx >= 0) {
+ s = s.substring(0, s.length() - 1) + posIdx;
+ } else if (negIdx >= 0) {
+ negative = true;
+ s = s.substring(0, s.length() - 1) + negIdx;
+ } else if (s.startsWith("-")) {
+ negative = true;
+ s = s.substring(1);
+ } else if (s.startsWith("+")) {
+ s = s.substring(1);
+ } else if (s.endsWith("-")) {
+ negative = true;
+ s = s.substring(0, s.length() - 1);
+ } else if (s.endsWith("+")) {
+ s = s.substring(0, s.length() - 1);
+ }
+
+ if (s.contains(".")) {
+ BigDecimal val = new BigDecimal(s);
+ return negative ? val.negate() : val;
+ }
+
+ if (s.length() <= 2) {
+ s = "0".repeat(3 - s.length()) + s;
+ }
+ String intPart = s.substring(0, s.length() - 2);
+ String decPart = s.substring(s.length() - 2);
+ BigDecimal val = new BigDecimal(intPart + "." + decPart);
+ return negative ? val.negate() : val;
+ }
+}
diff --git a/java-app/src/main/java/com/carddemo/model/ArrayAccountRecord.java b/java-app/src/main/java/com/carddemo/model/ArrayAccountRecord.java
new file mode 100644
index 00000000..651c921e
--- /dev/null
+++ b/java-app/src/main/java/com/carddemo/model/ArrayAccountRecord.java
@@ -0,0 +1,35 @@
+package com.carddemo.model;
+
+import java.math.BigDecimal;
+
+/**
+ * Maps to the COBOL ARR-ARRAY-REC structure written to ARRYFILE.
+ * Contains an account ID and 5 balance/debit slots.
+ */
+public record ArrayAccountRecord(
+ long acctId,
+ BigDecimal[] balances,
+ BigDecimal[] debits
+) {
+
+ public static final int SLOT_COUNT = 5;
+
+ public String toOutputLine() {
+ StringBuilder sb = new StringBuilder();
+ sb.append(String.format("%011d", acctId));
+ for (int i = 0; i < SLOT_COUNT; i++) {
+ sb.append(formatDecimal(balances[i]));
+ sb.append(formatDecimal(debits[i]));
+ }
+ sb.append(" "); // ARR-FILLER X(04)
+ return sb.toString();
+ }
+
+ private static String formatDecimal(BigDecimal val) {
+ boolean negative = val.signum() < 0;
+ BigDecimal abs = val.abs();
+ long unscaled = abs.movePointRight(2).longValue();
+ String digits = String.format("%012d", unscaled);
+ return (negative ? "-" : "+") + digits;
+ }
+}
diff --git a/java-app/src/main/java/com/carddemo/model/OutAccountRecord.java b/java-app/src/main/java/com/carddemo/model/OutAccountRecord.java
new file mode 100644
index 00000000..49eb98b3
--- /dev/null
+++ b/java-app/src/main/java/com/carddemo/model/OutAccountRecord.java
@@ -0,0 +1,43 @@
+package com.carddemo.model;
+
+import java.math.BigDecimal;
+
+/**
+ * Maps to the COBOL OUT-ACCT-REC structure written to OUTFILE.
+ */
+public record OutAccountRecord(
+ long acctId,
+ String activeStatus,
+ BigDecimal currentBalance,
+ BigDecimal creditLimit,
+ BigDecimal cashCreditLimit,
+ String openDate,
+ String expirationDate,
+ String reissueDate,
+ BigDecimal currentCycleCredit,
+ BigDecimal currentCycleDebit,
+ String groupId
+) {
+
+ public String toOutputLine() {
+ return String.format("%011d", acctId)
+ + activeStatus
+ + formatDecimal(currentBalance)
+ + formatDecimal(creditLimit)
+ + formatDecimal(cashCreditLimit)
+ + String.format("%-10s", openDate)
+ + String.format("%-10s", expirationDate)
+ + String.format("%-10s", reissueDate)
+ + formatDecimal(currentCycleCredit)
+ + formatDecimal(currentCycleDebit)
+ + String.format("%-10s", groupId);
+ }
+
+ private static String formatDecimal(BigDecimal val) {
+ boolean negative = val.signum() < 0;
+ BigDecimal abs = val.abs();
+ long unscaled = abs.movePointRight(2).longValue();
+ String digits = String.format("%012d", unscaled);
+ return (negative ? "-" : "+") + digits;
+ }
+}
diff --git a/java-app/src/main/java/com/carddemo/model/VbrRecord1.java b/java-app/src/main/java/com/carddemo/model/VbrRecord1.java
new file mode 100644
index 00000000..1b22134d
--- /dev/null
+++ b/java-app/src/main/java/com/carddemo/model/VbrRecord1.java
@@ -0,0 +1,14 @@
+package com.carddemo.model;
+
+/**
+ * Maps to COBOL VBRC-REC1 (12 bytes): ACCT-ID + ACTIVE-STATUS.
+ */
+public record VbrRecord1(
+ long acctId,
+ String activeStatus
+) {
+
+ public String toOutputLine() {
+ return String.format("%011d", acctId) + activeStatus;
+ }
+}
diff --git a/java-app/src/main/java/com/carddemo/model/VbrRecord2.java b/java-app/src/main/java/com/carddemo/model/VbrRecord2.java
new file mode 100644
index 00000000..20d28903
--- /dev/null
+++ b/java-app/src/main/java/com/carddemo/model/VbrRecord2.java
@@ -0,0 +1,29 @@
+package com.carddemo.model;
+
+import java.math.BigDecimal;
+
+/**
+ * Maps to COBOL VBRC-REC2 (39 bytes): ACCT-ID + CURR-BAL + CREDIT-LIMIT + REISSUE-YYYY.
+ */
+public record VbrRecord2(
+ long acctId,
+ BigDecimal currentBalance,
+ BigDecimal creditLimit,
+ String reissueYear
+) {
+
+ public String toOutputLine() {
+ return String.format("%011d", acctId)
+ + formatDecimal(currentBalance)
+ + formatDecimal(creditLimit)
+ + String.format("%-4s", reissueYear);
+ }
+
+ private static String formatDecimal(BigDecimal val) {
+ boolean negative = val.signum() < 0;
+ BigDecimal abs = val.abs();
+ long unscaled = abs.movePointRight(2).longValue();
+ String digits = String.format("%012d", unscaled);
+ return (negative ? "-" : "+") + digits;
+ }
+}
diff --git a/java-app/src/main/java/com/carddemo/util/DateConverter.java b/java-app/src/main/java/com/carddemo/util/DateConverter.java
new file mode 100644
index 00000000..466182e4
--- /dev/null
+++ b/java-app/src/main/java/com/carddemo/util/DateConverter.java
@@ -0,0 +1,60 @@
+package com.carddemo.util;
+
+/**
+ * Pure-Java replacement for the assembler program COBDATFT, which converts
+ * between date formats using the CODATECN copybook interface.
+ *
+ * Supported conversions (matching CODATECN-TYPE / CODATECN-OUTTYPE):
+ * Type 1 = YYYYMMDD
+ * Type 2 = YYYY-MM-DD
+ */
+public final class DateConverter {
+
+ private DateConverter() { }
+
+ public static final int YYYYMMDD = 1;
+ public static final int YYYY_MM_DD = 2;
+
+ /**
+ * Convert a date string between the two supported formats.
+ *
+ * @param inputDate the source date string
+ * @param inputType 1 for YYYYMMDD, 2 for YYYY-MM-DD
+ * @param outputType 1 for YYYYMMDD, 2 for YYYY-MM-DD
+ * @return the reformatted date string
+ * @throws IllegalArgumentException on invalid type codes or unparseable input
+ */
+ public static String convert(String inputDate, int inputType, int outputType) {
+ String yyyy;
+ String mm;
+ String dd;
+
+ switch (inputType) {
+ case YYYYMMDD -> {
+ if (inputDate.length() < 8) {
+ throw new IllegalArgumentException(
+ "YYYYMMDD input must be at least 8 characters: " + inputDate);
+ }
+ yyyy = inputDate.substring(0, 4);
+ mm = inputDate.substring(4, 6);
+ dd = inputDate.substring(6, 8);
+ }
+ case YYYY_MM_DD -> {
+ if (inputDate.length() < 10) {
+ throw new IllegalArgumentException(
+ "YYYY-MM-DD input must be at least 10 characters: " + inputDate);
+ }
+ yyyy = inputDate.substring(0, 4);
+ mm = inputDate.substring(5, 7);
+ dd = inputDate.substring(8, 10);
+ }
+ default -> throw new IllegalArgumentException("Unknown input type: " + inputType);
+ }
+
+ return switch (outputType) {
+ case YYYYMMDD -> yyyy + mm + dd;
+ case YYYY_MM_DD -> yyyy + "-" + mm + "-" + dd;
+ default -> throw new IllegalArgumentException("Unknown output type: " + outputType);
+ };
+ }
+}
diff --git a/java-app/src/test/java/com/carddemo/batch/AccountFileProcessorTest.java b/java-app/src/test/java/com/carddemo/batch/AccountFileProcessorTest.java
new file mode 100644
index 00000000..753c2e33
--- /dev/null
+++ b/java-app/src/test/java/com/carddemo/batch/AccountFileProcessorTest.java
@@ -0,0 +1,462 @@
+package com.carddemo.batch;
+
+import com.carddemo.model.AccountRecord;
+import com.carddemo.model.ArrayAccountRecord;
+import com.carddemo.model.OutAccountRecord;
+import com.carddemo.model.VbrRecord1;
+import com.carddemo.model.VbrRecord2;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.math.BigDecimal;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class AccountFileProcessorTest {
+
+ @TempDir
+ Path tempDir;
+
+ private Path acctFile;
+ private Path outFile;
+ private Path arryFile;
+ private Path vbrcFile;
+ private ByteArrayOutputStream consoleOutput;
+ private PrintStream consolePrintStream;
+
+ /**
+ * Build a 300-char fixed-length record that mirrors CVACT01Y layout.
+ *
+ * ACCT-ID PIC 9(11) -> 11 chars
+ * ACCT-ACTIVE-STATUS PIC X(01) -> 1 char
+ * ACCT-CURR-BAL PIC S9(10)V99 -> 12 chars (display numeric)
+ * ACCT-CREDIT-LIMIT PIC S9(10)V99 -> 12 chars
+ * ACCT-CASH-CREDIT-LIM PIC S9(10)V99 -> 12 chars
+ * ACCT-OPEN-DATE PIC X(10) -> 10 chars
+ * ACCT-EXPIRAION-DATE PIC X(10) -> 10 chars
+ * ACCT-REISSUE-DATE PIC X(10) -> 10 chars
+ * ACCT-CURR-CYC-CREDIT PIC S9(10)V99 -> 12 chars
+ * ACCT-CURR-CYC-DEBIT PIC S9(10)V99 -> 12 chars
+ * ACCT-ADDR-ZIP PIC X(10) -> 10 chars
+ * ACCT-GROUP-ID PIC X(10) -> 10 chars
+ * FILLER PIC X(178) -> 178 chars
+ */
+ private static String buildAccountLine(long acctId, String status,
+ BigDecimal bal, BigDecimal creditLimit,
+ BigDecimal cashCreditLimit,
+ String openDate, String expDate,
+ String reissueDate,
+ BigDecimal cycCredit, BigDecimal cycDebit,
+ String zip, String groupId) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(String.format("%011d", acctId)); // 11
+ sb.append(status); // 1
+ sb.append(formatSignedDecimal(bal)); // 12
+ sb.append(formatSignedDecimal(creditLimit)); // 12
+ sb.append(formatSignedDecimal(cashCreditLimit)); // 12
+ sb.append(String.format("%-10s", openDate)); // 10
+ sb.append(String.format("%-10s", expDate)); // 10
+ sb.append(String.format("%-10s", reissueDate)); // 10
+ sb.append(formatSignedDecimal(cycCredit)); // 12
+ sb.append(formatSignedDecimal(cycDebit)); // 12
+ sb.append(String.format("%-10s", zip)); // 10
+ sb.append(String.format("%-10s", groupId)); // 10
+ sb.append(" ".repeat(178)); // filler
+ return sb.toString();
+ }
+
+ private static final String POSITIVE_OVERPUNCH = "{ABCDEFGHI";
+ private static final String NEGATIVE_OVERPUNCH = "}JKLMNOPQR";
+
+ /**
+ * Format a BigDecimal as a 12-char COBOL zoned-decimal field with trailing
+ * overpunch encoding, matching the production data format.
+ */
+ private static String formatSignedDecimal(BigDecimal val) {
+ boolean negative = val.signum() < 0;
+ BigDecimal abs = val.abs();
+ long unscaled = abs.movePointRight(2).longValue();
+ String digits = String.format("%012d", unscaled);
+ int lastDigit = digits.charAt(11) - '0';
+ char overpunch = negative
+ ? NEGATIVE_OVERPUNCH.charAt(lastDigit)
+ : POSITIVE_OVERPUNCH.charAt(lastDigit);
+ return digits.substring(0, 11) + overpunch;
+ }
+
+ @BeforeEach
+ void setUp() {
+ acctFile = tempDir.resolve("acctfile.dat");
+ outFile = tempDir.resolve("outfile.dat");
+ arryFile = tempDir.resolve("arryfile.dat");
+ vbrcFile = tempDir.resolve("vbrcfile.dat");
+ consoleOutput = new ByteArrayOutputStream();
+ consolePrintStream = new PrintStream(consoleOutput);
+ }
+
+ private AccountFileProcessor newProcessor() {
+ return new AccountFileProcessor(
+ acctFile, outFile, arryFile, vbrcFile, consolePrintStream);
+ }
+
+ // ------------------------------------------------------------------
+ // Full end-to-end tests
+ // ------------------------------------------------------------------
+
+ @Test
+ void execute_singleRecord_writesAllThreeOutputFiles() throws IOException {
+ String line = buildAccountLine(
+ 12345678901L, "Y",
+ new BigDecimal("5000.50"), new BigDecimal("10000.00"),
+ new BigDecimal("3000.00"),
+ "2023-01-15", "2028-01-15", "2025-06-20",
+ new BigDecimal("200.00"), new BigDecimal("150.75"),
+ "90210 ", "GRP001 ");
+
+ Files.writeString(acctFile, line + "\n");
+
+ int count = newProcessor().execute();
+ assertEquals(1, count);
+
+ List outLines = Files.readAllLines(outFile);
+ assertEquals(1, outLines.size());
+
+ List arryLines = Files.readAllLines(arryFile);
+ assertEquals(1, arryLines.size());
+
+ // VBRC gets 2 records per input account
+ List vbrcLines = Files.readAllLines(vbrcFile);
+ assertEquals(2, vbrcLines.size());
+ }
+
+ @Test
+ void execute_multipleRecords_allProcessed() throws IOException {
+ String line1 = buildAccountLine(
+ 1L, "Y",
+ new BigDecimal("100.00"), new BigDecimal("5000.00"),
+ new BigDecimal("1000.00"),
+ "2020-01-01", "2025-01-01", "2024-06-15",
+ new BigDecimal("50.00"), new BigDecimal("25.00"),
+ "10001 ", "GRPA ");
+ String line2 = buildAccountLine(
+ 2L, "N",
+ new BigDecimal("200.00"), new BigDecimal("8000.00"),
+ new BigDecimal("2000.00"),
+ "2021-03-10", "2026-03-10", "2025-03-10",
+ new BigDecimal("75.00"), new BigDecimal("0.00"),
+ "10002 ", "GRPB ");
+
+ Files.writeString(acctFile, line1 + "\n" + line2 + "\n");
+
+ int count = newProcessor().execute();
+ assertEquals(2, count);
+
+ List outLines = Files.readAllLines(outFile);
+ assertEquals(2, outLines.size());
+
+ List vbrcLines = Files.readAllLines(vbrcFile);
+ assertEquals(4, vbrcLines.size());
+ }
+
+ @Test
+ void execute_emptyFile_processesZeroRecords() throws IOException {
+ Files.writeString(acctFile, "");
+
+ int count = newProcessor().execute();
+ assertEquals(0, count);
+
+ String console = consoleOutput.toString();
+ assertTrue(console.contains("START OF EXECUTION OF PROGRAM CBACT01C"));
+ assertTrue(console.contains("END OF EXECUTION OF PROGRAM CBACT01C"));
+ }
+
+ @Test
+ void execute_missingInputFile_throwsIOException() {
+ // acctFile not created
+ assertThrows(IOException.class, () -> newProcessor().execute());
+ }
+
+ // ------------------------------------------------------------------
+ // Business logic tests (transformation rules)
+ // ------------------------------------------------------------------
+
+ @Test
+ void populateOutRecord_zeroCycleDebit_substitutesDefault() {
+ AccountRecord acct = new AccountRecord(
+ 99L, "Y",
+ new BigDecimal("1000.00"), new BigDecimal("5000.00"),
+ new BigDecimal("2000.00"),
+ "2023-01-01", "2028-01-01", "2025-06-20",
+ new BigDecimal("300.00"), BigDecimal.ZERO.setScale(2),
+ "12345 ", "GRP1 ");
+
+ AccountFileProcessor processor = new AccountFileProcessor(
+ Path.of("x"), Path.of("x"), Path.of("x"), Path.of("x"));
+
+ OutAccountRecord out = processor.populateOutRecord(acct);
+ assertEquals(new BigDecimal("2525.00"), out.currentCycleDebit());
+ }
+
+ @Test
+ void populateOutRecord_nonZeroCycleDebit_preservedAsIs() {
+ AccountRecord acct = new AccountRecord(
+ 99L, "Y",
+ new BigDecimal("1000.00"), new BigDecimal("5000.00"),
+ new BigDecimal("2000.00"),
+ "2023-01-01", "2028-01-01", "2025-06-20",
+ new BigDecimal("300.00"), new BigDecimal("150.75"),
+ "12345 ", "GRP1 ");
+
+ AccountFileProcessor processor = new AccountFileProcessor(
+ Path.of("x"), Path.of("x"), Path.of("x"), Path.of("x"));
+
+ OutAccountRecord out = processor.populateOutRecord(acct);
+ assertEquals(new BigDecimal("150.75"), out.currentCycleDebit());
+ }
+
+ @Test
+ void populateOutRecord_reissueDateConverted() {
+ AccountRecord acct = new AccountRecord(
+ 99L, "Y",
+ BigDecimal.ZERO.setScale(2), BigDecimal.ZERO.setScale(2),
+ BigDecimal.ZERO.setScale(2),
+ "2023-01-01", "2028-01-01", "2025-06-20",
+ BigDecimal.ZERO.setScale(2), new BigDecimal("10.00"),
+ "12345 ", "GRP1 ");
+
+ AccountFileProcessor processor = new AccountFileProcessor(
+ Path.of("x"), Path.of("x"), Path.of("x"), Path.of("x"));
+
+ OutAccountRecord out = processor.populateOutRecord(acct);
+ assertEquals("20250620", out.reissueDate());
+ }
+
+ @Test
+ void populateArrayRecord_slotsPopulatedCorrectly() {
+ BigDecimal balance = new BigDecimal("7500.25");
+ AccountRecord acct = new AccountRecord(
+ 42L, "Y",
+ balance, new BigDecimal("20000.00"),
+ new BigDecimal("5000.00"),
+ "2023-01-01", "2028-01-01", "2025-06-20",
+ new BigDecimal("100.00"), new BigDecimal("50.00"),
+ "12345 ", "GRP1 ");
+
+ AccountFileProcessor processor = new AccountFileProcessor(
+ Path.of("x"), Path.of("x"), Path.of("x"), Path.of("x"));
+
+ ArrayAccountRecord arr = processor.populateArrayRecord(acct);
+
+ assertEquals(42L, arr.acctId());
+
+ // Slot 1: balance = account balance, debit = 1005.00
+ assertEquals(balance, arr.balances()[0]);
+ assertEquals(new BigDecimal("1005.00"), arr.debits()[0]);
+
+ // Slot 2: balance = account balance, debit = 1525.00
+ assertEquals(balance, arr.balances()[1]);
+ assertEquals(new BigDecimal("1525.00"), arr.debits()[1]);
+
+ // Slot 3: hardcoded negative values
+ assertEquals(new BigDecimal("-1025.00"), arr.balances()[2]);
+ assertEquals(new BigDecimal("-2500.00"), arr.debits()[2]);
+
+ // Slots 4-5: zero
+ assertEquals(0, arr.balances()[3].compareTo(BigDecimal.ZERO));
+ assertEquals(0, arr.debits()[3].compareTo(BigDecimal.ZERO));
+ assertEquals(0, arr.balances()[4].compareTo(BigDecimal.ZERO));
+ assertEquals(0, arr.debits()[4].compareTo(BigDecimal.ZERO));
+ }
+
+ @Test
+ void populateVbrRecord1_containsIdAndStatus() {
+ AccountRecord acct = new AccountRecord(
+ 55L, "N",
+ BigDecimal.ZERO.setScale(2), BigDecimal.ZERO.setScale(2),
+ BigDecimal.ZERO.setScale(2),
+ "2023-01-01", "2028-01-01", "2025-06-20",
+ BigDecimal.ZERO.setScale(2), BigDecimal.ZERO.setScale(2),
+ "12345 ", "GRP1 ");
+
+ AccountFileProcessor processor = new AccountFileProcessor(
+ Path.of("x"), Path.of("x"), Path.of("x"), Path.of("x"));
+
+ VbrRecord1 vbr1 = processor.populateVbrRecord1(acct);
+ assertEquals(55L, vbr1.acctId());
+ assertEquals("N", vbr1.activeStatus());
+
+ String output = vbr1.toOutputLine();
+ assertEquals(12, output.length());
+ assertTrue(output.startsWith("00000000055"));
+ assertTrue(output.endsWith("N"));
+ }
+
+ @Test
+ void populateVbrRecord2_extractsReissueYear() {
+ AccountRecord acct = new AccountRecord(
+ 77L, "Y",
+ new BigDecimal("3000.00"), new BigDecimal("15000.00"),
+ BigDecimal.ZERO.setScale(2),
+ "2023-01-01", "2028-01-01", "2025-06-20",
+ BigDecimal.ZERO.setScale(2), BigDecimal.ZERO.setScale(2),
+ "12345 ", "GRP1 ");
+
+ AccountFileProcessor processor = new AccountFileProcessor(
+ Path.of("x"), Path.of("x"), Path.of("x"), Path.of("x"));
+
+ VbrRecord2 vbr2 = processor.populateVbrRecord2(acct);
+ assertEquals(77L, vbr2.acctId());
+ assertEquals(new BigDecimal("3000.00"), vbr2.currentBalance());
+ assertEquals(new BigDecimal("15000.00"), vbr2.creditLimit());
+ assertEquals("2025", vbr2.reissueYear());
+ }
+
+ // ------------------------------------------------------------------
+ // Console output verification
+ // ------------------------------------------------------------------
+
+ @Test
+ void execute_displaysStartAndEndMessages() throws IOException {
+ Files.writeString(acctFile, "");
+
+ newProcessor().execute();
+
+ String output = consoleOutput.toString();
+ assertTrue(output.contains("START OF EXECUTION OF PROGRAM CBACT01C"));
+ assertTrue(output.contains("END OF EXECUTION OF PROGRAM CBACT01C"));
+ }
+
+ @Test
+ void execute_displaysAccountFieldsForEachRecord() throws IOException {
+ String line = buildAccountLine(
+ 12345678901L, "Y",
+ new BigDecimal("5000.50"), new BigDecimal("10000.00"),
+ new BigDecimal("3000.00"),
+ "2023-01-15", "2028-01-15", "2025-06-20",
+ new BigDecimal("200.00"), new BigDecimal("150.75"),
+ "90210 ", "GRP001 ");
+
+ Files.writeString(acctFile, line + "\n");
+
+ newProcessor().execute();
+
+ String output = consoleOutput.toString();
+ assertTrue(output.contains("ACCT-ID"));
+ assertTrue(output.contains("12345678901"));
+ assertTrue(output.contains("ACCT-ACTIVE-STATUS"));
+ assertTrue(output.contains("ACCT-CURR-BAL"));
+ assertTrue(output.contains("-------------------------------------------------"));
+ }
+
+ // ------------------------------------------------------------------
+ // AccountRecord parsing tests
+ // ------------------------------------------------------------------
+
+ @Test
+ void accountRecord_parse_productionDataLine() {
+ // First line from app/data/ASCII/acctdata.txt (trailing overpunch encoded)
+ String line = "00000000001Y00000001940{00000020200{00000010200{" +
+ "2014-11-202025-05-202025-05-2000000000000{00000000000{" +
+ "A000000000" + " ".repeat(178);
+
+ AccountRecord acct = AccountRecord.parse(line);
+ assertEquals(1L, acct.acctId());
+ assertEquals("Y", acct.activeStatus());
+ assertEquals(0, new BigDecimal("194.00").compareTo(acct.currentBalance()));
+ assertEquals(0, new BigDecimal("2020.00").compareTo(acct.creditLimit()));
+ assertEquals(0, new BigDecimal("1020.00").compareTo(acct.cashCreditLimit()));
+ assertEquals("2014-11-20", acct.openDate());
+ assertEquals("2025-05-20", acct.expirationDate());
+ assertEquals("2025-05-20", acct.reissueDate());
+ assertEquals(0, BigDecimal.ZERO.compareTo(acct.currentCycleCredit()));
+ assertEquals(0, BigDecimal.ZERO.compareTo(acct.currentCycleDebit()));
+ }
+
+ @Test
+ void accountRecord_parse_negativeOverpunch() {
+ // Build a line with negative values using overpunch encoding
+ // -1234.56 -> 123456 -> last digit 6 -> O (negative 6) -> 00000012345O
+ String line = "00000000099Y" +
+ "00000012345O" + // curr bal = -1234.56
+ "00000050000{" + // credit limit = 5000.00
+ "00000020000{" + // cash credit limit = 2000.00
+ "2023-01-01" +
+ "2028-01-01" +
+ "2025-06-20" +
+ "00000001000{" + // cycle credit = 100.00
+ "00000000500}" + // cycle debit = -50.00 (} = negative 0)
+ "12345 " +
+ "GRP1 " +
+ " ".repeat(178);
+
+ AccountRecord acct = AccountRecord.parse(line);
+ assertEquals(0, new BigDecimal("-1234.56").compareTo(acct.currentBalance()));
+ assertEquals(0, new BigDecimal("-50.00").compareTo(acct.currentCycleDebit()));
+ }
+
+ @Test
+ void accountRecord_parse_roundTrip() {
+ String line = buildAccountLine(
+ 99887766554L, "Y",
+ new BigDecimal("12345.67"), new BigDecimal("50000.00"),
+ new BigDecimal("10000.00"),
+ "2022-05-01", "2027-05-01", "2026-01-15",
+ new BigDecimal("500.00"), new BigDecimal("250.50"),
+ "30301 ", "GRPTEST ");
+
+ AccountRecord acct = AccountRecord.parse(line);
+ assertEquals(99887766554L, acct.acctId());
+ assertEquals("Y", acct.activeStatus());
+ assertEquals(0, new BigDecimal("12345.67").compareTo(acct.currentBalance()));
+ assertEquals(0, new BigDecimal("50000.00").compareTo(acct.creditLimit()));
+ assertEquals(0, new BigDecimal("10000.00").compareTo(acct.cashCreditLimit()));
+ assertEquals("2022-05-01", acct.openDate());
+ assertEquals("2027-05-01", acct.expirationDate());
+ assertEquals("2026-01-15", acct.reissueDate());
+ assertEquals(0, new BigDecimal("500.00").compareTo(acct.currentCycleCredit()));
+ assertEquals(0, new BigDecimal("250.50").compareTo(acct.currentCycleDebit()));
+ assertEquals("GRPTEST ", acct.groupId());
+ }
+
+ // ------------------------------------------------------------------
+ // Output format verification
+ // ------------------------------------------------------------------
+
+ @Test
+ void outAccountRecord_toOutputLine_format() {
+ OutAccountRecord rec = new OutAccountRecord(
+ 123L, "Y",
+ new BigDecimal("1000.00"), new BigDecimal("5000.00"),
+ new BigDecimal("2000.00"),
+ "2023-01-01", "2028-01-01", "20250620",
+ new BigDecimal("100.00"), new BigDecimal("50.00"),
+ "GRP1 ");
+
+ String output = rec.toOutputLine();
+ assertTrue(output.startsWith("00000000123"));
+ assertTrue(output.contains("Y"));
+ }
+
+ @Test
+ void arrayAccountRecord_toOutputLine_hasCorrectLength() {
+ BigDecimal[] balances = new BigDecimal[5];
+ BigDecimal[] debits = new BigDecimal[5];
+ for (int i = 0; i < 5; i++) {
+ balances[i] = BigDecimal.ZERO.setScale(2);
+ debits[i] = BigDecimal.ZERO.setScale(2);
+ }
+
+ ArrayAccountRecord rec = new ArrayAccountRecord(1L, balances, debits);
+ String output = rec.toOutputLine();
+ // 11 (id) + 5 * (13 + 13) (bal+debit) + 4 (filler) = 11 + 130 + 4 = 145
+ assertEquals(145, output.length());
+ }
+}
diff --git a/java-app/src/test/java/com/carddemo/util/DateConverterTest.java b/java-app/src/test/java/com/carddemo/util/DateConverterTest.java
new file mode 100644
index 00000000..ba22b772
--- /dev/null
+++ b/java-app/src/test/java/com/carddemo/util/DateConverterTest.java
@@ -0,0 +1,67 @@
+package com.carddemo.util;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class DateConverterTest {
+
+ @Test
+ void convertYyyyMmDdToYyyymmdd() {
+ String result = DateConverter.convert("2025-06-20", DateConverter.YYYY_MM_DD,
+ DateConverter.YYYYMMDD);
+ assertEquals("20250620", result);
+ }
+
+ @Test
+ void convertYyyymmddToYyyyMmDd() {
+ String result = DateConverter.convert("20250620", DateConverter.YYYYMMDD,
+ DateConverter.YYYY_MM_DD);
+ assertEquals("2025-06-20", result);
+ }
+
+ @Test
+ void convertYyyyMmDdRoundTrip() {
+ String original = "2023-12-31";
+ String compact = DateConverter.convert(original, DateConverter.YYYY_MM_DD,
+ DateConverter.YYYYMMDD);
+ assertEquals("20231231", compact);
+
+ String expanded = DateConverter.convert(compact, DateConverter.YYYYMMDD,
+ DateConverter.YYYY_MM_DD);
+ assertEquals(original, expanded);
+ }
+
+ @Test
+ void convertSameFormat_yyyymmddToYyyymmdd() {
+ String result = DateConverter.convert("20250620", DateConverter.YYYYMMDD,
+ DateConverter.YYYYMMDD);
+ assertEquals("20250620", result);
+ }
+
+ @Test
+ void convertSameFormat_yyyyMmDdToYyyyMmDd() {
+ String result = DateConverter.convert("2025-06-20", DateConverter.YYYY_MM_DD,
+ DateConverter.YYYY_MM_DD);
+ assertEquals("2025-06-20", result);
+ }
+
+ @Test
+ void invalidInputType_throwsException() {
+ assertThrows(IllegalArgumentException.class,
+ () -> DateConverter.convert("2025-06-20", 3, DateConverter.YYYY_MM_DD));
+ }
+
+ @Test
+ void invalidOutputType_throwsException() {
+ assertThrows(IllegalArgumentException.class,
+ () -> DateConverter.convert("2025-06-20", DateConverter.YYYY_MM_DD, 3));
+ }
+
+ @Test
+ void shortInput_throwsException() {
+ assertThrows(IllegalArgumentException.class,
+ () -> DateConverter.convert("2025", DateConverter.YYYY_MM_DD,
+ DateConverter.YYYYMMDD));
+ }
+}