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

Files touched

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

Review this PR

Java practice