Skip to content

feat(charges): payment-aware due-date + backfill payment_months (THI-329)#223

Merged
thierryvm merged 2 commits into
mainfrom
feat/thi-329-payment-aware-due-date
Jun 6, 2026
Merged

feat(charges): payment-aware due-date + backfill payment_months (THI-329)#223
thierryvm merged 2 commits into
mainfrom
feat/thi-329-payment-aware-due-date

Conversation

@thierryvm

@thierryvm thierryvm commented Jun 6, 2026

Copy link
Copy Markdown
Owner

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

  1. Migration 20260605000001 — recompute payment_months depuis (frequency, due_month) pour les charges incohérentes. Corrige le bug de 20260503000002 (l.58) qui stockait UN seul mois pour les périodiques (S.W.D.E quarterly = [1] → « janv. 2027 »). Idempotente, zéro-perte, aucune RLS touchée (SQL = paymentMonthsFromFrequency au caractère près).
  2. Domaine 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 dashboard getUpcomingCharges. La page charges (ChargesClient) suit en PR-B.

⚠️ Migration sur données prod

Le merge applique le backfill sur les charges de 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'ancre due_month mal 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 :

  • Ajouter une fonction de domaine pour calculer la prochaine date d’échéance impayée d’une facturation, en tenant compte de l’historique des paiements et en mettant en évidence les occurrences en retard.
  • Connecter le pipeline du tableau de bord upcoming-charges au nouveau résolveur de date d’échéance sensible aux paiements, tout en préservant l’API publique existante.

Corrections de bugs :

  • Rétro-remplir les payment_months incorrects pour les facturations périodiques afin de restaurer les cadences complètes dérivées de frequency et due_month, corrigeant les cas où les prochaines dates d’échéance étaient résolues avec un an d’avance.
  • S’assurer que les compartiments de retard incluent désormais correctement les occurrences du mois en cours qui sont échues mais non payées, au lieu de les faire avancer silencieusement.

Améliorations :

  • Affiner le comportement de upcoming-charges pour 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.
  • Documenter le nouveau comportement de date d’échéance sensible aux paiements et le plan de migration dans un document de conception dédié.

Documentation :

  • Ajouter un plan de conception et d’implémentation pour la date d’échéance sensible aux paiements et le travail de rétro-remplissage de cadence, y compris l’évaluation des risques et les invariants.

Tests :

  • Ajouter des tests unitaires pour le nouveau résolveur de prochaine date d’échéance impayée sensible aux paiements et mettre à jour les tests de upcoming-charges pour couvrir la gestion des retards, le saut des paiements et les cas limites de calendrier.

Tâches diverses :

  • Introduire une migration Supabase corrective et idempotente pour recalculer payment_months en 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:

  • Add a domain function to compute the next unpaid due date for a charge, taking into account payment history and surfacing overdue occurrences.
  • Wire the dashboard upcoming-charges pipeline to use the new payment-aware due-date resolver while preserving the existing public API.

Bug Fixes:

  • Backfill incorrect payment_months for periodic charges to restore full cadences derived from frequency and due_month, fixing cases where next due dates were resolved a year ahead.
  • Ensure overdue buckets now correctly include passed-but-unpaid current-month occurrences instead of silently rolling them forward.

Enhancements:

  • Refine upcoming-charges behavior to skip already-paid current-period occurrences and drop entries that fall beyond the 30-day horizon without changing existing interfaces.
  • Document the new payment-aware due-date behavior and migration plan in a dedicated design plan document.

Documentation:

  • Add a design and implementation plan for the payment-aware due-date and cadence backfill work, including risk assessment and invariants.

Tests:

  • Add unit tests for the new payment-aware next-unpaid-due-date resolver and update upcoming-charges tests to cover overdue handling, payment skipping, and calendar edge cases.

Chores:

  • Introduce a corrective, idempotent Supabase migration to recompute payment_months using canonical cadence rules for all existing charges.

thierryvm added 2 commits June 6, 2026 20:33
…(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.
@vercel

vercel Bot commented Jun 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ankora Ready Ready Preview, Comment Jun 6, 2026 6:49pm

@github-actions github-actions Bot added status:review-needed Ready for review type:feat New user-facing feature labels Jun 6, 2026
@sourcery-ai

sourcery-ai Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Guide du réviseur

Introduit 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 payment_months à partir de (frequency, due_month) pour corriger des cadences mal stockées, avec des tests unitaires ciblés et de la documentation de planification.

Diagramme de séquence pour la résolution des prochaines factures tenant compte des paiements

sequenceDiagram
  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
Loading

Modifications par fichier

Changement Détails Fichiers
Rendre le calcul des prochaines factures sensible aux paiements en utilisant un nouveau resolver nextUnpaidDueDate qui remonte les occurrences passées mais non payées comme en retard et ignore les périodes déjà payées.
  • Remplacer l’utilisation de nextDueDateForCharge dans getUpcomingCharges par nextUnpaidDueDate, en conservant la même API publique tout en modifiant le regroupement (bucketing) sur la base des occurrences non payées et du statut de retard.
  • Introduire les types PaymentAwareCharge et NextUnpaidDueResult ainsi que l’implémentation pure de nextUnpaidDueDate qui parcourt à partir du mois courant sur un horizon borné, ignore les mois en dehors de paymentMonths ou déjà payés dans le ledger, borne les jours à la longueur du mois, et marque les retards via comparaison lexicale des dates.
  • Mettre à jour les tests des prochaines factures pour couvrir le comportement de retard pour les occurrences passées mais non payées, l’ignorance des occurrences payées du mois courant, les cas limites de calendrier pour les factures annuelles, et la sémantique de saut tenant compte des paiements, tout en supprimant l’ancienne hypothèse selon laquelle les retards étaient structurellement inatteignables.
src/lib/domain/charges/upcoming.ts
src/lib/domain/charges/next-unpaid-due-date.ts
src/lib/domain/charges/__tests__/next-unpaid-due-date.test.ts
src/lib/domain/charges/__tests__/upcoming.test.ts
src/lib/domain/charges/index.ts
Rétro-remplir et normaliser les payment_months des charges sur les données de production pour correspondre à la cadence canonique dérivée de (frequency, due_month), corrigeant les erreurs de migration précédentes tout en restant idempotente et non destructive.
  • Ajouter une migration Supabase qui recalcule les tableaux payment_months canoniques pour les fréquences mensuelle, trimestrielle, semestrielle et annuelle via du SQL reflétant paymentMonthsFromFrequency, en ne mettant à jour que les lignes dont les tableaux stockés diffèrent de la valeur recomputée.
  • S’assurer que la migration contourne la RLS en s’exécutant en tant que propriétaire de la table, ne supprime pas de données, laisse les fréquences inconnues inchangées, et documente son rôle dans l’epic « Factures cohérentes » ainsi que son interaction avec les corrections ultérieures d’ancrage de cadence.
supabase/migrations/20260605000001_backfill_payment_months.sql
Documenter la conception de la date d’échéance tenant compte des paiements et son interaction avec le rétro-remplissage de cadence et l’epic plus large.
  • Ajouter un document de planification/spécification décrivant les objectifs, le périmètre, les changements par fichier, la logique de nextUnpaidDueDate, la stratégie de migration, les considérations de risque/QA, et les PR de suivi dans l’epic de cohérence des factures.
  • Capturer et intégrer les demandes de changement du réviseur du plan et les invariants tels que le format mono-mois du ledger de paiement et le schéma de clé partagé avec cockpit/types.ts.
docs/plans/pr-a-payment-aware-due-date.md

Conseils et commandes

Interagir avec Sourcery

  • Déclencher une nouvelle revue : Commentez @sourcery-ai review sur la pull request.
  • Continuer les discussions : Répondez directement aux commentaires de revue de Sourcery.
  • Générer une issue GitHub à partir d’un commentaire de revue : Demandez à Sourcery de créer une issue à partir d’un commentaire de revue en y répondant. Vous pouvez aussi répondre à un commentaire de revue avec @sourcery-ai issue pour créer une issue à partir de celui-ci.
  • Générer un titre de pull request : Écrivez @sourcery-ai n’importe où dans le titre de la pull request pour générer un titre à tout moment. Vous pouvez également commenter @sourcery-ai title sur la pull request pour (re)générer le titre à tout moment.
  • Générer un résumé de pull request : Écrivez @sourcery-ai summary n’importe où dans le corps de la pull request pour générer un résumé de PR à tout moment exactement à l’endroit souhaité. Vous pouvez aussi commenter @sourcery-ai summary sur la pull request pour (re)générer le résumé à tout moment.
  • Générer un guide du réviseur : Commentez @sourcery-ai guide sur la pull request pour (re)générer le guide du réviseur à tout moment.
  • Résoudre tous les commentaires Sourcery : Commentez @sourcery-ai resolve sur la pull request pour résoudre tous les commentaires Sourcery. Utile si vous avez déjà traité tous les commentaires et ne souhaitez plus les voir.
  • Ignorer toutes les revues Sourcery : Commentez @sourcery-ai dismiss sur la pull request pour ignorer toutes les revues Sourcery existantes. Particulièrement utile si vous voulez repartir de zéro avec une nouvelle revue — n’oubliez pas de commenter @sourcery-ai review pour déclencher une nouvelle revue !

Personnaliser votre expérience

Accédez à votre dashboard pour :

  • Activer ou désactiver des fonctionnalités de revue comme le résumé de pull request généré par Sourcery, le guide du réviseur, et d’autres.
  • Changer la langue de la revue.
  • Ajouter, supprimer ou modifier des instructions de revue personnalisées.
  • Ajuster d’autres paramètres de revue.

Obtenir de l’aide

Original review guide in English

Reviewer's Guide

Introduces 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 resolution

sequenceDiagram
  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
Loading

File-Level Changes

Change Details Files
Make upcoming-charges computation payment-aware by using a new nextUnpaidDueDate resolver that surfaces passed-but-unpaid occurrences as overdue and skips already-paid periods.
  • Replace nextDueDateForCharge usage in getUpcomingCharges with nextUnpaidDueDate, keeping the public API the same while changing bucketing based on unpaid occurrences and overdue status.
  • Introduce PaymentAwareCharge and NextUnpaidDueResult types and the pure nextUnpaidDueDate implementation that scans from the current month over a bounded horizon, skips months outside paymentMonths or already paid in the ledger, clamps days to month length, and flags overdue via lexical date comparison.
  • Update upcoming charges tests to cover overdue behavior for passed-but-unpaid occurrences, skipping paid current-month occurrences, calendar edge cases for annual charges, and payment-aware skip semantics, while removing the old assumption that overdue was structurally unreachable.
src/lib/domain/charges/upcoming.ts
src/lib/domain/charges/next-unpaid-due-date.ts
src/lib/domain/charges/__tests__/next-unpaid-due-date.test.ts
src/lib/domain/charges/__tests__/upcoming.test.ts
src/lib/domain/charges/index.ts
Backfill and normalize charge payment_months on production data to match the canonical cadence derived from (frequency, due_month), fixing prior migration errors while remaining idempotent and non-destructive.
  • Add a Supabase migration that recomputes canonical payment_months arrays for monthly, quarterly, semiannual, and annual frequencies via SQL mirroring paymentMonthsFromFrequency, only updating rows whose stored arrays differ from the recomputed value.
  • Ensure the migration bypasses RLS by running as table owner, does not delete data, leaves unknown frequencies unchanged, and documents its role in the "Factures cohérentes" epic and interaction with later cadence-anchor corrections.
supabase/migrations/20260605000001_backfill_payment_months.sql
Document the payment-aware due-date design and its interaction with the cadence backfill and broader epic.
  • Add a planning/spec document describing goals, scope, file-level changes, nextUnpaidDueDate logic, migration strategy, risk/QA considerations, and follow-up PRs in the invoices coherence epic.
  • Capture and integrate plan-reviewer change requests and invariants such as the mono-month payment ledger format and keying scheme shared with cockpit/types.ts.
docs/plans/pr-a-payment-aware-due-date.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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é dans next-unpaid-due-date.ts, ses tests et cockpit/types.ts. Pensez à le centraliser dans un helper partagé (ou à réutiliser l’existant paymentKeyFor) pour éviter les divergences à l’avenir si le format change.
  • nextUnpaidDueDate réimplémente une logique d’analyse de dates ISO (fromIso.split('-') + vérifications manuelles). Si vous avez déjà un helper partagé utilisé par nextDueDateForCharge/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 ✨
Aidez-moi à être plus utile ! Cliquez sur 👍 ou 👎 sur chaque commentaire et j’utiliserai vos retours pour améliorer mes revues.
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 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.
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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/lib/domain/charges/next-unpaid-due-date.ts
@thierryvm thierryvm merged commit cf656d0 into main Jun 6, 2026
10 checks passed
@thierryvm thierryvm deleted the feat/thi-329-payment-aware-due-date branch June 6, 2026 22:02
thierryvm added a commit that referenced this pull request Jun 6, 2026
…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).
thierryvm added a commit that referenced this pull request Jun 6, 2026
…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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status:review-needed Ready for review type:feat New user-facing feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant