Add monthly card statements
Java · Spring Boot · intermediate · modification
Adds monthly statements for card accounts, the last missing piece before we can switch off the PDF statements from the old core. Month boundaries are computed in the account's own zone (so a Berlin customer's October starts at midnight Berlin time, DST included) and the query is a single derived repository method, ordered in the database. Ownership is checked through `findByIdAndOwnerId` before anything is read. Tested against my staging account for September and October: totals match the transaction list.
Card-issuing backend on Spring Boot 3.3 (Java 21, Gradle with the Spring Boot plugin), Spring Data JPA on PostgreSQL. Spring Security requires authentication for every `/api/**` route; the principal is `com.acme.cards.auth.AppUser`, whose `getId()` returns the user's `Long` id. Existing: `Account` entity with `getTimeZone()` returning an IANA zone id (validated on write, never null), and `AccountRepository.findByIdAndOwnerId(Long id, Long ownerId)` returning `Optional<Account>`. Existing `CardTransaction` entity (table `card_transactions`): `Long id`, `Long accountId`, `long amountMinor` (card limits keep it between -1,000,000,000 and 1,000,000,000), `String description`, `Instant postedAt` (column `posted_at timestamptz`, microsecond precision). Card-network batch postings are stamped at exactly 00:00:00.000000 in the account's time zone on their posting date, so many transactions sit exactly on a midnight. Accounts hold at most a few hundred transactions a month; no pagination needed. Requests whose `month` year is outside 2000-2100 are rejected with 400 by the API gateway before they reach the service.
Requirements
- `GET /api/accounts/{accountId}/statements/{month}` (`month` as `YYYY-MM`) returns `{ "month", "totalMinor", "transactions": [{ "id", "postedAt", "description", "amountMinor" }] }` for one card account, transactions ordered by `postedAt`, then `id`.
- A statement covers the calendar month in the account's own time zone: transactions with `postedAt` from the first instant of the month (inclusive) up to the first instant of the next month (exclusive). Each transaction appears in exactly one monthly statement.
- `totalMinor` is the sum of `amountMinor` over the statement's transactions (signed minor units: purchases positive, refunds negative; one currency per account).
- Only the account's owner may read its statements; another user's account id, or an unknown one, answers 404. A malformed `month` answers 400. Any well-formed month is allowed; a month with no transactions returns an empty list and a total of 0.
- Read-only: no new tables or columns.
Files touched
- src/main/java/com/acme/cards/statement/CardTransactionRepository.java
- src/main/java/com/acme/cards/statement/MonthlyStatement.java
- src/main/java/com/acme/cards/statement/StatementService.java
- src/main/java/com/acme/cards/statement/StatementController.java
--- src/main/java/com/acme/cards/statement/CardTransactionRepository.java
package com.acme.cards.statement;
+
+import java.time.Instant;
+import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CardTransactionRepository extends JpaRepository<CardTransaction, Long> {
+
+ List<CardTransaction> findAllByAccountIdAndPostedAtBetweenOrderByPostedAtAscIdAsc(
+ Long accountId, Instant from, Instant to);
}
--- src/main/java/com/acme/cards/statement/MonthlyStatement.java
+package com.acme.cards.statement;
+
+import java.time.Instant;
+import java.time.YearMonth;
+import java.util.List;
+
+public record MonthlyStatement(YearMonth month, long totalMinor, List<Line> transactions) {
+
+ public record Line(long id, Instant postedAt, String description, long amountMinor) {
+
+ static Line from(CardTransaction tx) {
+ return new Line(tx.getId(), tx.getPostedAt(), tx.getDescription(), tx.getAmountMinor());
+ }
+ }
+}