|
| 1 | +package ch.wisv.chpay.core.service; |
| 2 | + |
| 3 | +import ch.wisv.chpay.core.model.User; |
| 4 | +import ch.wisv.chpay.core.model.transaction.Transaction; |
| 5 | +import java.math.BigDecimal; |
| 6 | +import java.nio.charset.StandardCharsets; |
| 7 | +import java.time.ZoneId; |
| 8 | +import java.time.format.DateTimeFormatter; |
| 9 | +import java.util.List; |
| 10 | +import java.util.Locale; |
| 11 | +import java.util.UUID; |
| 12 | +import org.springframework.stereotype.Service; |
| 13 | + |
| 14 | +@Service |
| 15 | +public class OfxExportService { |
| 16 | + |
| 17 | + private static final DateTimeFormatter OFX_DATE_TIME = |
| 18 | + DateTimeFormatter.ofPattern("yyyyMMddHHmmss", Locale.ROOT).withZone(ZoneId.systemDefault()); |
| 19 | + |
| 20 | + /** |
| 21 | + * Generate OFX content (OFX 1.x SGML) for a user's transactions. |
| 22 | + * |
| 23 | + * @param user owner of the transactions |
| 24 | + * @param transactions transactions to include |
| 25 | + * @return bytes of the OFX file (UTF-8) |
| 26 | + */ |
| 27 | + public byte[] generateOfx(User user, List<Transaction> transactions) { |
| 28 | + StringBuilder sb = new StringBuilder(); |
| 29 | + |
| 30 | + // SGML header (OFX 1.02) using UNICODE/UTF-8 to match produced bytes |
| 31 | + sb.append("OFXHEADER:100\n"); |
| 32 | + sb.append("DATA:OFXSGML\n"); |
| 33 | + sb.append("VERSION:102\n"); |
| 34 | + sb.append("SECURITY:NONE\n"); |
| 35 | + sb.append("ENCODING:UNICODE\n"); |
| 36 | + sb.append("CHARSET:UTF-8\n"); |
| 37 | + sb.append("COMPRESSION:NONE\n"); |
| 38 | + sb.append("OLDFILEUID:NONE\n"); |
| 39 | + sb.append("NEWFILEUID:").append(UUID.randomUUID()).append('\n'); |
| 40 | + |
| 41 | + // Body |
| 42 | + sb.append("<OFX>\n"); |
| 43 | + sb.append(" <SIGNONMSGSRSV1>\n"); |
| 44 | + sb.append(" <SONRS>\n"); |
| 45 | + sb.append(" <STATUS>\n"); |
| 46 | + sb.append(" <CODE>0\n"); |
| 47 | + sb.append(" <SEVERITY>INFO\n"); |
| 48 | + sb.append(" </STATUS>\n"); |
| 49 | + sb.append(" <DTSERVER>") |
| 50 | + .append(OFX_DATE_TIME.format(java.time.Instant.now())) |
| 51 | + .append('\n'); |
| 52 | + sb.append(" <LANGUAGE>ENG\n"); |
| 53 | + sb.append(" <FI>\n"); |
| 54 | + sb.append(" <ORG>W.I.S.V. 'Christiaan Huygens'\n"); |
| 55 | + sb.append(" <FID>CHPAY\n"); |
| 56 | + sb.append(" </FI>\n"); |
| 57 | + sb.append(" </SONRS>\n"); |
| 58 | + sb.append(" </SIGNONMSGSRSV1>\n"); |
| 59 | + |
| 60 | + // Use BANKMSGSRSV1/STMTRS as a generic statement container |
| 61 | + sb.append(" <BANKMSGSRSV1>\n"); |
| 62 | + sb.append(" <STMTTRNRS>\n"); |
| 63 | + sb.append(" <TRNUID>").append(UUID.randomUUID()).append('\n'); |
| 64 | + sb.append(" <STATUS>\n"); |
| 65 | + sb.append(" <CODE>0\n"); |
| 66 | + sb.append(" <SEVERITY>INFO\n"); |
| 67 | + sb.append(" </STATUS>\n"); |
| 68 | + sb.append(" <STMTRS>\n"); |
| 69 | + sb.append(" <CURDEF>EUR\n"); |
| 70 | + sb.append(" <BANKACCTFROM>\n"); |
| 71 | + sb.append(" <BANKID>CHPAY\n"); |
| 72 | + sb.append(" <BRANCHID>CH\n"); |
| 73 | + sb.append(" <ACCTID>") |
| 74 | + .append(user.getId() != null ? user.getId() : UUID.randomUUID()) |
| 75 | + .append('\n'); |
| 76 | + sb.append(" <ACCTTYPE>CHECKING\n"); |
| 77 | + sb.append(" </BANKACCTFROM>\n"); |
| 78 | + |
| 79 | + sb.append(" <BANKTRANLIST>\n"); |
| 80 | + // Add DTSTART/DTEND based on min/max timestamps |
| 81 | + java.time.Instant minTs = null; |
| 82 | + java.time.Instant maxTs = null; |
| 83 | + for (Transaction tx : transactions) { |
| 84 | + java.time.Instant ts = tx.getTimestamp().atZone(ZoneId.systemDefault()).toInstant(); |
| 85 | + if (minTs == null || ts.isBefore(minTs)) { |
| 86 | + minTs = ts; |
| 87 | + } |
| 88 | + if (maxTs == null || ts.isAfter(maxTs)) { |
| 89 | + maxTs = ts; |
| 90 | + } |
| 91 | + } |
| 92 | + if (minTs != null) { |
| 93 | + sb.append(" <DTSTART>").append(OFX_DATE_TIME.format(minTs)).append('\n'); |
| 94 | + } |
| 95 | + if (maxTs != null) { |
| 96 | + sb.append(" <DTEND>").append(OFX_DATE_TIME.format(maxTs)).append('\n'); |
| 97 | + } |
| 98 | + for (Transaction tx : transactions) { |
| 99 | + appendTransaction(sb, tx); |
| 100 | + } |
| 101 | + sb.append(" </BANKTRANLIST>\n"); |
| 102 | + |
| 103 | + // Ledger balance is not tracked here; emit zero to keep format valid |
| 104 | + sb.append(" <LEDGERBAL>\n"); |
| 105 | + sb.append(" <BALAMT>0.00\n"); |
| 106 | + sb.append(" <DTASOF>") |
| 107 | + .append(OFX_DATE_TIME.format(java.time.Instant.now())) |
| 108 | + .append('\n'); |
| 109 | + sb.append(" </LEDGERBAL>\n"); |
| 110 | + |
| 111 | + sb.append(" </STMTRS>\n"); |
| 112 | + sb.append(" </STMTTRNRS>\n"); |
| 113 | + sb.append(" </BANKMSGSRSV1>\n"); |
| 114 | + sb.append("</OFX>\n"); |
| 115 | + |
| 116 | + return sb.toString().getBytes(StandardCharsets.UTF_8); |
| 117 | + } |
| 118 | + |
| 119 | + private static void appendTransaction(StringBuilder sb, Transaction tx) { |
| 120 | + sb.append(" <STMTTRN>\n"); |
| 121 | + sb.append(" <TRNTYPE>").append(mapType(tx)).append('\n'); |
| 122 | + sb.append(" <DTPOSTED>") |
| 123 | + .append(OFX_DATE_TIME.format(tx.getTimestamp().atZone(ZoneId.systemDefault()))) |
| 124 | + .append('\n'); |
| 125 | + sb.append(" <TRNAMT>").append(formatAmount(tx.getAmount())).append('\n'); |
| 126 | + sb.append(" <FITID>").append(tx.getId()).append('\n'); |
| 127 | + // Payee name: use Mollie for top-ups, W.I.S.V. for other transactions |
| 128 | + String payeeName = |
| 129 | + tx.getType() == Transaction.TransactionType.TOP_UP |
| 130 | + ? "Mollie" |
| 131 | + : "W.I.S.V. 'Christiaan Huygens'"; |
| 132 | + sb.append(" <NAME>").append(payeeName).append('\n'); |
| 133 | + // Use MEMO for the transaction description |
| 134 | + String memo = sanitize(tx.getDescription()); |
| 135 | + if (memo != null && !memo.isEmpty()) { |
| 136 | + sb.append(" <MEMO>").append(memo).append('\n'); |
| 137 | + } |
| 138 | + sb.append(" </STMTTRN>\n"); |
| 139 | + } |
| 140 | + |
| 141 | + private static String mapType(Transaction tx) { |
| 142 | + // Map using sign and type, keeping TRNTYPE simple and import-friendly |
| 143 | + if (tx.getAmount() == null) { |
| 144 | + return "OTHER"; |
| 145 | + } |
| 146 | + return tx.getAmount().compareTo(BigDecimal.ZERO) >= 0 ? "CREDIT" : "DEBIT"; |
| 147 | + } |
| 148 | + |
| 149 | + private static String formatAmount(BigDecimal amount) { |
| 150 | + if (amount == null) { |
| 151 | + return "0.00"; |
| 152 | + } |
| 153 | + return amount.setScale(2, java.math.RoundingMode.HALF_UP).toPlainString(); |
| 154 | + } |
| 155 | + |
| 156 | + private static String sanitize(String value) { |
| 157 | + if (value == null) { |
| 158 | + return ""; |
| 159 | + } |
| 160 | + // OFX SGML is permissive, but avoid newlines and angle brackets |
| 161 | + return value.replace('\n', ' ').replace('<', '(').replace('>', ')'); |
| 162 | + } |
| 163 | +} |
0 commit comments