Skip to content

Commit 25e4585

Browse files
committed
Add export features for users transactions
1 parent 4a8ae70 commit 25e4585

File tree

5 files changed

+329
-30
lines changed

5 files changed

+329
-30
lines changed

src/main/java/ch/wisv/chpay/admin/controller/AdminTransactionsController.java

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@
22

33
import ch.wisv.chpay.admin.service.AdminTransactionService;
44
import ch.wisv.chpay.core.model.transaction.Transaction;
5+
import ch.wisv.chpay.core.service.CsvExportService;
56
import jakarta.servlet.http.HttpServletRequest;
6-
import java.io.ByteArrayInputStream;
7-
import java.io.InputStream;
8-
import java.nio.charset.StandardCharsets;
97
import java.time.YearMonth;
108
import java.util.List;
119
import java.util.stream.Collectors;
@@ -27,9 +25,13 @@
2725
@RequestMapping("/admin/transactions")
2826
public class AdminTransactionsController extends BaseTransactionController {
2927

28+
private final CsvExportService csvExportService;
29+
3030
@Autowired
31-
protected AdminTransactionsController(AdminTransactionService adminTransactionService) {
31+
protected AdminTransactionsController(
32+
AdminTransactionService adminTransactionService, CsvExportService csvExportService) {
3233
super(adminTransactionService);
34+
this.csvExportService = csvExportService;
3335
}
3436

3537
/**
@@ -79,33 +81,18 @@ public ResponseEntity<InputStreamResource> fooAsCSV(@RequestParam String yearMon
7981

8082
List<Transaction> transactions =
8183
adminTransactionService.getTransactionsByYearMonth(selectedYearMonth);
82-
String csvData =
84+
transactions =
8385
transactions.stream()
8486
.filter(
8587
t ->
8688
t.getStatus().equals(Transaction.TransactionStatus.SUCCESSFUL)
8789
|| t.getStatus().equals(Transaction.TransactionStatus.PARTIALLY_REFUNDED)
8890
|| t.getStatus().equals(Transaction.TransactionStatus.REFUNDED))
89-
.map(
90-
t ->
91-
t.getId().toString()
92-
+ ";"
93-
+ t.getType().toString()
94-
+ ";"
95-
+ t.getUser().getName()
96-
+ ";"
97-
+ t.getDescription()
98-
+ ";"
99-
+ t.getAmount()
100-
+ ";"
101-
+ t.getStatus().name()
102-
+ ";"
103-
+ t.getTimestamp().toString())
104-
.collect(Collectors.joining("\n"));
105-
csvData = "Id;Type;Name;Description;Amount;Status;Timestamp\n" + csvData;
106-
InputStream bufferedInputStream =
107-
new ByteArrayInputStream(csvData.getBytes(StandardCharsets.UTF_8));
108-
InputStreamResource fileInputStream = new InputStreamResource(bufferedInputStream);
91+
.collect(Collectors.toList());
92+
93+
byte[] csvBytes = csvExportService.generateCsv(transactions);
94+
InputStreamResource fileInputStream =
95+
new InputStreamResource(new java.io.ByteArrayInputStream(csvBytes));
10996

11097
String filename = "chpay_" + selectedYearMonth + "_export.csv";
11198

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package ch.wisv.chpay.core.service;
2+
3+
import ch.wisv.chpay.core.model.transaction.Transaction;
4+
import java.nio.charset.StandardCharsets;
5+
import java.util.List;
6+
import java.util.stream.Collectors;
7+
import org.springframework.stereotype.Service;
8+
9+
@Service
10+
public class CsvExportService {
11+
12+
/**
13+
* Generate CSV for transactions with header row (semicolon separated).
14+
*
15+
* <p>Columns: Id;Type;Name;Description;Amount;Status;Timestamp
16+
*/
17+
public byte[] generateCsv(List<Transaction> transactions) {
18+
String csvData =
19+
transactions.stream()
20+
.map(
21+
t ->
22+
t.getId().toString()
23+
+ ";"
24+
+ t.getType().toString()
25+
+ ";"
26+
+ (t.getUser() != null ? t.getUser().getName() : "")
27+
+ ";"
28+
+ sanitize(t.getDescription())
29+
+ ";"
30+
+ t.getAmount().toPlainString()
31+
+ ";"
32+
+ t.getStatus().name()
33+
+ ";"
34+
+ t.getTimestamp().toString())
35+
.collect(Collectors.joining("\n"));
36+
37+
csvData = "Id;Type;Name;Description;Amount;Status;Timestamp\n" + csvData;
38+
return csvData.getBytes(StandardCharsets.UTF_8);
39+
}
40+
41+
private static String sanitize(String value) {
42+
if (value == null) {
43+
return "";
44+
}
45+
// Avoid newlines and semicolons that would break CSV; replace with spaces
46+
return value.replace('\n', ' ').replace(';', ',');
47+
}
48+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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+
}

src/main/java/ch/wisv/chpay/customer/controller/TransactionHistoryController.java

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33
import ch.wisv.chpay.core.model.User;
44
import ch.wisv.chpay.core.model.transaction.Transaction;
5-
import ch.wisv.chpay.core.service.NotificationService;
65
import ch.wisv.chpay.core.service.TransactionService;
76
import ch.wisv.chpay.customer.service.MailService;
87
import java.util.List;
98
import java.util.Optional;
109
import java.util.UUID;
1110
import org.springframework.beans.factory.annotation.Autowired;
11+
import org.springframework.http.HttpHeaders;
1212
import org.springframework.http.HttpStatus;
13+
import org.springframework.http.MediaType;
1314
import org.springframework.http.ResponseEntity;
1415
import org.springframework.security.access.prepost.PreAuthorize;
1516
import org.springframework.security.core.Authentication;
@@ -24,18 +25,21 @@
2425
public class TransactionHistoryController extends CustomerController {
2526

2627
private final TransactionService transactionService;
27-
private final NotificationService notificationService;
28+
private final ch.wisv.chpay.core.service.OfxExportService ofxExportService;
29+
private final ch.wisv.chpay.core.service.CsvExportService csvExportService;
2830
private final MailService mailService;
2931

3032
@Autowired
3133
protected TransactionHistoryController(
3234
TransactionService transactionService,
33-
NotificationService notificationService,
34-
MailService mailService) {
35+
MailService mailService,
36+
ch.wisv.chpay.core.service.OfxExportService ofxExportService,
37+
ch.wisv.chpay.core.service.CsvExportService csvExportService) {
3538
super();
3639
this.transactionService = transactionService;
37-
this.notificationService = notificationService;
3840
this.mailService = mailService;
41+
this.ofxExportService = ofxExportService;
42+
this.csvExportService = csvExportService;
3943
}
4044

4145
/**
@@ -61,6 +65,56 @@ public String getSimplifiedTransactionsPage(Model model) {
6165
return "transactions";
6266
}
6367

68+
/** Export all of the current user's transactions as an OFX file. */
69+
@PreAuthorize("hasAnyRole('USER', 'BANNED')")
70+
@GetMapping(value = "/transactions/export/ofx")
71+
public ResponseEntity<byte[]> exportTransactionsOfx(Model model) {
72+
User currentUser = (User) model.getAttribute("currentUser");
73+
List<Transaction> transactions =
74+
new java.util.ArrayList<>(
75+
transactionService.getTransactionsForUser(currentUser).stream()
76+
.filter(
77+
t ->
78+
t.getStatus() == Transaction.TransactionStatus.SUCCESSFUL
79+
|| t.getStatus() == Transaction.TransactionStatus.PARTIALLY_REFUNDED
80+
|| t.getStatus() == Transaction.TransactionStatus.REFUNDED)
81+
.toList());
82+
transactions.sort((t1, t2) -> t2.getTimestamp().compareTo(t1.getTimestamp()));
83+
84+
byte[] ofxBytes = ofxExportService.generateOfx(currentUser, transactions);
85+
86+
HttpHeaders headers = new HttpHeaders();
87+
headers.setContentType(new MediaType("application", "x-ofx"));
88+
headers.set(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=transactions.ofx");
89+
headers.setContentLength(ofxBytes.length);
90+
return new ResponseEntity<>(ofxBytes, headers, HttpStatus.OK);
91+
}
92+
93+
/** Export all of the current user's transactions as a CSV file. */
94+
@PreAuthorize("hasAnyRole('USER', 'BANNED')")
95+
@GetMapping(value = "/transactions/export/csv")
96+
public ResponseEntity<byte[]> exportTransactionsCsv(Model model) {
97+
User currentUser = (User) model.getAttribute("currentUser");
98+
List<Transaction> transactions =
99+
new java.util.ArrayList<>(
100+
transactionService.getTransactionsForUser(currentUser).stream()
101+
.filter(
102+
t ->
103+
t.getStatus() == Transaction.TransactionStatus.SUCCESSFUL
104+
|| t.getStatus() == Transaction.TransactionStatus.PARTIALLY_REFUNDED
105+
|| t.getStatus() == Transaction.TransactionStatus.REFUNDED)
106+
.toList());
107+
transactions.sort((t1, t2) -> t2.getTimestamp().compareTo(t1.getTimestamp()));
108+
109+
byte[] csvBytes = csvExportService.generateCsv(transactions);
110+
111+
HttpHeaders headers = new HttpHeaders();
112+
headers.set(HttpHeaders.CONTENT_TYPE, "text/csv");
113+
headers.set(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=transactions.csv");
114+
headers.setContentLength(csvBytes.length);
115+
return new ResponseEntity<>(csvBytes, headers, HttpStatus.OK);
116+
}
117+
64118
/**
65119
* Sends a user their receipt
66120
*

0 commit comments

Comments
 (0)