Give invoices a due date in the customer's time zone
Java · Spring Boot · advanced · modification
Invoices now get a due date when they are issued. `DueDateCalculator` counts the customer's net terms in business days (Monday to Friday) from the issue date, which is taken in the customer's own time zone, and computes `overdueAt`, the start of the following day in that zone, stored as an `Instant` so the overdue query compares plain instants. `InvoiceService` reads the time from an injected `Clock` (new `ClockConfig` bean on the system UTC clock), and `findOverdue()` lists the issued invoices whose `overdueAt` has been reached, for the dunning job that comes next. The unit tests pin the clock and cover month ends, weekend issue dates, the switch to summer time and a Tokyo and a New York customer issued at the same instant.
Billing backend on Spring Boot 3.3 (Java 21), Spring Data JPA with Hibernate 6.5 on PostgreSQL 16; Hibernate maps `Instant` to `timestamptz` and `LocalDate` to `date`. Existing and unchanged: `Customer` (constructor `Customer(String name, String timeZone, int paymentTermsDays)`) with `timeZone` (an IANA region id such as `Europe/Berlin`, validated when the customer is saved, so `ZoneId.of` accepts it) and `int paymentTermsDays` (the customer's net terms in business days, validated to 1 to 60 when the customer is saved); `InvoiceStatus` (`DRAFT`, `ISSUED`, `PAID`, `VOID`); `InvoiceNotFoundException` and `InvoiceStateException`, which the existing controller advice maps to 404 and 409, as it maps an optimistic-locking failure on the `@Version` field to 409. Before this PR the application has no `Clock` bean. Nothing calls `findOverdue()` yet; the dunning job that will call it is the next PR and reads the whole list in one run, which is fine because the overdue set is small (finance keeps it under a few hundred invoices; the account has about 4,000 invoices a year).
Requirements
- When an invoice is issued (`InvoiceService.issue`, already called by the existing `POST /invoices/{id}/issue`), it gets a due date. The issue date is the calendar date in the customer's `timeZone` at the moment of issuing. The due date is the business day that is `paymentTermsDays` business days after the issue date: business days are Monday to Friday, and the issue date itself never counts, even when it is a business day (issued on a Friday with 1 day of terms: due Monday). Public holidays are out of scope for this PR.
- Store the due date as a calendar date (`dueDate`) and the moment the invoice becomes overdue as an instant (`overdueAt`): the start of the day after the due date in the customer's time zone. An issued invoice is overdue from `overdueAt` on, inclusive. `findOverdue()` returns the `ISSUED` invoices that are overdue at the current time, earliest `overdueAt` first. Invoices issued before this change have no due date until a separate backfill and are not returned.
- Read the current time through an injected `java.time.Clock` (this PR adds the application's `Clock` bean) so tests can pin it. Ship unit tests for month ends, weekend issue dates and a customer whose local date differs from the UTC date.
- The `due_date` (`date`) and `overdue_at` (`timestamptz`) columns are added by a migration that ships separately and is already applied.
Files touched
- src/main/java/com/acme/billing/invoice/Invoice.java
- src/main/java/com/acme/billing/invoice/InvoiceRepository.java
- src/main/java/com/acme/billing/config/ClockConfig.java
- src/main/java/com/acme/billing/invoice/DueDateCalculator.java
- src/main/java/com/acme/billing/invoice/InvoiceService.java
- src/test/java/com/acme/billing/invoice/DueDateCalculatorTest.java
- src/test/java/com/acme/billing/invoice/InvoiceServiceTest.java
--- src/main/java/com/acme/billing/invoice/Invoice.java
import jakarta.persistence.*;
import java.time.Instant;
+import java.time.LocalDate;
@Entity
private Instant issuedAt;
+ private LocalDate dueDate;
+
+ private Instant overdueAt;
+
protected Invoice() {}
public Instant getIssuedAt() { return issuedAt; }
public void setIssuedAt(Instant issuedAt) { this.issuedAt = issuedAt; }
+ public LocalDate getDueDate() { return dueDate; }
+ public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
+ public Instant getOverdueAt() { return overdueAt; }
+ public void setOverdueAt(Instant overdueAt) { this.overdueAt = overdueAt; }
}
--- src/main/java/com/acme/billing/invoice/InvoiceRepository.java
package com.acme.billing.invoice;
+import java.time.Instant;
+import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvoiceRepository extends JpaRepository<Invoice, Long> {