Leap-Day Invoice Run Fails on a Strict Parse
A billing service generates monthly invoices. The renewal date is read from a config file, parsed with DateTimeFormatter.ofPattern("yyyy-MM-dd").withResolverStyle(ResolverStyle.STRICT) and LocalDate.parse. Formatting the invoice PDF uses a second formatter built from the same pattern. Replaying 2024 data fails with DateTimeParseException on "2024-02-29", although 2024 is a leap year and "2024-03-01" parses fine. The renewal date is computed as startDate.plusMonths(1); a subscription started on 2024-02-15 renews on 2024-03-15, but the business rule says renewals always fall on the last day of the following month, and an existing test expecting 2024-03-31 for a start of 2024-02-29 also fails. Nightly totals are accumulated in a double and rendered with String.format("%,.2f", total); the same amount prints as 1,234.57 on the CI box and 1.234,57 on a developer laptop, and 0.1 + 0.2 is logged as 0.30000000000000004 before formatting. No exception is thrown anywhere in the formatting path.
1. Why does STRICT parsing reject "2024-02-29" when "2024-03-01" also depends on the same pattern? Wait, both use the same pattern - so what actually fails, and what is the minimal fix?
Read the worked answer
The failing field is the year, not the day. yyyy is the pattern letter for year-of-era (ChronoField.YEAR_OF_ERA), while uuuu maps to the proleptic year (ChronoField.YEAR). Under ResolverStyle.STRICT the formatter refuses to guess an era: with no G (era) field present, year-of-era cannot be converted into a year, so LocalDate.parse fails with DateTimeParseException: Unable to obtain LocalDate from TemporalAccessor.
The misleading part is that "2024-03-01" presumably parsed on an earlier build because DateTimeFormatter.ofPattern defaults to ResolverStyle.SMART, which silently assumes CE. The bug appeared the day someone added withResolverStyle(STRICT), not when the leap day arrived.
Minimal fixes, best first:
LocalDate.parse(s, DateTimeFormatter.ISO_LOCAL_DATE);
// or keep the custom pattern but use uuuu
DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT);
Adding parseDefaulting(ChronoField.ERA, 1) or reverting to LENIENT also silences it but reintroduces ambiguity. Formatting was never affected: the resolver style only governs parsing, which is why the PDF output looked correct while input failed.
2. What rule makes plusMonths(1) turn 2024-02-15 into 2024-03-15 and 2024-02-29 into 2024-03-29? Give code that implements the actual business rule (last day of the following month).
Read the worked answer
plusMonths performs calendar arithmetic in the ISO calendar. It first increments the month field, then clamps the day-of-month to the last valid day of the resulting month; it never rolls excess days forward. So 2024-01-31 + 1 month = 2024-02-29, and 2024-02-29 + 1 month = 2024-03-29, not 2024-03-31. Period.ofMonths(1) and LocalDate.plus(Period) behave identically because they delegate to the same unit. Duration is not an option: it is time-based and LocalDate.plus(Duration) throws UnsupportedTemporalTypeException.
The business rule is "end of next month", which is a different operation from "same day next month":
LocalDate renewal = YearMonth.from(start).plusMonths(1).atEndOfMonth();
// equivalent:
LocalDate renewal2 = start.withDayOfMonth(1).plusMonths(1)
.with(TemporalAdjusters.lastDayOfMonth());
Note the order in the second form: reset to day 1 first, otherwise the clamp still applies before the adjuster fixes it. If a time component matters, do the arithmetic on LocalDate and re-attach the LocalTime afterwards; doing it on ZonedDateTime across a DST gap can shift the wall-clock time.
3. Explain the two independent defects behind the formatting symptoms and what you would change in the totals code.
Read the worked answer
Two unrelated problems are bundled together.
1. double cannot represent decimal fractions. 0.1 + 0.2 is stored as 0.30000000000000004, and String.format("%,.2f", ...) merely rounds that binary value for display. Accumulating thousands of such values drifts, and equality tests on the totals fail. Money must be held in BigDecimal built from decimal text or BigDecimal.valueOf, with an explicit scale and RoundingMode (typically HALF_UP for invoicing):
BigDecimal total = amounts.stream()
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(2, RoundingMode.HALF_UP);
2. The formatter uses the default locale. String.format with no Locale argument resolves Locale.getDefault(Locale.Category.FORMAT), so %,.2f inserts that locale's grouping separator and decimal separator: 1,234.57 in en-US, 1.234,57 in de-DE. The CI box and the laptop simply have different defaults.
String s = String.format(Locale.US, "%,.2f", total);
// or NumberFormat nf = NumberFormat.getInstance(Locale.US);
Always pass a fixed locale for machine-readable output; the same trap applies to toUpperCase() (Turkish dotless i) and DateTimeFormatter patterns like MMM.