feat(charges): payment-aware due-date + backfill payment_months (THI-329)#223
Conversation
…(THI-329) Fixes incoherent bill dates at the root, without touching nextDueDateForCharge. DATA: migration 20260605000001 recomputes payment_months from (frequency, due_month) for charges where the stored array is inconsistent — fixes the 20260503000002 bug that stored a SINGLE month for periodic charges (S.W.D.E quarterly = [1] → janv. 2027). Idempotent, zero-loss, no RLS touched. DOMAIN: new pure nextUnpaidDueDate(charge, payments, fromIso) → next UNPAID occurrence. Skips paid occurrences (Impôt June paid → rolls to July) and surfaces a passed-but-unpaid current-month occurrence as overdue (Taxe voiture June unpaid → overdue, no longer hidden a year ahead). Wired into the dashboard getUpcomingCharges; the page charges (ChargesClient) lands in PR-B. plan-reviewer 🟡→integrated · rls-flow-tester GO · financial-formula-validator GO. 32 domain tests + full suite (1433) + build green.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Guide du réviseurIntroduit une fonction de domaine de prochaine date d’échéance tenant compte des paiements et l’intègre dans le calcul des prochaines factures, ainsi qu’une migration corrective idempotente qui recompute Diagramme de séquence pour la résolution des prochaines factures tenant compte des paiementssequenceDiagram
actor User
participant Dashboard as DashboardApp
participant Domain as getUpcomingCharges
participant Resolver as nextUnpaidDueDate
User->>Dashboard: open /app (Prochaines factures)
Dashboard->>Domain: getUpcomingCharges(charges, payments, todayIso)
loop for each charge
Domain->>Resolver: nextUnpaidDueDate(charge, payments, todayIso)
alt nextUnpaidDueDate returns null
Resolver-->>Domain: null
Domain-->>Domain: skip charge
else returns NextUnpaidDueResult
Resolver-->>Domain: NextUnpaidDueResult{dueDateIso,isOverdue}
Domain-->>Domain: diffInDays(todayIso, dueDateIso)
alt isOverdue
Domain-->>Domain: buckets.overdue.push(item)
else daysUntilDue <= 7
Domain-->>Domain: buckets.j7.push(item)
else daysUntilDue <= 30
Domain-->>Domain: buckets.j30.push(item)
end
end
end
Domain-->>Dashboard: UpcomingByBucket
Dashboard-->>User: shows upcoming and overdue charges
Modifications par fichier
Conseils et commandesInteragir avec Sourcery
Personnaliser votre expérienceAccédez à votre dashboard pour :
Obtenir de l’aide
Original review guide in EnglishReviewer's GuideIntroduces a payment-aware next-due-date domain function and wires it into upcoming charges, plus a corrective, idempotent migration that recomputes payment_months from (frequency, due_month) to fix mis-stored cadences, along with targeted unit tests and planning docs. Sequence diagram for payment-aware upcoming charges resolutionsequenceDiagram
actor User
participant Dashboard as DashboardApp
participant Domain as getUpcomingCharges
participant Resolver as nextUnpaidDueDate
User->>Dashboard: open /app (Prochaines factures)
Dashboard->>Domain: getUpcomingCharges(charges, payments, todayIso)
loop for each charge
Domain->>Resolver: nextUnpaidDueDate(charge, payments, todayIso)
alt nextUnpaidDueDate returns null
Resolver-->>Domain: null
Domain-->>Domain: skip charge
else returns NextUnpaidDueResult
Resolver-->>Domain: NextUnpaidDueResult{dueDateIso,isOverdue}
Domain-->>Domain: diffInDays(todayIso, dueDateIso)
alt isOverdue
Domain-->>Domain: buckets.overdue.push(item)
else daysUntilDue <= 7
Domain-->>Domain: buckets.j7.push(item)
else daysUntilDue <= 30
Domain-->>Domain: buckets.j30.push(item)
end
end
end
Domain-->>Dashboard: UpcomingByBucket
Dashboard-->>User: shows upcoming and overdue charges
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - j’ai trouvé 1 problème et laissé quelques retours plus globaux :
- Le format de clé de grand livre (
${id}-${year}-${month}) est maintenant dupliqué dansnext-unpaid-due-date.ts, ses tests etcockpit/types.ts. Pensez à le centraliser dans un helper partagé (ou à réutiliser l’existantpaymentKeyFor) pour éviter les divergences à l’avenir si le format change. nextUnpaidDueDateréimplémente une logique d’analyse de dates ISO (fromIso.split('-')+ vérifications manuelles). Si vous avez déjà un helper partagé utilisé parnextDueDateForCharge/diffInDays, le réutiliser permettrait de garder un traitement des dates cohérent et de réduire le risque de différences subtiles entre les resolvers.
Prompt pour les agents IA
Please address the comments from this code review:
## Overall Comments
- The ledger key format (`${id}-${year}-${month}`) is now duplicated across `next-unpaid-due-date.ts`, its tests, and `cockpit/types.ts`; consider centralizing this in a shared helper (or reusing the existing `paymentKeyFor`) to avoid future drift if the format ever changes.
- `nextUnpaidDueDate` re-implements ISO date parsing logic (`fromIso.split('-')` + manual checks); if you already have a shared helper used by `nextDueDateForCharge`/`diffInDays`, reusing it would keep date handling consistent and reduce the chance of subtle differences between resolvers.
## Individual Comments
### Comment 1
<location path="src/lib/domain/charges/next-unpaid-due-date.ts" line_range="65" />
<code_context>
+ // Search the current month and the next 23 — covers every cadence (monthly
+ // resolves at offset 0, annual within 12); the 24-cap is defensive against
+ // an ill-formed `paymentMonths`.
+ for (let offset = 0; offset < 24; offset += 1) {
+ const totalMonth = refMonth - 1 + offset;
+ const year = refYear + Math.floor(totalMonth / 12);
</code_context>
<issue_to_address>
**issue (complexity):** Envisagez de refactorer la gestion des dates en centralisant l’arithmétique sur les mois et le formatage ISO, afin de rendre la logique métier de la boucle plus claire et de supprimer les helpers de padding personnalisés.
Vous pouvez conserver tout le comportement actuel tout en simplifiant la mécanique de dates et en isolant l’arithmétique sur les mois. Deux refactorings ciblés :
---
### 1. Utiliser `Date` pour le formatage ISO (supprimer `pad2`/`pad4`)
Vous utilisez déjà `Date` dans `daysInMonth` ; vous pouvez aussi l’utiliser pour générer la chaîne ISO et supprimer les helpers de padding personnalisés :
```ts
// inside the loop, replace:
const day = Math.min(charge.paymentDay, daysInMonth(year, month));
const dueDateIso = `${pad4(year)}-${pad2(month)}-${pad2(day)}`;
// ISO `YYYY-MM-DD` lexical order === chronological order.
return { dueDateIso, isOverdue: dueDateIso < fromIso };
// with:
const day = Math.min(charge.paymentDay, daysInMonth(year, month));
const dueDate = new Date(Date.UTC(year, month - 1, day));
const dueDateIso = dueDate.toISOString().slice(0, 10);
// ISO `YYYY-MM-DD` lexical order === chronological order.
return { dueDateIso, isOverdue: dueDateIso < fromIso };
```
Then you can safely delete:
```ts
function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
function pad4(n: number): string {
return n < 1000 ? String(n).padStart(4, '0') : String(n);
}
```
Le comportement reste inchangé (toujours en UTC, toujours `YYYY-MM-DD`, toujours comparable lexicalement) mais avec moins de logique de formatage personnalisée.
---
### 2. Extraire l’arithmétique de décalage de mois hors de la boucle principale
Pour réduire la charge cognitive dans la boucle principale, vous pouvez encapsuler le calcul `totalMonth` / `year` / `month` dans un petit helper :
```ts
function addMonths(
base: { year: number; month: number },
offset: number,
): { year: number; month: number } {
const totalMonth = base.month - 1 + offset;
const year = base.year + Math.floor(totalMonth / 12);
const month = (totalMonth % 12) + 1;
return { year, month };
}
```
La boucle devient alors plus facile à lire :
```ts
const base = { year: refYear, month: refMonth };
for (let offset = 0; offset < 24; offset += 1) {
const { year, month } = addMonths(base, offset);
if (!monthsSet.has(month)) continue;
if (payments.get(`${charge.id}-${year}-${month}`) === true) continue;
const day = Math.min(charge.paymentDay, daysInMonth(year, month));
const dueDate = new Date(Date.UTC(year, month - 1, day));
const dueDateIso = dueDate.toISOString().slice(0, 10);
return { dueDateIso, isOverdue: dueDateIso < fromIso };
}
```
Cela conserve tout le comportement existant (y compris la limite de 24 mois et l’indexation des mois), mais localise l’arithmétique la plus subtile, ce qui rend la logique métier (« ignorer les mois payés, choisir le premier impayé ») plus évidente.
</issue_to_address>Sourcery est gratuit pour l’open source – si nos revues vous plaisent, pensez à les partager ✨
Original comment in English
Hey - I've found 1 issue, and left some high level feedback:
- The ledger key format (
${id}-${year}-${month}) is now duplicated acrossnext-unpaid-due-date.ts, its tests, andcockpit/types.ts; consider centralizing this in a shared helper (or reusing the existingpaymentKeyFor) to avoid future drift if the format ever changes. nextUnpaidDueDatere-implements ISO date parsing logic (fromIso.split('-')+ manual checks); if you already have a shared helper used bynextDueDateForCharge/diffInDays, reusing it would keep date handling consistent and reduce the chance of subtle differences between resolvers.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The ledger key format (`${id}-${year}-${month}`) is now duplicated across `next-unpaid-due-date.ts`, its tests, and `cockpit/types.ts`; consider centralizing this in a shared helper (or reusing the existing `paymentKeyFor`) to avoid future drift if the format ever changes.
- `nextUnpaidDueDate` re-implements ISO date parsing logic (`fromIso.split('-')` + manual checks); if you already have a shared helper used by `nextDueDateForCharge`/`diffInDays`, reusing it would keep date handling consistent and reduce the chance of subtle differences between resolvers.
## Individual Comments
### Comment 1
<location path="src/lib/domain/charges/next-unpaid-due-date.ts" line_range="65" />
<code_context>
+ // Search the current month and the next 23 — covers every cadence (monthly
+ // resolves at offset 0, annual within 12); the 24-cap is defensive against
+ // an ill-formed `paymentMonths`.
+ for (let offset = 0; offset < 24; offset += 1) {
+ const totalMonth = refMonth - 1 + offset;
+ const year = refYear + Math.floor(totalMonth / 12);
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring date handling by centralizing month arithmetic and ISO formatting to make the loop’s business logic clearer and remove custom padding helpers.
You can keep all the current behavior while simplifying the date machinery and isolating the month arithmetic. Two focused refactors:
---
### 1. Use `Date` for ISO formatting (remove `pad2`/`pad4`)
You’re already using `Date` in `daysInMonth`; you can also use it to generate the ISO string and drop the custom padding helpers:
```ts
// inside the loop, replace:
const day = Math.min(charge.paymentDay, daysInMonth(year, month));
const dueDateIso = `${pad4(year)}-${pad2(month)}-${pad2(day)}`;
// ISO `YYYY-MM-DD` lexical order === chronological order.
return { dueDateIso, isOverdue: dueDateIso < fromIso };
// with:
const day = Math.min(charge.paymentDay, daysInMonth(year, month));
const dueDate = new Date(Date.UTC(year, month - 1, day));
const dueDateIso = dueDate.toISOString().slice(0, 10);
// ISO `YYYY-MM-DD` lexical order === chronological order.
return { dueDateIso, isOverdue: dueDateIso < fromIso };
```
Then you can safely delete:
```ts
function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
function pad4(n: number): string {
return n < 1000 ? String(n).padStart(4, '0') : String(n);
}
```
Behavior is unchanged (still UTC, still `YYYY-MM-DD`, still lexicographically comparable) but with less custom formatting logic.
---
### 2. Extract month-offset arithmetic from the core loop
To reduce cognitive load in the main loop, you can encapsulate the `totalMonth` / `year` / `month` math in a small helper:
```ts
function addMonths(
base: { year: number; month: number },
offset: number,
): { year: number; month: number } {
const totalMonth = base.month - 1 + offset;
const year = base.year + Math.floor(totalMonth / 12);
const month = (totalMonth % 12) + 1;
return { year, month };
}
```
Then the loop becomes easier to read:
```ts
const base = { year: refYear, month: refMonth };
for (let offset = 0; offset < 24; offset += 1) {
const { year, month } = addMonths(base, offset);
if (!monthsSet.has(month)) continue;
if (payments.get(`${charge.id}-${year}-${month}`) === true) continue;
const day = Math.min(charge.paymentDay, daysInMonth(year, month));
const dueDate = new Date(Date.UTC(year, month - 1, day));
const dueDateIso = dueDate.toISOString().slice(0, 10);
return { dueDateIso, isOverdue: dueDateIso < fromIso };
}
```
This keeps all existing behavior (including the 24‑month cap and month indexing) but localizes the trickier arithmetic, making the business logic (“skip paid months, pick first unpaid”) more apparent.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
…carry (THI-329)
Charges page now reflects the CURRENT month per bill instead of rolling past it.
DOMAIN: new pure currentPeriodDueDate(charge, period, todayIso, isPaid) anchors
to the current month (paid/overdue/dueThisMonth) and only rolls to the real next
occurrence (upcoming) when the month is not a payment month — fixes the page
showing 'juillet' for a June bill ('juillet avant juin'). 13 tests.
UI (carry of #221, trim dropped): Payé toggle (useOptimistic + togglePaymentAction),
paid line-through, 'Payées ce mois' summary, group redesign (Repeat + count,
rounded list, brand subtotal). New: per-row 'En retard' badge driven by
currentPeriodDueDate. nextDueLabel (nextDueDateForCharge, rolled forward) removed.
a11y (dashboard-ux C1/H1): overdue badge = white on solid danger (4.84:1 both
themes); date stays neutral (no color-only signal); group subtotal text-brand-700
-> text-brand-text (dark-overridden, AA both themes).
i18n: app.charges.{toastMarkedPaid,toastMarkedUnpaid,markPaidAria,unmarkPaidAria,
paidSummary,paidHint,statusOverdue} x5 locales. moreCount NOT carried.
plan-reviewer 🟡→integrated (CR-1..4) · financial-formula-validator GO ·
i18n-auditor GO · dashboard-ux C1/H1 fixed · mobile-ios PASS_WITH_NOTES.
Branched from main, independent of PR-A #223 (index.ts export = trivial merge).
…carry (THI-329) (#224) ## PR-B — Page charges : période courante + carry #221 (THI-329) Epic « Factures cohérentes ». La page `/app/charges` reflète enfin la **réalité du mois courant** par facture, au lieu de rouler prématurément vers l'occurrence suivante. **Branchée depuis `main`, indépendante de PR-A #223** (seul overlap au merge = bloc d'exports `index.ts`, trivial). ### Le fix « juillet avant juin » Domaine pur nouveau **`currentPeriodDueDate(charge, period, todayIso, isPaid)`** : ancre au mois courant (`paid` / `overdue` / `dueThisMonth`) et ne roule vers la vraie prochaine occurrence (`upcoming`) que si le mois courant n'est pas un mois de paiement. Remplace `nextDueLabel` (qui utilisait `nextDueDateForCharge`, lequel roulait en avant → affichait « juillet » pour une facture de juin). 13 tests. ### Carry de #221 (trim rejeté retiré) Toggle **Payé** (`useOptimistic` + `togglePaymentAction` existant), style payé (line-through), résumé « Payées ce mois : x/y · reste {montant} », redesign groupes (header Repeat + compteur, `<ul>` arrondi bordé, sous-total bas). **Le trim dashboard (`moreCount`) n'est PAS carrié** (rejeté @Thierry — un marqueur « à surveiller » viendra). **Nouveau** : badge par-ligne « En retard ». ### a11y (dashboard-ux C1/H1 corrigés) - Badge « En retard » = **blanc sur `danger` plein** → **4.84:1** dans les 2 thèmes (`--color-danger` n'a pas d'override dark, donc `text-danger` y échouait). - Date reste **neutre** (plus de couleur seule = WCAG 1.4.1). - Sous-total groupe `text-brand-700` → `text-brand-text` (overridé dark → AA les 2 thèmes). ### Gouvernance plan-reviewer 🟡→intégré (CR-1..4 + faits git vérifiés) · **financial-formula-validator GO** · **i18n-auditor GO** (parité 5 locales) · **dashboard-ux C1/H1 corrigés** · **mobile-ios PASS_WITH_NOTES** · 47 tests charges + suite complète (1444) + build verts. ###⚠️ Dette a11y hors-scope (à tracker) `ProchainesFacturesCard.tsx` (dashboard, **sur main**, non touché ici) a le **même** pattern `bg-danger/10 text-danger` (chip danger) + `text-brand-700` (tone info) → même fail dark. À corriger dans une passe a11y dédiée. ### Smoke @Thierry (preview, **desktop + mobile**) `/app/charges` en juin : facture mensuelle payée → affiche **juin payé** (plus juillet) ; facture jour passé non payée → badge **« En retard »** ; toggle bascule l'état ; trimestriel hors-mois → prochaine date réelle. Vérifier badge lisible en **dark mode** + pas de chevauchement date/badge/montant sur **iPhone SE**. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
PR-A — Payment-aware due-date + backfill payment_months (THI-329)
Corrige à la racine les dates de factures incohérentes, sans toucher
nextDueDateForCharge. Fondation de l'epic « Factures cohérentes » (spec :docs/plans/epic-factures-coherentes-spec.md).Deux leviers
20260605000001— recomputepayment_monthsdepuis(frequency, due_month)pour les charges incohérentes. Corrige le bug de20260503000002(l.58) qui stockait UN seul mois pour les périodiques (S.W.D.Equarterly=[1]→ « janv. 2027 »). Idempotente, zéro-perte, aucune RLS touchée (SQL =paymentMonthsFromFrequencyau caractère près).nextUnpaidDueDate()(pur, nouveau) — prochaine occurrence NON PAYÉE : saute les payées (Impôt juin payé → roule à juillet) + surface une occurrence du mois courant passée-non-payée en overdue (Taxe voiture juin → en retard, plus caché un an). Câblé dans le dashboardgetUpcomingCharges. La page charges (ChargesClient) suit en PR-B.Le merge applique le backfill sur les
chargesde tous les workspaces. C'est correctif + idempotent + zéro-perte (ne touche que les lignes incohérentes, aucun DELETE). Il dé-casse la cadence (un seul mois → cadence complète) mais ne corrige PAS l'ancredue_monthmal saisie (ex. S.W.D.E réel = mai) → @Thierry corrige l'ancre via CadenceField (PR-D).Gouvernance
plan-reviewer 🟡→intégré · rls-flow-tester GO · financial-formula-validator GO · 32 tests domaine + suite complète 1433 + build verts.
Smoke @Thierry (preview)
Dashboard
/app« Prochaines factures » : Impôt (juin payé) roulé à juillet ; Taxe voiture (juin non payée) en « En retard » ; S.W.D.E n'affiche plus janv. 2027.🤖 Generated with Claude Code
Summary by Sourcery
Introduire un résolveur de date d’échéance sensible aux paiements pour les prochaines facturations et rétro-remplir les cadences de facturation incohérentes afin de rétablir des calendriers de facturation corrects.
Nouvelles fonctionnalités :
upcoming-chargesau nouveau résolveur de date d’échéance sensible aux paiements, tout en préservant l’API publique existante.Corrections de bugs :
payment_monthsincorrects pour les facturations périodiques afin de restaurer les cadences complètes dérivées defrequencyetdue_month, corrigeant les cas où les prochaines dates d’échéance étaient résolues avec un an d’avance.Améliorations :
upcoming-chargespour ignorer les occurrences déjà payées de la période en cours et supprimer les entrées qui dépassent l’horizon de 30 jours, sans modifier les interfaces existantes.Documentation :
Tests :
upcoming-chargespour couvrir la gestion des retards, le saut des paiements et les cas limites de calendrier.Tâches diverses :
payment_monthsen utilisant des règles de cadence canoniques pour toutes les facturations existantes.Original summary in English
Summary by Sourcery
Introduce a payment-aware due-date resolver for upcoming charges and backfill inconsistent charge cadences to restore correct billing schedules.
New Features:
Bug Fixes:
Enhancements:
Documentation:
Tests:
Chores: