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)); + } +}