diff --git a/contributor_tasks/bank-interest-legacy-java7/Dockerfile b/contributor_tasks/bank-interest-legacy-java7/Dockerfile new file mode 100644 index 0000000..8d85a8e --- /dev/null +++ b/contributor_tasks/bank-interest-legacy-java7/Dockerfile @@ -0,0 +1,35 @@ +FROM --platform=linux/amd64 ghcr.io/laude-institute/t-bench/python-3-13:20250620 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +ENV JAVA_HOME=/opt/jdk7 +ENV PATH="$JAVA_HOME/bin:$PATH" + +ARG ZULU7_URL=https://cdn.azul.com/zulu/bin/zulu7.56.0.11-ca-jdk7.0.352-linux_x64.tar.gz +ARG ZULU7_SHA256=8a7387c1ed151474301b6553c6046f865dc6c1e1890bcf106acc2780c55727c8 +RUN mkdir -p /opt/zulu7 \ + && curl -fsSL "$ZULU7_URL" -o /tmp/zulu7.tgz \ + && echo "$ZULU7_SHA256 /tmp/zulu7.tgz" | sha256sum -c - \ + && tar -xzf /tmp/zulu7.tgz -C /opt/zulu7 \ + && rm /tmp/zulu7.tgz \ + && JDK_DIR="$(find /opt/zulu7 -mindepth 1 -maxdepth 1 -type d -name 'zulu7*' | head -n 1)" \ + && test -n "$JDK_DIR" \ + && rm -rf /opt/jdk7 \ + && ln -s "$JDK_DIR" /opt/jdk7 \ + && ln -sf /opt/jdk7/bin/java /usr/local/bin/java \ + && ln -sf /opt/jdk7/bin/javac /usr/local/bin/javac \ + && ln -sf /opt/jdk7/bin/jar /usr/local/bin/jar + +RUN pip install --no-cache-dir pytest==8.4.2 + +WORKDIR /app + +RUN mkdir -p /opt/legacy-lib \ + && curl -fsSL -o /opt/legacy-lib/h2-1.3.176.jar https://repo1.maven.org/maven2/com/h2database/h2/1.3.176/h2-1.3.176.jar \ + && true + +COPY data ./data + +CMD ["bash"] diff --git a/contributor_tasks/bank-interest-legacy-java7/data/accounts.csv b/contributor_tasks/bank-interest-legacy-java7/data/accounts.csv new file mode 100644 index 0000000..a10b344 --- /dev/null +++ b/contributor_tasks/bank-interest-legacy-java7/data/accounts.csv @@ -0,0 +1,5 @@ +account_id,opened_at,start_balance,base_apr,promo_apr,promo_days,tier_threshold,tier_apr,currency,fee_plan +A1001,2024-02-01,10000.00,0.01000,0.00500,29,5000.00,0.00200,USD,PLAN_A +A1002,2020-01-01,5000.00,0.00800,0.00000,0,5000.00,0.00100,USD,PLAN_B +A1003,2023-12-01,-10.00,0.02000,0.00000,0,10000.00,0.00500,EUR,PLAN_A +A1004,2023-01-01,36500.00,0.01005,0.00000,0,999999.00,0.00000,USD,PLAN_C diff --git a/contributor_tasks/bank-interest-legacy-java7/data/biz_date.txt b/contributor_tasks/bank-interest-legacy-java7/data/biz_date.txt new file mode 100644 index 0000000..f4f1f2e --- /dev/null +++ b/contributor_tasks/bank-interest-legacy-java7/data/biz_date.txt @@ -0,0 +1 @@ +2024-03-01 diff --git a/contributor_tasks/bank-interest-legacy-java7/data/fee_rules.csv b/contributor_tasks/bank-interest-legacy-java7/data/fee_rules.csv new file mode 100644 index 0000000..68bfcb7 --- /dev/null +++ b/contributor_tasks/bank-interest-legacy-java7/data/fee_rules.csv @@ -0,0 +1,4 @@ +fee_plan,applies_to,flat_fee,pct_fee,min_fee,max_fee +PLAN_A,DEBIT,0.50,0.005,0.25,5.00 +PLAN_B,ALL,0.10,0.002,0.10,2.00 +PLAN_C,CREDIT,0.25,0.001,0.25,1.00 diff --git a/contributor_tasks/bank-interest-legacy-java7/data/fx_pivots.csv b/contributor_tasks/bank-interest-legacy-java7/data/fx_pivots.csv new file mode 100644 index 0000000..300561a --- /dev/null +++ b/contributor_tasks/bank-interest-legacy-java7/data/fx_pivots.csv @@ -0,0 +1,3 @@ +pivot +GBP +EUR diff --git a/contributor_tasks/bank-interest-legacy-java7/data/fx_rates.csv b/contributor_tasks/bank-interest-legacy-java7/data/fx_rates.csv new file mode 100644 index 0000000..f2a7d1b --- /dev/null +++ b/contributor_tasks/bank-interest-legacy-java7/data/fx_rates.csv @@ -0,0 +1,19 @@ +rate_date,from_ccy,to_ccy,rate +2024-01-01,USD,EUR,0.9000 +2024-01-01,EUR,USD,1.1111 +2024-02-01,USD,EUR,0.9100 +2024-02-01,EUR,USD,1.0989 +2024-03-01,USD,EUR,0.9200 +2024-03-01,EUR,USD,1.0870 +2024-02-01,JPY,EUR,0.0065 +2024-03-01,JPY,EUR,0.0066 +2024-02-01,USD,GBP,0.7900 +2024-03-01,USD,GBP,0.8000 +2024-02-01,GBP,JPY,190.00 +2024-03-01,GBP,JPY,188.00 +2024-02-01,JPY,USD,0.0067 +2024-03-01,USD,JPY,150.00 +2024-02-01,CAD,GBP,0.5800 +2024-03-01,GBP,CAD,1.7200 +2024-02-01,CAD,EUR,0.6700 +2024-03-01,EUR,CAD,1.4900 diff --git a/contributor_tasks/bank-interest-legacy-java7/data/transactions.csv b/contributor_tasks/bank-interest-legacy-java7/data/transactions.csv new file mode 100644 index 0000000..6e3afa6 --- /dev/null +++ b/contributor_tasks/bank-interest-legacy-java7/data/transactions.csv @@ -0,0 +1,21 @@ +tx_id,account_id,effective_date,amount,currency,status,reversal_of +T001,A1001,2024-03-01,-2000.00,USD,POSTED, +T002,A1001,2024-03-02,-500.00,EUR,POSTED, +T003,A1001,2024-02-28,100.00,USD,PENDING, +T004,A1002,2024-02-15,50.00,USD,POSTED, +T004,A1002,2024-02-15,50.00,USD,POSTED, +T005,A1002,2024-02-16,-20.00,USD,POSTED, +T006,A1002,2024-02-20,20.00,USD,POSTED,T005 +T007,A1003,2024-03-01,10.00,USD,POSTED, +T008,A1003,2024-03-01,-10.00,EUR,POSTED,NOPE +T009,A1004,2024-01-15,1000.00,EUR,POSTED, +T010,A1004,2024-03-01,-1000.00,USD,POSTED,T009 +T011,A1002,2024-03-01,5.00,USD,POSTED, +T012,A1002,2024-03-05,5.00,USD,POSTED, +T013,A1001,2024-02-10,30.00,GBP,POSTED, +T014,A1001,2024-03-01,-10000.00,JPY,POSTED, +T015,A1002,2024-03-01,100.00,CAD,POSTED, +T016,A9999,2024-03-01,25.00,USD,POSTED, +T016,A1001,2024-03-01,25.00,USD,POSTED, +T017,A1003,2024-03-01,-15.00,USD,POSTED,T018 +T018,A1003,2024-03-01,15.00,USD,POSTED, diff --git a/contributor_tasks/bank-interest-legacy-java7/docker-compose.yaml b/contributor_tasks/bank-interest-legacy-java7/docker-compose.yaml new file mode 100644 index 0000000..e0ef70a --- /dev/null +++ b/contributor_tasks/bank-interest-legacy-java7/docker-compose.yaml @@ -0,0 +1,12 @@ +services: + client: + build: + dockerfile: Dockerfile + image: ${T_BENCH_TASK_DOCKER_CLIENT_IMAGE_NAME} + container_name: ${T_BENCH_TASK_DOCKER_CLIENT_CONTAINER_NAME} + command: [ "sh", "-c", "sleep infinity" ] + environment: + - TEST_DIR=${T_BENCH_TEST_DIR} + volumes: + - ${T_BENCH_TASK_LOGS_PATH}:${T_BENCH_CONTAINER_LOGS_PATH} + - ${T_BENCH_TASK_AGENT_LOGS_PATH}:${T_BENCH_CONTAINER_AGENT_LOGS_PATH} diff --git a/contributor_tasks/bank-interest-legacy-java7/run-tests.sh b/contributor_tasks/bank-interest-legacy-java7/run-tests.sh new file mode 100755 index 0000000..e581048 --- /dev/null +++ b/contributor_tasks/bank-interest-legacy-java7/run-tests.sh @@ -0,0 +1,3 @@ +#!/bin/bash +set -euo pipefail +pytest $TEST_DIR/test_outputs.py -v -rA diff --git a/contributor_tasks/bank-interest-legacy-java7/solution.sh b/contributor_tasks/bank-interest-legacy-java7/solution.sh new file mode 100644 index 0000000..a334735 --- /dev/null +++ b/contributor_tasks/bank-interest-legacy-java7/solution.sh @@ -0,0 +1,382 @@ +#!/bin/bash +set -euo pipefail +WORK=/tmp/bank_job +BUILD="$WORK/build" +SRC="$WORK/src" +rm -rf "$WORK" +mkdir -p "$BUILD" "$SRC" +cat >"$SRC/BankBatchJob.java" <<'JAVA' +import java.io.*; +import java.math.*; +import java.nio.charset.Charset; +import java.security.*; +import java.sql.*; +import java.text.*; +import java.util.*; +public class BankBatchJob { + private static final Charset UTF8 = Charset.forName("UTF-8"); + private static final TimeZone UTC = TimeZone.getTimeZone("UTC"); + private static final SimpleDateFormat DF = new SimpleDateFormat("yyyy-MM-dd", Locale.US); + private static final BigDecimal DAYS = new BigDecimal("365"); + private static final String JDBC_URL = "jdbc:h2:file:/app/output/bankdb;MODE=DB2"; + private static final String DB_USER = "sa"; + private static final String DB_PASS = ""; + private static final File ACCOUNTS = new File("/app/data/accounts.csv"); + private static final File TXS = new File("/app/data/transactions.csv"); + private static final File FX = new File("/app/data/fx_rates.csv"); + private static final File FX_PIVOTS = new File("/app/data/fx_pivots.csv"); + private static final File FEES = new File("/app/data/fee_rules.csv"); + private static final File BIZ_DATE = new File("/app/data/biz_date.txt"); + private static final File OUT_DIR = new File("/app/output"); + private static final File OUT_INTEREST = new File("/app/output/interest.csv"); + private static final File OUT_EXCEPTIONS = new File("/app/output/exceptions.csv"); + private static final File OUT_STATS = new File("/app/output/stats.json"); + static { DF.setLenient(false); DF.setTimeZone(UTC); } + static final class Account { + final String id, currency, feePlan; + final java.util.Date opened; + final BigDecimal start, base, promo, tierThr, tierApr; + final int promoDays; + Account(String id, java.util.Date opened, BigDecimal start, BigDecimal base, BigDecimal promo, int promoDays, + BigDecimal tierThr, BigDecimal tierApr, String currency, String feePlan) { + this.id=id; this.opened=opened; this.start=start; this.base=base; this.promo=promo; this.promoDays=promoDays; + this.tierThr=tierThr; this.tierApr=tierApr; this.currency=currency; this.feePlan=feePlan; + } + } + static final class TxnRow { + final int row; final String txId, acct, currency, status, revOf; final java.util.Date eff; final BigDecimal amt; + TxnRow(int row, String txId, String acct, java.util.Date eff, BigDecimal amt, String currency, String status, String revOf) { + this.row=row; this.txId=txId; this.acct=acct; this.eff=eff; this.amt=amt; this.currency=currency; this.status=status; this.revOf=revOf; + } + } + static final class Rate { final long day; final BigDecimal rate; Rate(long day, BigDecimal rate){this.day=day;this.rate=rate;} } + static final class FeeRule { + final String applies; final BigDecimal flat, pct, min, max; + FeeRule(String applies, BigDecimal flat, BigDecimal pct, BigDecimal min, BigDecimal max){ + this.applies=applies; this.flat=flat; this.pct=pct; this.min=min; this.max=max; + } + } + static final class Ex { final int row; final String txId, reason; Ex(int row,String txId,String reason){this.row=row;this.txId=txId;this.reason=reason;} } + static final class Interest { final String md5; final BigDecimal total; Interest(String md5, BigDecimal total){this.md5=md5;this.total=total;} } + public static void main(String[] args) throws Exception { + TimeZone.setDefault(UTC); + prepOut(); + String bizStr = readLine(BIZ_DATE); + java.util.Date biz = parseDate(bizStr); + List accounts = readAccounts(); + Map accountById = new HashMap(); + for (Account a : accounts) accountById.put(a.id, a); + List txs = readTxns(); + Map> fxRates = readFxRates(); + List pivots = readPivots(); + Map feeRules = readFeeRules(); + Map sums = new HashMap(); + List exs = new ArrayList(); + int validTxs = applyRules(biz, txs, accountById, fxRates, pivots, feeRules, sums, exs); + int skippedTxs = exs.size(); + writeExceptions(exs); + Interest interest = writeInterest(bizStr, biz, accounts, sums); + writeStats(bizStr, accounts.size(), validTxs, skippedTxs, interest.total, interest.md5); + buildDb(bizStr, accounts, txs, validTxs, skippedTxs, interest.md5); + } + private static void prepOut() { + OUT_DIR.mkdirs(); + File[] files = new File[] { + OUT_INTEREST, OUT_EXCEPTIONS, OUT_STATS, + new File("/app/output/bankdb.h2.db"), + new File("/app/output/bankdb.lock.db"), + new File("/app/output/bankdb.trace.db") + }; + for (File f : files) if (f.exists()) f.delete(); + } + private static int applyRules(java.util.Date biz, List txs, Map accounts, + Map> rates, List pivots, Map fees, + Map sums, List exs) { + Map first = new HashMap(); + for (TxnRow t : txs) if (!first.containsKey(t.txId)) first.put(t.txId, Integer.valueOf(t.row)); + Set reversed = new HashSet(); + Set missing = new HashSet(); + for (TxnRow t : txs) { + if (t.revOf == null) continue; + Integer ref = first.get(t.revOf); + if (ref == null) missing.add(Integer.valueOf(t.row)); + else { reversed.add(Integer.valueOf(t.row)); reversed.add(ref); } + } + int valid = 0; + for (TxnRow t : txs) { + String reason = null; + Integer firstRow = first.get(t.txId); + if (reversed.contains(Integer.valueOf(t.row))) reason = "REVERSED"; + else if (missing.contains(Integer.valueOf(t.row))) reason = "REVERSAL_MISSING"; + Account a = accounts.get(t.acct); + BigDecimal amt = t.amt; + if (reason == null) { + if (a == null) { + reason = "FX_MISSING"; + } else if (!t.currency.equals(a.currency)) { + BigDecimal rate = fxRate(rates, pivots, t.currency, a.currency, t.eff); + if (rate == null) reason = "FX_MISSING"; + else amt = amt.multiply(rate); + } + } + if (reason == null) { + if (t.eff.after(biz)) reason = "AFTER_BIZ_DATE"; + else if (!"POSTED".equals(t.status)) reason = t.status; + else if (firstRow != null && firstRow.intValue() != t.row) reason = "DUPLICATE"; + } + if (reason != null) { exs.add(new Ex(t.row, t.txId, reason)); continue; } + FeeRule rule = a == null ? null : fees.get(a.feePlan); + BigDecimal adj = applyFee(amt, rule); + BigDecimal cur = sums.get(t.acct); if (cur == null) cur = BigDecimal.ZERO; + sums.put(t.acct, cur.add(adj)); + valid++; + } + Collections.sort(exs, new Comparator() { public int compare(Ex a, Ex b){ return a.row - b.row; } }); + return valid; + } + private static BigDecimal bestRate(Map> rates, String from, String to, java.util.Date eff) { + long day = day(eff); + BigDecimal direct = null; + long directDay = Long.MIN_VALUE; + List list = rates.get(from + "->" + to); + if (list != null) { + for (Rate r : list) { + if (r.day <= day) { direct = r.rate; directDay = r.day; } + else break; + } + } + BigDecimal reverse = null; + long reverseDay = Long.MIN_VALUE; + list = rates.get(to + "->" + from); + if (list != null) { + for (Rate r : list) { + if (r.day <= day) { reverse = r.rate; reverseDay = r.day; } + else break; + } + } + if (direct == null && reverse == null) return null; + if (reverse == null || (direct != null && directDay >= reverseDay)) return direct; + if (reverse.compareTo(BigDecimal.ZERO) == 0) return null; + return BigDecimal.ONE.divide(reverse, 10, RoundingMode.HALF_EVEN); + } + private static BigDecimal fxRate(Map> rates, List pivots, + String from, String to, java.util.Date eff) { + if (from.equals(to)) return BigDecimal.ONE; + BigDecimal out = bestRate(rates, from, to, eff); + if (out != null) return out; + for (String pivot : pivots) { + if (pivot.equals(from) || pivot.equals(to)) continue; + BigDecimal leg1 = bestRate(rates, from, pivot, eff); + if (leg1 == null) continue; + BigDecimal leg2 = bestRate(rates, pivot, to, eff); + if (leg2 == null) continue; + return leg1.multiply(leg2).setScale(10, RoundingMode.HALF_EVEN); + } + return null; + } + private static BigDecimal applyFee(BigDecimal amt, FeeRule rule) { + if (rule == null) return amt.setScale(2, RoundingMode.HALF_EVEN); + int sign = amt.compareTo(BigDecimal.ZERO); + boolean applies = false; + if ("ALL".equals(rule.applies)) applies = sign != 0; + else if ("DEBIT".equals(rule.applies)) applies = sign < 0; + else if ("CREDIT".equals(rule.applies)) applies = sign > 0; + if (!applies) return amt.setScale(2, RoundingMode.HALF_EVEN); + BigDecimal fee = rule.flat.add(amt.abs().multiply(rule.pct)).setScale(2, RoundingMode.HALF_EVEN); + if (fee.compareTo(rule.min) < 0) fee = rule.min; + if (fee.compareTo(rule.max) > 0) fee = rule.max; + fee = fee.setScale(2, RoundingMode.HALF_EVEN); + return amt.subtract(fee).setScale(2, RoundingMode.HALF_EVEN); + } + private static void writeExceptions(List exs) throws Exception { + BufferedWriter w = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(OUT_EXCEPTIONS), UTF8)); + try { + w.write("row_num,tx_id,reason\n"); + for (Ex e : exs) w.write(e.row + "," + e.txId + "," + e.reason + "\n"); + } finally { w.close(); } + } + private static Interest writeInterest(String bizStr, java.util.Date biz, List accounts, Map sums) throws Exception { + List sorted = new ArrayList(accounts); + Collections.sort(sorted, new Comparator() { public int compare(Account a, Account b){ return a.id.compareTo(b.id); } }); + MessageDigest md = MessageDigest.getInstance("MD5"); + DigestOutputStream dos = new DigestOutputStream(new FileOutputStream(OUT_INTEREST), md); + BufferedWriter w = new BufferedWriter(new OutputStreamWriter(dos, UTF8)); + BigDecimal total = BigDecimal.ZERO; + try { + w.write("account_id,biz_date,eod_balance,apr,daily_interest\n"); + for (Account a : sorted) { + BigDecimal delta = sums.get(a.id); if (delta == null) delta = BigDecimal.ZERO; + BigDecimal eod = a.start.add(delta).setScale(2, RoundingMode.HALF_EVEN); + BigDecimal apr = computeApr(a, eod, biz).setScale(6, RoundingMode.HALF_EVEN); + BigDecimal dailyRaw = dailyInterestRaw(eod, apr); + BigDecimal daily = dailyRaw.setScale(2, RoundingMode.HALF_EVEN); + total = total.add(dailyRaw); + w.write(a.id + "," + bizStr + "," + eod.toPlainString() + "," + apr.toPlainString() + "," + daily.toPlainString() + "\n"); + } + } finally { w.close(); } + return new Interest(toHex(md.digest()), total.setScale(2, RoundingMode.HALF_EVEN)); + } + private static void writeStats(String biz, int accounts, int valid, int skipped, BigDecimal total, String md5) throws Exception { + String j = "{\"biz_date\":\"" + biz + "\",\"accounts\":" + accounts + ",\"valid_txs\":" + valid + ",\"skipped_txs\":" + skipped + + ",\"total_interest\":\"" + total.setScale(2, RoundingMode.HALF_EVEN).toPlainString() + "\",\"report_md5\":\"" + md5 + "\"}"; + FileOutputStream out = new FileOutputStream(OUT_STATS); + try { out.write(j.getBytes(UTF8)); } finally { out.close(); } + } + private static void buildDb(String biz, List accounts, List txs, int valid, int skipped, String md5) throws Exception { + Class.forName("org.h2.Driver"); + Connection c = DriverManager.getConnection(JDBC_URL, DB_USER, DB_PASS); + try { + c.setAutoCommit(false); + Statement st = c.createStatement(); + st.execute("DROP TABLE IF EXISTS RUN_AUDIT"); + st.execute("DROP TABLE IF EXISTS TXNS"); + st.execute("DROP TABLE IF EXISTS ACCOUNTS"); + st.execute("CREATE TABLE ACCOUNTS (account_id VARCHAR PRIMARY KEY, opened_at DATE, start_balance DECIMAL(18,2), base_apr DECIMAL(18,6), promo_apr DECIMAL(18,6), promo_days INT, tier_threshold DECIMAL(18,2), tier_apr DECIMAL(18,6), currency VARCHAR, fee_plan VARCHAR)"); + st.execute("CREATE TABLE TXNS (row_num INT PRIMARY KEY, tx_id VARCHAR, account_id VARCHAR, effective_date DATE, amount DECIMAL(18,2), currency VARCHAR, status VARCHAR, reversal_of VARCHAR)"); + st.execute("CREATE TABLE RUN_AUDIT (jdbc_url VARCHAR, biz_date VARCHAR, accounts INT, valid_txs INT, skipped_txs INT, report_md5 VARCHAR)"); + PreparedStatement pa = c.prepareStatement("INSERT INTO ACCOUNTS(account_id,opened_at,start_balance,base_apr,promo_apr,promo_days,tier_threshold,tier_apr,currency,fee_plan) VALUES(?,?,?,?,?,?,?,?,?,?)"); + for (Account a : accounts) { + pa.setString(1, a.id); pa.setDate(2, new java.sql.Date(a.opened.getTime())); + pa.setBigDecimal(3, a.start.setScale(2, RoundingMode.HALF_EVEN)); + pa.setBigDecimal(4, a.base.setScale(6, RoundingMode.HALF_EVEN)); + pa.setBigDecimal(5, a.promo.setScale(6, RoundingMode.HALF_EVEN)); + pa.setInt(6, a.promoDays); + pa.setBigDecimal(7, a.tierThr.setScale(2, RoundingMode.HALF_EVEN)); + pa.setBigDecimal(8, a.tierApr.setScale(6, RoundingMode.HALF_EVEN)); + pa.setString(9, a.currency); + pa.setString(10, a.feePlan); + pa.addBatch(); + } + pa.executeBatch(); pa.close(); + PreparedStatement pt = c.prepareStatement("INSERT INTO TXNS(row_num,tx_id,account_id,effective_date,amount,currency,status,reversal_of) VALUES(?,?,?,?,?,?,?,?)"); + for (TxnRow t : txs) { + pt.setInt(1, t.row); pt.setString(2, t.txId); pt.setString(3, t.acct); pt.setDate(4, new java.sql.Date(t.eff.getTime())); + pt.setBigDecimal(5, t.amt.setScale(2, RoundingMode.HALF_EVEN)); pt.setString(6, t.currency); + pt.setString(7, t.status); + if (t.revOf == null) pt.setNull(8, Types.VARCHAR); else pt.setString(8, t.revOf); + pt.addBatch(); + } + pt.executeBatch(); pt.close(); + PreparedStatement pr = c.prepareStatement("INSERT INTO RUN_AUDIT(jdbc_url,biz_date,accounts,valid_txs,skipped_txs,report_md5) VALUES(?,?,?,?,?,?)"); + pr.setString(1, JDBC_URL); pr.setString(2, biz); pr.setInt(3, accounts.size()); pr.setInt(4, valid); pr.setInt(5, skipped); pr.setString(6, md5); + pr.executeUpdate(); pr.close(); + c.commit(); + } finally { c.close(); } + } + private static BigDecimal computeApr(Account a, BigDecimal eod, java.util.Date biz) { + BigDecimal apr = a.base; + if (eod.compareTo(a.tierThr) >= 0) apr = apr.add(a.tierApr); + if (a.promoDays > 0 && daysBetween(a.opened, biz) < a.promoDays) apr = apr.add(a.promo); + return apr; + } + private static BigDecimal dailyInterestRaw(BigDecimal eod, BigDecimal apr) { + if (eod.compareTo(BigDecimal.ZERO) <= 0) return BigDecimal.ZERO.setScale(10, RoundingMode.HALF_EVEN); + return eod.multiply(apr).divide(DAYS, 10, RoundingMode.HALF_EVEN); + } + private static int daysBetween(java.util.Date open, java.util.Date biz) { return (int) ((biz.getTime() - open.getTime()) / 86400000L); } + private static long day(java.util.Date d) { return d.getTime() / 86400000L; } + private static List readAccounts() throws Exception { + BufferedReader r = new BufferedReader(new InputStreamReader(new FileInputStream(ACCOUNTS), UTF8)); + try { + String header = r.readLine(); if (header == null) return new ArrayList(); + assertHeader(header, "account_id","opened_at","start_balance","base_apr","promo_apr","promo_days","tier_threshold","tier_apr","currency","fee_plan"); + List out = new ArrayList(); + String line; + while ((line = r.readLine()) != null) { + if (line.trim().isEmpty()) continue; + String[] p = split(line, 10); + out.add(new Account(p[0], parseDate(p[1]), new BigDecimal(p[2]), new BigDecimal(p[3]), new BigDecimal(p[4]), + Integer.parseInt(p[5]), new BigDecimal(p[6]), new BigDecimal(p[7]), p[8], p[9])); + } + return out; + } finally { r.close(); } + } + private static List readTxns() throws Exception { + BufferedReader r = new BufferedReader(new InputStreamReader(new FileInputStream(TXS), UTF8)); + try { + String header = r.readLine(); if (header == null) return new ArrayList(); + assertHeader(header, "tx_id","account_id","effective_date","amount","currency","status","reversal_of"); + List out = new ArrayList(); + String line; int row = 0; + while ((line = r.readLine()) != null) { + if (line.trim().isEmpty()) continue; + row++; + String[] p = split(line, 7); + String revOf = p[6].isEmpty() ? null : p[6]; + out.add(new TxnRow(row, p[0], p[1], parseDate(p[2]), new BigDecimal(p[3]), p[4], p[5], revOf)); + } + return out; + } finally { r.close(); } + } + private static Map> readFxRates() throws Exception { + BufferedReader r = new BufferedReader(new InputStreamReader(new FileInputStream(FX), UTF8)); + try { + String header = r.readLine(); if (header == null) return new HashMap>(); + assertHeader(header, "rate_date","from_ccy","to_ccy","rate"); + Map> out = new HashMap>(); + String line; + while ((line = r.readLine()) != null) { + if (line.trim().isEmpty()) continue; + String[] p = split(line, 4); + long day = day(parseDate(p[0])); + String key = p[1] + "->" + p[2]; + List list = out.get(key); + if (list == null) { list = new ArrayList(); out.put(key, list); } + list.add(new Rate(day, new BigDecimal(p[3]))); + } + for (List list : out.values()) { + Collections.sort(list, new Comparator() { public int compare(Rate a, Rate b){ return (a.day < b.day) ? -1 : (a.day == b.day ? 0 : 1); } }); + } + return out; + } finally { r.close(); } + } + private static List readPivots() throws Exception { + BufferedReader r = new BufferedReader(new InputStreamReader(new FileInputStream(FX_PIVOTS), UTF8)); + try { + String header = r.readLine(); if (header == null) return new ArrayList(); + assertHeader(header, "pivot"); + List out = new ArrayList(); + String line; + while ((line = r.readLine()) != null) { + if (line.trim().isEmpty()) continue; + String[] p = split(line, 1); + out.add(p[0]); + } + return out; + } finally { r.close(); } + } + private static Map readFeeRules() throws Exception { + BufferedReader r = new BufferedReader(new InputStreamReader(new FileInputStream(FEES), UTF8)); + try { + String header = r.readLine(); if (header == null) return new HashMap(); + assertHeader(header, "fee_plan","applies_to","flat_fee","pct_fee","min_fee","max_fee"); + Map out = new HashMap(); + String line; + while ((line = r.readLine()) != null) { + if (line.trim().isEmpty()) continue; + String[] p = split(line, 6); + out.put(p[0], new FeeRule(p[1], new BigDecimal(p[2]), new BigDecimal(p[3]), new BigDecimal(p[4]), new BigDecimal(p[5]))); + } + return out; + } finally { r.close(); } + } + private static void assertHeader(String headerLine, String... cols) { + String[] got = headerLine.split(",", -1); + if (got.length != cols.length) die("bad CSV header"); + for (int i = 0; i < cols.length; i++) if (!cols[i].equals(got[i].trim())) die("bad CSV header col " + i); + } + private static String[] split(String line, int expected) { String[] p = line.split(",", -1); if (p.length != expected) die("bad CSV row"); return p; } + private static String readLine(File f) throws Exception { + BufferedReader r = new BufferedReader(new InputStreamReader(new FileInputStream(f), UTF8)); + try { String s = r.readLine(); if (s == null) die("missing biz_date"); return s.trim(); } finally { r.close(); } + } + private static java.util.Date parseDate(String s) throws Exception { synchronized (DF) { return DF.parse(s); } } + private static String toHex(byte[] b) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < b.length; i++){ int x = b[i] & 0xff; if (x < 16) sb.append('0'); sb.append(Integer.toHexString(x)); } return sb.toString(); } + private static void die(String msg) { System.err.println(msg); System.exit(1); } +} +JAVA +javac -source 1.7 -target 1.7 -cp "/opt/legacy-lib/*" -d "$BUILD" "$SRC/BankBatchJob.java" +jar cf /app/job.jar -C "$BUILD" . +rm -rf /app/output +mkdir -p /app/output +java -cp "/app/job.jar:/opt/legacy-lib/*" BankBatchJob diff --git a/contributor_tasks/bank-interest-legacy-java7/task.yaml b/contributor_tasks/bank-interest-legacy-java7/task.yaml new file mode 100644 index 0000000..f9863eb --- /dev/null +++ b/contributor_tasks/bank-interest-legacy-java7/task.yaml @@ -0,0 +1,50 @@ +instruction: |- + Old nightly batch for Java 7. Put BankBatchJob in /app/job.jar, compile to Java 7 bytecode, and run it. Running it again on the same day should give the same results. + + Inputs are in /app/data: + - accounts.csv + - transactions.csv + - biz_date.txt + - fx_rates.csv + - fx_pivots.csv + - fee_rules.csv + + Outputs are in /app/output: + - interest.csv, LF + trailing newline. + Account, business date, end-of-day balance, annual percentage rate (APR), and daily interest. + apr 6dp, eod_balance, and daily_interest 2dp, sorted by account_id. + - exceptions.csv, LF + trailing newline. + Title: row_num, tx_id, and reason. + Row_num is 1-based data row. + - stats.json is single-line JSON without newlines. + Business date, accounts, valid_txs, skipped_txs, total_interest, and report_md5. + report_md5 is md5 of interest.csv bytes. total_interest=2dp. + - bankdb.h2.db H2 database with ACCOUNTS and TXNS (row_num 1..N). + There is one row in RUN_AUDIT: jdbc_url, biz_date, accounts, valid_txs, skipped_txs, and report_md5. + + Use these reasons in file order: + - REVERSED (both rows if reversal_of points to any tx_id in file, even if referenced row appears later) + - REVERSAL_MISSING + - FX_MISSING (before FX) + - AFTER_BIZ_DATE + - status!=POSTED (use status) + - DUPLICATE (any tx_id after first appearance, even if first row was invalid) + + For FX, use latest rate_date <= effective_date. If direct and reverse exist, choose the newer one; if tied, use direct. reverse 1/rate (HALF_EVEN 10dp). If no direct/reverse, try fx_pivots.csv in order: solve each leg the same way, multiply, round HALF_EVEN 10dp (skip pivots equal to from/to). The first pivot wins; if not, FX_MISSING. + Fees: First, change currency to account's. fee = flat fee + percentage fee * abs(amount), rounded to nearest half-even 2dp and limited to [min_fee,max_fee]. adjusted = amount - fee, rounded Half-even 2dp. This applies to DEBIT, CREDIT, and ALL fee plans. eod_balance = start_balance + sum(valid adjusted), rounded to two decimal places. If eod_balance is greater than or equal to tier_threshold, APR = base_apr + tier_apr + promo_apr (if promo_days > 0 and days_since_opened_at < promo_days; opened_at = day 0). + Interest uses principal = max(eod_balance,0). daily_interest_raw = principal * apr/365 (HALF_EVEN 10dp). interest.csv rounds daily_interest_raw to two decimal places. total_interest is sum(daily_interest_raw, keep 10dp) rounded to 2dp. + + Don't touch /app/data. H2 1.3 DB2 mode; user sa has a blank password. JDBC URL must be a single literal string that starts with "jdbc:h2:" and has "MODE=DB2" and "/app/output/bankdb" in it. Use at runtime is required. Java 7-only bytecode + +difficulty: hard +category: legacy +tags: + - java + - legacy + - banking +parser_name: pytest +max_agent_timeout_sec: 300.0 +max_test_timeout_sec: 120.0 +run_tests_in_same_shell: false +expert_time_estimate_min: 20 +junior_time_estimate_min: 40 diff --git a/contributor_tasks/bank-interest-legacy-java7/tests/OutputValidator.java b/contributor_tasks/bank-interest-legacy-java7/tests/OutputValidator.java new file mode 100644 index 0000000..bb91163 --- /dev/null +++ b/contributor_tasks/bank-interest-legacy-java7/tests/OutputValidator.java @@ -0,0 +1,732 @@ +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.Charset; +import java.security.MessageDigest; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TimeZone; + +public class OutputValidator { + private static final Charset UTF8 = Charset.forName("UTF-8"); + private static final TimeZone UTC = TimeZone.getTimeZone("UTC"); + private static final SimpleDateFormat DF = new SimpleDateFormat("yyyy-MM-dd", Locale.US); + private static final BigDecimal DAYS_IN_YEAR = new BigDecimal("365"); + + private static final File ACCOUNTS = new File("/app/data/accounts.csv"); + private static final File TXS = new File("/app/data/transactions.csv"); + private static final File BIZ_DATE = new File("/app/data/biz_date.txt"); + private static final File FX = new File("/app/data/fx_rates.csv"); + private static final File FX_PIVOTS = new File("/app/data/fx_pivots.csv"); + private static final File FEES = new File("/app/data/fee_rules.csv"); + + private static final File OUT_INTEREST = new File("/app/output/interest.csv"); + private static final File OUT_EXCEPTIONS = new File("/app/output/exceptions.csv"); + private static final File OUT_STATS = new File("/app/output/stats.json"); + private static final File OUT_DB = new File("/app/output/bankdb.h2.db"); + + static { + DF.setLenient(false); + DF.setTimeZone(UTC); + } + + static final class Account { + final String accountId; + final java.util.Date openedAt; + final BigDecimal startBalance; + final BigDecimal baseApr; + final BigDecimal promoApr; + final int promoDays; + final BigDecimal tierThreshold; + final BigDecimal tierApr; + final String currency; + final String feePlan; + + Account(String accountId, java.util.Date openedAt, BigDecimal startBalance, BigDecimal baseApr, BigDecimal promoApr, + int promoDays, BigDecimal tierThreshold, BigDecimal tierApr, String currency, String feePlan) { + this.accountId = accountId; + this.openedAt = openedAt; + this.startBalance = startBalance; + this.baseApr = baseApr; + this.promoApr = promoApr; + this.promoDays = promoDays; + this.tierThreshold = tierThreshold; + this.tierApr = tierApr; + this.currency = currency; + this.feePlan = feePlan; + } + } + + static final class TxnRow { + final int rowNum; + final String txId; + final String accountId; + final java.util.Date effectiveDate; + final BigDecimal amount; + final String currency; + final String status; + final String reversalOf; + + TxnRow(int rowNum, String txId, String accountId, java.util.Date effectiveDate, BigDecimal amount, + String currency, String status, String reversalOf) { + this.rowNum = rowNum; + this.txId = txId; + this.accountId = accountId; + this.effectiveDate = effectiveDate; + this.amount = amount; + this.currency = currency; + this.status = status; + this.reversalOf = reversalOf; + } + } + + static final class Rate { + final long day; + final BigDecimal rate; + + Rate(long day, BigDecimal rate) { + this.day = day; + this.rate = rate; + } + } + + static final class FeeRule { + final String appliesTo; + final BigDecimal flatFee; + final BigDecimal pctFee; + final BigDecimal minFee; + final BigDecimal maxFee; + + FeeRule(String appliesTo, BigDecimal flatFee, BigDecimal pctFee, BigDecimal minFee, BigDecimal maxFee) { + this.appliesTo = appliesTo; + this.flatFee = flatFee; + this.pctFee = pctFee; + this.minFee = minFee; + this.maxFee = maxFee; + } + } + + static final class ExceptionRow { + final int rowNum; + final String txId; + final String reason; + + ExceptionRow(int rowNum, String txId, String reason) { + this.rowNum = rowNum; + this.txId = txId; + this.reason = reason; + } + } + + static final class InterestOut { + final String csv; + final String md5; + final BigDecimal total; + + InterestOut(String csv, String md5, BigDecimal total) { + this.csv = csv; + this.md5 = md5; + this.total = total; + } + } + + public static void main(String[] args) throws Exception { + TimeZone.setDefault(UTC); + + assertFile(ACCOUNTS, "accounts.csv missing"); + assertFile(TXS, "transactions.csv missing"); + assertFile(BIZ_DATE, "biz_date.txt missing"); + assertFile(FX, "fx_rates.csv missing"); + assertFile(FX_PIVOTS, "fx_pivots.csv missing"); + assertFile(FEES, "fee_rules.csv missing"); + assertFile(OUT_INTEREST, "interest.csv missing"); + assertFile(OUT_EXCEPTIONS, "exceptions.csv missing"); + assertFile(OUT_STATS, "stats.json missing"); + assertFile(OUT_DB, "bankdb.h2.db missing"); + + assertMd5(ACCOUNTS, "505829c96c9fa568cf70abf7c52247d5"); + assertMd5(TXS, "ad100f16f2e9b52929da8eca55b3edad"); + assertMd5(BIZ_DATE, "4a58c5b49dafc91047e9f999ede44581"); + assertMd5(FX, "d351af4cc717a6d8d9353f53d8616c05"); + assertMd5(FX_PIVOTS, "31e9e031a576af3d943424f47d9f9390"); + assertMd5(FEES, "a2a8a66a3db722b2f4a051612c8e8bd7"); + + String bizDate = readFirstLine(BIZ_DATE); + java.util.Date biz = parseDate(bizDate); + + List accounts = readAccounts(ACCOUNTS); + Map accountById = new HashMap(); + for (Account a : accounts) { + accountById.put(a.accountId, a); + } + + List txs = readTransactions(TXS); + Map> fxRates = readFxRates(FX); + List pivots = readPivots(FX_PIVOTS); + Map feeRules = readFeeRules(FEES); + + Map sums = new HashMap(); + List exceptions = new ArrayList(); + int validTxs = applyTxnRulesAndBuildSums(biz, txs, accountById, fxRates, pivots, feeRules, sums, exceptions); + int skippedTxs = exceptions.size(); + + String expectedExceptionsCsv = buildExpectedExceptionsCsv(exceptions); + byte[] gotExceptionsBytes = readAllBytes(OUT_EXCEPTIONS); + assertLfOnly(gotExceptionsBytes, "exceptions.csv"); + if (!bytesEqual(expectedExceptionsCsv.getBytes(UTF8), gotExceptionsBytes)) { + die("exceptions.csv mismatch"); + } + + InterestOut interest = buildExpectedInterestCsv(bizDate, biz, accounts, sums); + byte[] expectedInterestBytes = interest.csv.getBytes(UTF8); + byte[] gotInterestBytes = readAllBytes(OUT_INTEREST); + assertLfOnly(gotInterestBytes, "interest.csv"); + if (!bytesEqual(expectedInterestBytes, gotInterestBytes)) { + die("interest.csv mismatch"); + } + + String expectedStatsJson = buildExpectedStatsJson(bizDate, accounts.size(), validTxs, skippedTxs, interest.total, interest.md5); + byte[] gotStatsBytes = readAllBytes(OUT_STATS); + assertNoNewlines(gotStatsBytes, "stats.json"); + String gotStats = new String(gotStatsBytes, UTF8); + if (!expectedStatsJson.equals(gotStats)) { + die("stats.json mismatch"); + } + + validateDb(accounts.size(), txs.size(), bizDate, validTxs, skippedTxs, interest.md5); + } + + private static int applyTxnRulesAndBuildSums(java.util.Date biz, List txs, + Map accounts, + Map> rates, + List pivots, + Map fees, + Map sums, + List exceptions) { + Map firstRowByTxId = new HashMap(); + for (int i = 0; i < txs.size(); i++) { + TxnRow row = txs.get(i); + if (!firstRowByTxId.containsKey(row.txId)) { + firstRowByTxId.put(row.txId, Integer.valueOf(row.rowNum)); + } + } + + Set reversedRows = new HashSet(); + Set reversalMissingRows = new HashSet(); + for (int i = 0; i < txs.size(); i++) { + TxnRow row = txs.get(i); + if (row.reversalOf == null) continue; + Integer refRow = firstRowByTxId.get(row.reversalOf); + if (refRow == null) { + reversalMissingRows.add(Integer.valueOf(row.rowNum)); + } else { + reversedRows.add(Integer.valueOf(row.rowNum)); + reversedRows.add(refRow); + } + } + + int valid = 0; + for (int i = 0; i < txs.size(); i++) { + TxnRow row = txs.get(i); + String reason = null; + Integer firstRow = firstRowByTxId.get(row.txId); + + if (reversedRows.contains(Integer.valueOf(row.rowNum))) { + reason = "REVERSED"; + } else if (reversalMissingRows.contains(Integer.valueOf(row.rowNum))) { + reason = "REVERSAL_MISSING"; + } + + Account account = accounts.get(row.accountId); + BigDecimal amount = row.amount; + if (reason == null) { + if (account == null) { + reason = "FX_MISSING"; + } else if (!row.currency.equals(account.currency)) { + BigDecimal rate = fxRate(rates, pivots, row.currency, account.currency, row.effectiveDate); + if (rate == null) { + reason = "FX_MISSING"; + } else { + amount = amount.multiply(rate); + } + } + } + + if (reason == null) { + if (row.effectiveDate.after(biz)) { + reason = "AFTER_BIZ_DATE"; + } else if (!"POSTED".equals(row.status)) { + reason = row.status; + } else if (firstRow != null && firstRow.intValue() != row.rowNum) { + reason = "DUPLICATE"; + } + } + + if (reason != null) { + exceptions.add(new ExceptionRow(row.rowNum, row.txId, reason)); + continue; + } + + FeeRule rule = fees.get(account.feePlan); + BigDecimal adjusted = applyFee(amount, rule); + BigDecimal cur = sums.get(account.accountId); + if (cur == null) cur = BigDecimal.ZERO; + sums.put(account.accountId, cur.add(adjusted)); + valid += 1; + } + + Collections.sort(exceptions, new Comparator() { + public int compare(ExceptionRow a, ExceptionRow b) { return a.rowNum - b.rowNum; } + }); + return valid; + } + + private static BigDecimal bestRate(Map> rates, String from, String to, java.util.Date effective) { + long day = day(effective); + BigDecimal direct = null; + long directDay = Long.MIN_VALUE; + List list = rates.get(from + "->" + to); + if (list != null) { + for (int i = 0; i < list.size(); i++) { + Rate r = list.get(i); + if (r.day <= day) { direct = r.rate; directDay = r.day; } + else break; + } + } + BigDecimal reverse = null; + long reverseDay = Long.MIN_VALUE; + list = rates.get(to + "->" + from); + if (list != null) { + for (int i = 0; i < list.size(); i++) { + Rate r = list.get(i); + if (r.day <= day) { reverse = r.rate; reverseDay = r.day; } + else break; + } + } + if (direct == null && reverse == null) return null; + if (reverse == null || (direct != null && directDay >= reverseDay)) return direct; + if (reverse.compareTo(BigDecimal.ZERO) == 0) return null; + return BigDecimal.ONE.divide(reverse, 10, RoundingMode.HALF_EVEN); + } + + private static BigDecimal fxRate(Map> rates, List pivots, + String from, String to, java.util.Date effective) { + if (from.equals(to)) return BigDecimal.ONE; + BigDecimal out = bestRate(rates, from, to, effective); + if (out != null) return out; + for (int i = 0; i < pivots.size(); i++) { + String pivot = pivots.get(i); + if (pivot.equals(from) || pivot.equals(to)) continue; + BigDecimal leg1 = bestRate(rates, from, pivot, effective); + if (leg1 == null) continue; + BigDecimal leg2 = bestRate(rates, pivot, to, effective); + if (leg2 == null) continue; + return leg1.multiply(leg2).setScale(10, RoundingMode.HALF_EVEN); + } + return null; + } + + private static BigDecimal applyFee(BigDecimal amt, FeeRule rule) { + if (rule == null) return amt.setScale(2, RoundingMode.HALF_EVEN); + int sign = amt.compareTo(BigDecimal.ZERO); + boolean applies = false; + if ("ALL".equals(rule.appliesTo)) applies = sign != 0; + else if ("DEBIT".equals(rule.appliesTo)) applies = sign < 0; + else if ("CREDIT".equals(rule.appliesTo)) applies = sign > 0; + if (!applies) return amt.setScale(2, RoundingMode.HALF_EVEN); + BigDecimal fee = rule.flatFee.add(amt.abs().multiply(rule.pctFee)).setScale(2, RoundingMode.HALF_EVEN); + if (fee.compareTo(rule.minFee) < 0) fee = rule.minFee; + if (fee.compareTo(rule.maxFee) > 0) fee = rule.maxFee; + fee = fee.setScale(2, RoundingMode.HALF_EVEN); + return amt.subtract(fee).setScale(2, RoundingMode.HALF_EVEN); + } + + private static InterestOut buildExpectedInterestCsv(String bizDate, java.util.Date biz, + List accounts, Map sums) throws Exception { + List sorted = new ArrayList(accounts); + Collections.sort(sorted, new Comparator() { + public int compare(Account a, Account b) { return a.accountId.compareTo(b.accountId); } + }); + + StringBuilder sb = new StringBuilder(); + sb.append("account_id,biz_date,eod_balance,apr,daily_interest\n"); + BigDecimal total = BigDecimal.ZERO; + for (int i = 0; i < sorted.size(); i++) { + Account a = sorted.get(i); + BigDecimal delta = sums.get(a.accountId); + if (delta == null) delta = BigDecimal.ZERO; + BigDecimal eod = a.startBalance.add(delta).setScale(2, RoundingMode.HALF_EVEN); + BigDecimal apr = computeApr(a, eod, biz).setScale(6, RoundingMode.HALF_EVEN); + BigDecimal dailyRaw = dailyInterest(eod, apr); + BigDecimal daily = dailyRaw.setScale(2, RoundingMode.HALF_EVEN); + total = total.add(dailyRaw); + sb.append(a.accountId).append(',').append(bizDate).append(',') + .append(eod.toPlainString()).append(',') + .append(apr.toPlainString()).append(',') + .append(daily.toPlainString()).append('\n'); + } + byte[] bytes = sb.toString().getBytes(UTF8); + return new InterestOut(sb.toString(), md5Hex(bytes), total.setScale(2, RoundingMode.HALF_EVEN)); + } + + private static String buildExpectedStatsJson(String bizDate, int accounts, int valid, int skipped, + BigDecimal total, String md5) { + return "{\"biz_date\":\"" + bizDate + "\",\"accounts\":" + accounts + + ",\"valid_txs\":" + valid + ",\"skipped_txs\":" + skipped + + ",\"total_interest\":\"" + total.setScale(2, RoundingMode.HALF_EVEN).toPlainString() + "\"" + + ",\"report_md5\":\"" + md5 + "\"}"; + } + + private static void validateDb(int expectedAccounts, int expectedTxs, String bizDate, + int validTxs, int skippedTxs, String expectedMd5) throws Exception { + Class.forName("org.h2.Driver"); + Connection conn = DriverManager.getConnection("jdbc:h2:/app/output/bankdb;MODE=DB2;IFEXISTS=TRUE", "sa", ""); + try { + assertTable(conn, "ACCOUNTS"); + assertTable(conn, "TXNS"); + assertTable(conn, "RUN_AUDIT"); + + assertCount(conn, "ACCOUNTS", expectedAccounts); + + int txCount = count(conn, "TXNS"); + Statement st = conn.createStatement(); + ResultSet rs = st.executeQuery("SELECT MIN(row_num), MAX(row_num), COUNT(DISTINCT row_num) FROM TXNS"); + rs.next(); + int minRow = rs.getInt(1); + int maxRow = rs.getInt(2); + int distinctRow = rs.getInt(3); + rs.close(); + st.close(); + if (minRow != 1 || maxRow != txCount || distinctRow != txCount || txCount != expectedTxs) { + throw new RuntimeException("TXNS row_num must be 1..N without gaps"); + } + + st = conn.createStatement(); + rs = st.executeQuery("SELECT jdbc_url,biz_date,accounts,valid_txs,skipped_txs,report_md5 FROM RUN_AUDIT"); + if (!rs.next()) { + throw new RuntimeException("RUN_AUDIT missing row"); + } + String jdbcUrl = rs.getString(1); + String gotBizDate = rs.getString(2); + int accounts = rs.getInt(3); + int gotValid = rs.getInt(4); + int gotSkipped = rs.getInt(5); + String md5 = rs.getString(6); + + if (jdbcUrl == null || jdbcUrl.indexOf("jdbc:h2:") != 0 || jdbcUrl.indexOf("/app/output/bankdb") < 0 || jdbcUrl.indexOf("MODE=DB2") < 0) { + throw new RuntimeException("RUN_AUDIT.jdbc_url must include jdbc:h2:, /app/output/bankdb, and MODE=DB2"); + } + if (!bizDate.equals(gotBizDate)) throw new RuntimeException("RUN_AUDIT.biz_date mismatch"); + if (accounts != expectedAccounts) throw new RuntimeException("RUN_AUDIT.accounts mismatch"); + if (gotValid != validTxs) throw new RuntimeException("RUN_AUDIT.valid_txs mismatch"); + if (gotSkipped != skippedTxs) throw new RuntimeException("RUN_AUDIT.skipped_txs mismatch"); + if (!expectedMd5.equals(md5)) throw new RuntimeException("RUN_AUDIT.report_md5 mismatch"); + + if (rs.next()) throw new RuntimeException("RUN_AUDIT must have exactly one row"); + rs.close(); + st.close(); + } finally { + conn.close(); + } + } + + private static void assertTable(Connection conn, String name) throws Exception { + DatabaseMetaData md = conn.getMetaData(); + ResultSet rs = md.getTables(null, null, name, null); + try { + if (!rs.next()) throw new RuntimeException("missing table: " + name); + } finally { + rs.close(); + } + } + + private static int count(Connection conn, String table) throws Exception { + Statement st = conn.createStatement(); + ResultSet rs = st.executeQuery("SELECT COUNT(*) FROM " + table); + rs.next(); + int out = rs.getInt(1); + rs.close(); + st.close(); + return out; + } + + private static void assertCount(Connection conn, String table, int expected) throws Exception { + int got = count(conn, table); + if (got != expected) throw new RuntimeException(table + " row count mismatch"); + } + + private static List readAccounts(File file) throws Exception { + List out = new ArrayList(); + BufferedReader r = new BufferedReader(new InputStreamReader(new java.io.FileInputStream(file), UTF8)); + try { + String header = r.readLine(); + assertHeader(header, "account_id","opened_at","start_balance","base_apr","promo_apr","promo_days","tier_threshold","tier_apr","currency","fee_plan"); + String line; + while ((line = r.readLine()) != null) { + if (line.trim().length() == 0) continue; + String[] parts = line.split(",", -1); + out.add(new Account( + parts[0], + parseDate(parts[1]), + new BigDecimal(parts[2]), + new BigDecimal(parts[3]), + new BigDecimal(parts[4]), + Integer.parseInt(parts[5]), + new BigDecimal(parts[6]), + new BigDecimal(parts[7]), + parts[8], + parts[9] + )); + } + } finally { + r.close(); + } + return out; + } + + private static List readTransactions(File file) throws Exception { + List out = new ArrayList(); + BufferedReader r = new BufferedReader(new InputStreamReader(new java.io.FileInputStream(file), UTF8)); + try { + String header = r.readLine(); + assertHeader(header, "tx_id","account_id","effective_date","amount","currency","status","reversal_of"); + String line; + int row = 1; + while ((line = r.readLine()) != null) { + if (line.trim().length() == 0) continue; + String[] parts = line.split(",", -1); + String reversal = parts[6].length() == 0 ? null : parts[6]; + out.add(new TxnRow( + row, + parts[0], + parts[1], + parseDate(parts[2]), + new BigDecimal(parts[3]), + parts[4], + parts[5], + reversal + )); + row++; + } + } finally { + r.close(); + } + return out; + } + + private static Map> readFxRates(File file) throws Exception { + Map> out = new HashMap>(); + BufferedReader r = new BufferedReader(new InputStreamReader(new java.io.FileInputStream(file), UTF8)); + try { + String header = r.readLine(); + assertHeader(header, "rate_date","from_ccy","to_ccy","rate"); + String line; + while ((line = r.readLine()) != null) { + if (line.trim().length() == 0) continue; + String[] parts = line.split(",", -1); + long day = day(parseDate(parts[0])); + String key = parts[1] + "->" + parts[2]; + List list = out.get(key); + if (list == null) { + list = new ArrayList(); + out.put(key, list); + } + list.add(new Rate(day, new BigDecimal(parts[3]))); + } + } finally { + r.close(); + } + for (List list : out.values()) { + Collections.sort(list, new Comparator() { + public int compare(Rate a, Rate b) { + if (a.day < b.day) return -1; + if (a.day > b.day) return 1; + return 0; + } + }); + } + return out; + } + + private static List readPivots(File file) throws Exception { + List out = new ArrayList(); + BufferedReader r = new BufferedReader(new InputStreamReader(new java.io.FileInputStream(file), UTF8)); + try { + String header = r.readLine(); + assertHeader(header, "pivot"); + String line; + while ((line = r.readLine()) != null) { + if (line.trim().length() == 0) continue; + String[] parts = line.split(",", -1); + out.add(parts[0]); + } + } finally { + r.close(); + } + return out; + } + + private static Map readFeeRules(File file) throws Exception { + Map out = new HashMap(); + BufferedReader r = new BufferedReader(new InputStreamReader(new java.io.FileInputStream(file), UTF8)); + try { + String header = r.readLine(); + assertHeader(header, "fee_plan","applies_to","flat_fee","pct_fee","min_fee","max_fee"); + String line; + while ((line = r.readLine()) != null) { + if (line.trim().length() == 0) continue; + String[] parts = line.split(",", -1); + out.put(parts[0], new FeeRule( + parts[1], + new BigDecimal(parts[2]), + new BigDecimal(parts[3]), + new BigDecimal(parts[4]), + new BigDecimal(parts[5]) + )); + } + } finally { + r.close(); + } + return out; + } + + private static String buildExpectedExceptionsCsv(List exceptions) { + StringBuilder sb = new StringBuilder(); + sb.append("row_num,tx_id,reason\n"); + for (int i = 0; i < exceptions.size(); i++) { + ExceptionRow r = exceptions.get(i); + sb.append(r.rowNum).append(',').append(r.txId).append(',').append(r.reason).append('\n'); + } + return sb.toString(); + } + + private static BigDecimal computeApr(Account a, BigDecimal eod, java.util.Date biz) { + BigDecimal apr = a.baseApr; + if (eod.compareTo(a.tierThreshold) >= 0) apr = apr.add(a.tierApr); + if (a.promoDays > 0) { + int days = daysBetween(a.openedAt, biz); + if (days < a.promoDays) apr = apr.add(a.promoApr); + } + return apr; + } + + private static BigDecimal dailyInterest(BigDecimal eod, BigDecimal apr) { + BigDecimal principal = eod.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : eod; + return principal.multiply(apr).divide(DAYS_IN_YEAR, 10, RoundingMode.HALF_EVEN); + } + + private static int daysBetween(java.util.Date start, java.util.Date end) { + long ms = end.getTime() - start.getTime(); + return (int) (ms / 86400000L); + } + + private static long day(java.util.Date d) { + return d.getTime() / 86400000L; + } + + private static java.util.Date parseDate(String v) throws Exception { + return DF.parse(v); + } + + private static String readFirstLine(File f) throws Exception { + BufferedReader r = new BufferedReader(new InputStreamReader(new java.io.FileInputStream(f), UTF8)); + try { + String line = r.readLine(); + return line == null ? "" : line.trim(); + } finally { + r.close(); + } + } + + private static void assertHeader(String header, String... expected) { + if (header == null) die("missing header"); + String[] got = header.split(",", -1); + if (got.length != expected.length) die("header column count mismatch"); + for (int i = 0; i < expected.length; i++) { + if (!expected[i].equals(got[i])) { + die("header mismatch: expected " + expected[i] + " got " + got[i]); + } + } + } + + private static void assertFile(File f, String msg) { + if (!f.exists()) die(msg); + if (f.length() == 0) die(msg); + } + + private static void assertMd5(File f, String expected) throws Exception { + String got = md5Hex(readAllBytes(f)); + if (!expected.equals(got)) { + die("md5 mismatch for " + f.getName()); + } + } + + private static byte[] readAllBytes(File f) throws Exception { + java.io.FileInputStream in = new java.io.FileInputStream(f); + try { + byte[] buf = new byte[(int) f.length()]; + int off = 0; + while (off < buf.length) { + int n = in.read(buf, off, buf.length - off); + if (n <= 0) break; + off += n; + } + return buf; + } finally { + in.close(); + } + } + + private static String md5Hex(byte[] bytes) throws Exception { + MessageDigest md = MessageDigest.getInstance("MD5"); + md.update(bytes); + byte[] out = md.digest(); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < out.length; i++) { + int b = out[i] & 0xff; + if (b < 16) sb.append('0'); + sb.append(Integer.toHexString(b)); + } + return sb.toString(); + } + + private static boolean bytesEqual(byte[] a, byte[] b) { + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + + private static void assertLfOnly(byte[] bytes, String name) { + for (int i = 0; i < bytes.length; i++) { + if (bytes[i] == '\r') die(name + " must use LF line endings"); + } + } + + private static void assertNoNewlines(byte[] bytes, String name) { + for (int i = 0; i < bytes.length; i++) { + if (bytes[i] == '\n' || bytes[i] == '\r') die(name + " must be single-line JSON"); + } + } + + private static void die(String msg) { + System.err.println(msg); + System.exit(1); + } +} diff --git a/contributor_tasks/bank-interest-legacy-java7/tests/test_outputs.py b/contributor_tasks/bank-interest-legacy-java7/tests/test_outputs.py new file mode 100644 index 0000000..9898cb4 --- /dev/null +++ b/contributor_tasks/bank-interest-legacy-java7/tests/test_outputs.py @@ -0,0 +1,452 @@ +"""Runs BankBatchJob and validates inputs, outputs, and DB state.""" +import hashlib +import os +import secrets +import shutil +import subprocess +import zipfile +from datetime import date +from decimal import Decimal, ROUND_HALF_EVEN +from pathlib import Path +TWOP = Decimal("0.01") +SIXP = Decimal("0.000001") +TENP = Decimal("0.0000000001") +DAYS = Decimal("365") +def _classfile_utf8_strings(raw: bytes) -> list[str]: + if raw[:4] != b"\xCA\xFE\xBA\xBE": + return [] + out = [] + cp_count = int.from_bytes(raw[8:10], "big") + i = 1 + p = 10 + while i < cp_count: + tag = raw[p] + p += 1 + if tag == 1: # Utf8 + ln = int.from_bytes(raw[p:p + 2], "big") + p += 2 + out.append(raw[p:p + ln].decode("utf-8", "replace")) + p += ln + elif tag in (3, 4): # Integer, Float + p += 4 + elif tag in (5, 6): # Long, Double (take two entries) + p += 8 + i += 1 + elif tag in (7, 8, 16, 19, 20): # Class, String, MethodType, Module, Package + p += 2 + elif tag in (9, 10, 11, 12, 18): # Field/Method/Interface/NameAndType/InvokeDynamic + p += 4 + elif tag == 15: # MethodHandle + p += 3 + else: + break + i += 1 + return out +def _build_jdbc_probe_agent(build_dir: Path) -> Path: + agent_src_dir = build_dir / "src" + agent_classes_dir = build_dir / "classes" + agent_src_dir.mkdir(parents=True, exist_ok=True) + agent_classes_dir.mkdir(parents=True, exist_ok=True) + src = agent_src_dir / "JdbcProbeAgent.java" + src.write_text( + "package tbprobe; import java.io.FileOutputStream; import java.io.IOException; import java.lang.instrument.Instrumentation; import java.sql.Connection; import java.sql.Driver; import java.sql.DriverManager; import java.sql.DriverPropertyInfo; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; import java.util.Enumeration; import java.util.Properties; import java.util.logging.Logger;\n" + "public final class JdbcProbeAgent { public static void premain(String agentArgs, Instrumentation inst) throws Exception { String markerPath = (agentArgs==null||agentArgs.length()==0)?\"/tmp/jdbc_probe.txt\":agentArgs; Enumeration drivers = DriverManager.getDrivers(); while (drivers.hasMoreElements()) { Driver d = drivers.nextElement(); if (\"org.h2.Driver\".equals(d.getClass().getName())) { DriverManager.deregisterDriver(d); } } DriverManager.registerDriver(new RecordingH2Driver(markerPath)); }\n" + "private static final class RecordingH2Driver implements Driver { private final String markerPath; private final org.h2.Driver delegate = new org.h2.Driver(); RecordingH2Driver(String markerPath){this.markerPath=markerPath;} public boolean acceptsURL(String url) throws SQLException { return url!=null && url.startsWith(\"jdbc:h2:\"); }\n" + "public Connection connect(String url, Properties info) throws SQLException { if (!acceptsURL(url)) return null; String user = info==null?null:info.getProperty(\"user\"); String pass = info==null?null:info.getProperty(\"password\"); record(url,user,pass); return delegate.connect(url, info);} public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) throws SQLException { return delegate.getPropertyInfo(url, info);} public int getMajorVersion(){return delegate.getMajorVersion();} public int getMinorVersion(){return delegate.getMinorVersion();} public boolean jdbcCompliant(){return delegate.jdbcCompliant();} public Logger getParentLogger() throws SQLFeatureNotSupportedException { return Logger.getLogger(\"global\"); }\n" + "private void record(String url,String user,String pass){ FileOutputStream out=null; try{ out=new FileOutputStream(markerPath); out.write(url.getBytes(\"UTF-8\")); out.write('\\n'); out.write((user==null?\"\":user).getBytes(\"UTF-8\")); out.write('\\n'); out.write((pass==null?\"\":pass).getBytes(\"UTF-8\")); } catch(IOException ignored){} finally{ if(out!=null){ try{out.close();}catch(IOException ignored2){} } } } } }\n", + encoding="utf-8", + ) + manifest = build_dir / "MANIFEST.MF" + manifest.write_text( + "Manifest-Version: 1.0\nPremain-Class: tbprobe.JdbcProbeAgent\n", + encoding="utf-8", + ) + subprocess.check_call( + [ + "javac", + "-source", + "1.7", + "-target", + "1.7", + "-encoding", + "UTF-8", + "-cp", + "/opt/legacy-lib/*", + "-d", + str(agent_classes_dir), + str(src), + ] + ) + agent_jar = build_dir / "jdbc-probe-agent.jar" + if agent_jar.exists(): + agent_jar.unlink() + subprocess.check_call(["jar", "cfm", str(agent_jar), str(manifest), "-C", str(agent_classes_dir), "."]) + return agent_jar +def _build_db_audit_check(build_dir: Path) -> Path: + src_dir = build_dir / "src" + classes_dir = build_dir / "classes" + src_dir.mkdir(parents=True, exist_ok=True) + classes_dir.mkdir(parents=True, exist_ok=True) + src = src_dir / "DbAuditCheck.java" + src.write_text( + "import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.Statement;\n" + "public final class DbAuditCheck { public static void main(String[] args) throws Exception { if (args.length != 5) throw new IllegalArgumentException(\"expected 5 args\"); String expectedBizDate = args[0]; int expectedAccounts = Integer.parseInt(args[1]); int expectedValidTxs = Integer.parseInt(args[2]); int expectedSkippedTxs = Integer.parseInt(args[3]); String expectedMd5 = args[4]; Class.forName(\"org.h2.Driver\"); Connection conn = DriverManager.getConnection(\"jdbc:h2:/app/output/bankdb;MODE=DB2;IFEXISTS=TRUE\", \"sa\", \"\"); try { assertTable(conn,\"ACCOUNTS\"); assertTable(conn,\"TXNS\"); assertTable(conn,\"RUN_AUDIT\"); assertCount(conn,\"ACCOUNTS\", expectedAccounts); int txCount = count(conn, \"TXNS\"); Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(\"SELECT MIN(row_num), MAX(row_num), COUNT(DISTINCT row_num) FROM TXNS\"); rs.next(); int minRow = rs.getInt(1); int maxRow = rs.getInt(2); int distinctRow = rs.getInt(3); rs.close(); st.close(); if (minRow != 1 || maxRow != txCount || distinctRow != txCount) throw new RuntimeException(\"TXNS row_num must be 1..N without gaps\"); st = conn.createStatement(); rs = st.executeQuery(\"SELECT jdbc_url,biz_date,accounts,valid_txs,skipped_txs,report_md5 FROM RUN_AUDIT\"); if (!rs.next()) throw new RuntimeException(\"RUN_AUDIT missing row\"); String jdbcUrl = rs.getString(1); String bizDate = rs.getString(2); int accounts = rs.getInt(3); int validTxs = rs.getInt(4); int skippedTxs = rs.getInt(5); String md5 = rs.getString(6); if (jdbcUrl == null || jdbcUrl.indexOf(\"jdbc:h2:\") != 0 || jdbcUrl.indexOf(\"/app/output/bankdb\") < 0 || jdbcUrl.indexOf(\"MODE=DB2\") < 0) throw new RuntimeException(\"RUN_AUDIT.jdbc_url must include jdbc:h2:, /app/output/bankdb, and MODE=DB2\"); if (!expectedBizDate.equals(bizDate)) throw new RuntimeException(\"RUN_AUDIT.biz_date mismatch\"); if (accounts != expectedAccounts) throw new RuntimeException(\"RUN_AUDIT.accounts mismatch\"); if (validTxs != expectedValidTxs) throw new RuntimeException(\"RUN_AUDIT.valid_txs mismatch\"); if (skippedTxs != expectedSkippedTxs) throw new RuntimeException(\"RUN_AUDIT.skipped_txs mismatch\"); if (!expectedMd5.equals(md5)) throw new RuntimeException(\"RUN_AUDIT.report_md5 mismatch\"); if (rs.next()) throw new RuntimeException(\"RUN_AUDIT must have exactly one row\"); rs.close(); st.close(); } finally { conn.close(); } }\n" + "private static void assertTable(Connection conn, String name) throws Exception { DatabaseMetaData md = conn.getMetaData(); ResultSet rs = md.getTables(null, null, name, null); try { if (!rs.next()) throw new RuntimeException(\"missing table: \" + name); } finally { rs.close(); } }\n" + "private static int count(Connection conn, String table) throws Exception { Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(\"SELECT COUNT(*) FROM \" + table); rs.next(); int out = rs.getInt(1); rs.close(); st.close(); return out; }\n" + "private static void assertCount(Connection conn, String table, int expected) throws Exception { int got = count(conn, table); if (got != expected) throw new RuntimeException(table + \" row count mismatch\"); } }\n", + encoding="utf-8", + ) + subprocess.check_call( + [ + "javac", + "-source", + "1.7", + "-target", + "1.7", + "-encoding", + "UTF-8", + "-cp", + "/opt/legacy-lib/*", + "-d", + str(classes_dir), + str(src), + ] + ) + return classes_dir +def _read_csv(path: Path) -> tuple[list[str], list[list[str]]]: + lines = path.read_text(encoding="utf-8").splitlines() + header = lines[0].split(",") + rows = [ln.split(",", -1) for ln in lines[1:] if ln.strip()] + return header, rows +def _parse_date(value: str) -> date: + return date.fromisoformat(value) +def _best_rate(rates: dict[str, list[tuple[date, Decimal]]], from_ccy: str, to_ccy: str, eff: date) -> Decimal | None: + direct = None + direct_date = None + for d, r in rates.get(f"{from_ccy}->{to_ccy}", []): + if d <= eff: + direct = r + direct_date = d + else: + break + reverse = None + reverse_date = None + for d, r in rates.get(f"{to_ccy}->{from_ccy}", []): + if d <= eff: + reverse = r + reverse_date = d + else: + break + if direct is None and reverse is None: + return None + if reverse is None or (direct is not None and direct_date >= reverse_date): + return direct + if reverse == 0: + return None + return (Decimal("1") / reverse).quantize(TENP, rounding=ROUND_HALF_EVEN) + + +def _fx_rate( + rates: dict[str, list[tuple[date, Decimal]]], + pivots: list[str], + from_ccy: str, + to_ccy: str, + eff: date, +) -> Decimal | None: + if from_ccy == to_ccy: + return Decimal("1") + out = _best_rate(rates, from_ccy, to_ccy, eff) + if out is not None: + return out + for pivot in pivots: + if pivot in (from_ccy, to_ccy): + continue + leg1 = _best_rate(rates, from_ccy, pivot, eff) + if leg1 is None: + continue + leg2 = _best_rate(rates, pivot, to_ccy, eff) + if leg2 is None: + continue + return (leg1 * leg2).quantize(TENP, rounding=ROUND_HALF_EVEN) + return None +def _apply_fee(amt: Decimal, rule: dict | None) -> Decimal: + if rule is None: + return amt.quantize(TWOP, rounding=ROUND_HALF_EVEN) + sign = amt.compare(Decimal("0")) + applies = rule["applies_to"] == "ALL" and sign != 0 + applies = applies or (rule["applies_to"] == "DEBIT" and sign < 0) + applies = applies or (rule["applies_to"] == "CREDIT" and sign > 0) + if not applies: + return amt.quantize(TWOP, rounding=ROUND_HALF_EVEN) + fee = (rule["flat_fee"] + amt.copy_abs() * rule["pct_fee"]).quantize(TWOP, rounding=ROUND_HALF_EVEN) + if fee < rule["min_fee"]: + fee = rule["min_fee"] + if fee > rule["max_fee"]: + fee = rule["max_fee"] + fee = fee.quantize(TWOP, rounding=ROUND_HALF_EVEN) + return (amt - fee).quantize(TWOP, rounding=ROUND_HALF_EVEN) +def _compute_expected() -> tuple[str, str, str, int, int, str, int, str]: + biz_date = Path("/app/data/biz_date.txt").read_text(encoding="utf-8").strip() + biz = _parse_date(biz_date) + acc_header, acc_rows = _read_csv(Path("/app/data/accounts.csv")) + assert acc_header == [ + "account_id", + "opened_at", + "start_balance", + "base_apr", + "promo_apr", + "promo_days", + "tier_threshold", + "tier_apr", + "currency", + "fee_plan", + ] + accounts: dict[str, dict] = {} + for row in acc_rows: + accounts[row[0]] = { + "opened_at": _parse_date(row[1]), + "start_balance": Decimal(row[2]), + "base_apr": Decimal(row[3]), + "promo_apr": Decimal(row[4]), + "promo_days": int(row[5]), + "tier_threshold": Decimal(row[6]), + "tier_apr": Decimal(row[7]), + "currency": row[8], + "fee_plan": row[9], + } + tx_header, tx_rows = _read_csv(Path("/app/data/transactions.csv")) + assert tx_header == ["tx_id", "account_id", "effective_date", "amount", "currency", "status", "reversal_of"] + txs = [] + for i, row in enumerate(tx_rows, start=1): + txs.append( + { + "row_num": i, + "tx_id": row[0], + "account_id": row[1], + "effective_date": _parse_date(row[2]), + "amount": Decimal(row[3]), + "currency": row[4], + "status": row[5], + "reversal_of": row[6] or None, + } + ) + fx_header, fx_rows = _read_csv(Path("/app/data/fx_rates.csv")) + assert fx_header == ["rate_date", "from_ccy", "to_ccy", "rate"] + rates: dict[str, list[tuple[date, Decimal]]] = {} + for row in fx_rows: + rates.setdefault(f"{row[1]}->{row[2]}", []).append((_parse_date(row[0]), Decimal(row[3]))) + for key in rates: + rates[key].sort(key=lambda x: x[0]) + pivot_header, pivot_rows = _read_csv(Path("/app/data/fx_pivots.csv")) + assert pivot_header == ["pivot"] + pivots = [row[0] for row in pivot_rows if row[0]] + fee_header, fee_rows = _read_csv(Path("/app/data/fee_rules.csv")) + assert fee_header == ["fee_plan", "applies_to", "flat_fee", "pct_fee", "min_fee", "max_fee"] + fees: dict[str, dict] = {} + for row in fee_rows: + fees[row[0]] = { + "applies_to": row[1], + "flat_fee": Decimal(row[2]), + "pct_fee": Decimal(row[3]), + "min_fee": Decimal(row[4]), + "max_fee": Decimal(row[5]), + } + first_row_by_tx_id: dict[str, int] = {} + for row in txs: + first_row_by_tx_id.setdefault(row["tx_id"], row["row_num"]) + reversed_rows: set[int] = set() + reversal_missing_rows: set[int] = set() + for row in txs: + if not row["reversal_of"]: + continue + ref = first_row_by_tx_id.get(row["reversal_of"]) + if ref is None: + reversal_missing_rows.add(row["row_num"]) + else: + reversed_rows.add(row["row_num"]) + reversed_rows.add(ref) + sums: dict[str, Decimal] = {} + exceptions: list[tuple[int, str, str]] = [] + valid = 0 + for row in txs: + reason = None + first_row = first_row_by_tx_id.get(row["tx_id"]) + if row["row_num"] in reversed_rows: + reason = "REVERSED" + elif row["row_num"] in reversal_missing_rows: + reason = "REVERSAL_MISSING" + acct = accounts.get(row["account_id"]) + amount = row["amount"] + if reason is None: + if acct is None: + reason = "FX_MISSING" + elif row["currency"] != acct["currency"]: + rate = _fx_rate(rates, pivots, row["currency"], acct["currency"], row["effective_date"]) + if rate is None: + reason = "FX_MISSING" + else: + amount = amount * rate + if reason is None: + if row["effective_date"] > biz: + reason = "AFTER_BIZ_DATE" + elif row["status"] != "POSTED": + reason = row["status"] + elif first_row is not None and first_row != row["row_num"]: + reason = "DUPLICATE" + if reason is not None: + exceptions.append((row["row_num"], row["tx_id"], reason)) + continue + adjusted = _apply_fee(amount, fees.get(acct["fee_plan"])) + sums[row["account_id"]] = sums.get(row["account_id"], Decimal("0.00")) + adjusted + valid += 1 + exceptions.sort(key=lambda x: x[0]) + ex_lines = ["row_num,tx_id,reason"] + ex_lines.extend([f"{r},{t},{reason}" for r, t, reason in exceptions]) + exceptions_csv = "\n".join(ex_lines) + "\n" + total_interest = Decimal("0.00") + interest_lines = ["account_id,biz_date,eod_balance,apr,daily_interest"] + for account_id in sorted(accounts.keys()): + acc = accounts[account_id] + delta = sums.get(account_id, Decimal("0.00")) + eod = (acc["start_balance"] + delta).quantize(TWOP, rounding=ROUND_HALF_EVEN) + apr = acc["base_apr"] + if eod >= acc["tier_threshold"]: + apr += acc["tier_apr"] + if acc["promo_days"] > 0 and (biz - acc["opened_at"]).days < acc["promo_days"]: + apr += acc["promo_apr"] + apr_out = apr.quantize(SIXP, rounding=ROUND_HALF_EVEN) + principal = eod if eod >= 0 else Decimal("0.00") + daily_raw = (principal * apr_out / DAYS).quantize(TENP, rounding=ROUND_HALF_EVEN) + daily = daily_raw.quantize(TWOP, rounding=ROUND_HALF_EVEN) + total_interest += daily_raw + interest_lines.append( + f"{account_id},{biz_date},{eod:.2f},{apr_out:.6f},{daily:.2f}" + ) + interest_csv = "\n".join(interest_lines) + "\n" + total_interest = total_interest.quantize(TWOP, rounding=ROUND_HALF_EVEN) + report_md5 = hashlib.md5(interest_csv.encode("utf-8")).hexdigest() + stats_json = ( + f'{{"biz_date":"{biz_date}","accounts":{len(accounts)},"valid_txs":{valid},' + f'"skipped_txs":{len(exceptions)},"total_interest":"{total_interest:.2f}",' + f'"report_md5":"{report_md5}"}}' + ) + return interest_csv, exceptions_csv, stats_json, valid, len(exceptions), biz_date, len(accounts), report_md5 +def test_outputs_via_java_validator(): + """Runs the job end-to-end and validates outputs + key runtime constraints.""" + out_dir = Path("/app/output") + if out_dir.exists(): + shutil.rmtree(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + data_files = { + "/app/data/accounts.csv": "505829c96c9fa568cf70abf7c52247d5", + "/app/data/transactions.csv": "ad100f16f2e9b52929da8eca55b3edad", + "/app/data/biz_date.txt": "4a58c5b49dafc91047e9f999ede44581", + "/app/data/fx_rates.csv": "d351af4cc717a6d8d9353f53d8616c05", + "/app/data/fx_pivots.csv": "31e9e031a576af3d943424f47d9f9390", + "/app/data/fee_rules.csv": "a2a8a66a3db722b2f4a051612c8e8bd7", + } + for path, expected_md5 in data_files.items(): + got = hashlib.md5(Path(path).read_bytes()).hexdigest() + assert got == expected_md5, f"input modified before run: {path}" + job_jar = Path("/app/job.jar") + assert job_jar.exists(), "/app/job.jar is missing" + assert job_jar.stat().st_size > 0, "/app/job.jar is empty" + with zipfile.ZipFile(job_jar, "r") as zf: + class_entries = [n for n in zf.namelist() if n.endswith(".class")] + assert class_entries, "/app/job.jar has no class files" + class_bytes = [] + for entry in class_entries: + raw = zf.read(entry) + assert raw[:4] == b"\xCA\xFE\xBA\xBE", "invalid class file in /app/job.jar" + major = int.from_bytes(raw[6:8], "big") + assert major == 51, f"/app/job.jar must be Java 7 bytecode (major=51), got {major}" + class_bytes.append(raw) + literals = [] + for blob in class_bytes: + literals.extend(_classfile_utf8_strings(blob)) + jdbc_literals = [ + s for s in literals + if s.startswith("jdbc:h2:") and "/app/output/bankdb" in s and "MODE=DB2" in s + ] + assert jdbc_literals, "jar must include a JDBC URL literal with /app/output/bankdb and MODE=DB2" + assert len(set(jdbc_literals)) == 1, "jar must include exactly one JDBC URL literal value" + assert len(jdbc_literals) == 1, "jar must include the JDBC URL literal only once" + agent_build = Path("/tmp/jdbc_probe_agent") + if agent_build.exists(): + shutil.rmtree(agent_build) + agent_build.mkdir(parents=True, exist_ok=True) + agent_jar = _build_jdbc_probe_agent(agent_build) + legacy_cp = "/opt/legacy-lib/*" + env = os.environ.copy() + + def run_job(marker_path: Path) -> None: + env["JAVA_TOOL_OPTIONS"] = f"-javaagent:{agent_jar}={marker_path}" + subprocess.check_call(["java", "-cp", f"{job_jar}:{legacy_cp}", "BankBatchJob"], cwd="/app", env=env) + marker_text = marker_path.read_text(encoding="utf-8") if marker_path.exists() else "" + marker_lines = marker_text.splitlines() + url = marker_lines[0] if len(marker_lines) > 0 else "" + user = marker_lines[1] if len(marker_lines) > 1 else "" + password = marker_lines[2] if len(marker_lines) > 2 else "" + assert url.startswith("jdbc:h2:"), "job must open an H2 JDBC connection at runtime" + assert "MODE=DB2" in url, "opened JDBC URL must include MODE=DB2" + assert "/app/output/bankdb" in url, "opened JDBC URL must use the /app/output/bankdb database file" + assert url in jdbc_literals, "opened JDBC URL must match a single literal in the jar" + assert user == "sa", "H2 connection user must be 'sa'" + assert password == "", "H2 connection password must be empty" + run_job(agent_build / f"marker_{secrets.token_hex(8)}.txt") + first_interest = Path("/app/output/interest.csv").read_bytes() + first_ex = Path("/app/output/exceptions.csv").read_bytes() + first_stats = Path("/app/output/stats.json").read_bytes() + run_job(agent_build / f"marker_{secrets.token_hex(8)}.txt") + assert Path("/app/output/interest.csv").read_bytes() == first_interest, "interest.csv must be identical across re-runs" + assert Path("/app/output/exceptions.csv").read_bytes() == first_ex, "exceptions.csv must be identical across re-runs" + assert Path("/app/output/stats.json").read_bytes() == first_stats, "stats.json must be identical across re-runs" + ( + expected_interest, + expected_exceptions, + expected_stats, + expected_valid, + expected_skipped, + expected_biz, + expected_accounts, + expected_report_md5, + ) = _compute_expected() + assert Path("/app/output/interest.csv").read_text(encoding="utf-8") == expected_interest, "interest.csv mismatch" + assert Path("/app/output/exceptions.csv").read_text(encoding="utf-8") == expected_exceptions, "exceptions.csv mismatch" + assert Path("/app/output/stats.json").read_text(encoding="utf-8") == expected_stats, "stats.json mismatch" + db_file = Path("/app/output/bankdb.h2.db") + assert db_file.exists(), "/app/output/bankdb.h2.db is missing" + assert db_file.stat().st_size > 0, "/app/output/bankdb.h2.db is empty" + actual_md5 = hashlib.md5(Path("/app/output/interest.csv").read_bytes()).hexdigest() + assert actual_md5 == expected_report_md5, "report_md5 mismatch" + audit_build = Path("/tmp/db_audit_check") + if audit_build.exists(): + shutil.rmtree(audit_build) + audit_build.mkdir(parents=True, exist_ok=True) + audit_classes = _build_db_audit_check(audit_build) + subprocess.check_call( + [ + "java", + "-cp", + f"{audit_classes}:{legacy_cp}", + "DbAuditCheck", + expected_biz, + str(expected_accounts), + str(expected_valid), + str(expected_skipped), + expected_report_md5, + ] + ) + for path, expected_md5 in data_files.items(): + got = hashlib.md5(Path(path).read_bytes()).hexdigest() + assert got == expected_md5, f"input modified after run: {path}" + src = Path(__file__).with_name("OutputValidator.java") + assert src.exists(), "OutputValidator.java missing" + build = Path("/tmp/validator") + if build.exists(): + shutil.rmtree(build) + build.mkdir(parents=True, exist_ok=True) + subprocess.check_call( + ["javac", "-source", "1.7", "-target", "1.7", "-encoding", "UTF-8", "-cp", legacy_cp, "-d", str(build), str(src)] + ) + subprocess.check_call(["java", "-cp", f"{build}:{legacy_cp}", "OutputValidator"])