Skip to content

feat(commitments): foundations — schema, Zod contracts, pure schedule domain (épic PR-1) - #233

Merged
thierryvm merged 3 commits into
mainfrom
feat/commitments-foundations
Jul 20, 2026
Merged

feat(commitments): foundations — schema, Zod contracts, pure schedule domain (épic PR-1)#233
thierryvm merged 3 commits into
mainfrom
feat/commitments-foundations

Conversation

@thierryvm

@thierryvm thierryvm commented Jul 20, 2026

Copy link
Copy Markdown
Owner

PR-1 — Fondations « Engagements » (dettes, échéanciers, factures futures)

Première brique de l'épic issu de tes retours sur ton Coda : « pas les factures futures, les dettes liées à des crédits, ou un remboursement différé des impôts suite à un arrangement avec le SPF ».

Cette PR ne change RIEN de visible — schéma + domaine + contrats uniquement. L'UI arrive en PR-2.

Le modèle : un seul objet pour tes 3 besoins

debt (crédit voiture) installment_plan (SPF) one_off (facture future)
Ce que tu saisis reste 4 200 € · 17 × 250 € reste 1 600 € · 8 × 200 € 340 € en octobre
Ce qu'Ankora affiche (PR-2) barre de progression + solde « 3/8 payées · reste 1 000 € » « 340 € le 12 oct. »

Tout est dérivé (date de fin, solde restant, progression) — jamais stocké, donc jamais faux. Les paiements réutilisent exactement le mécanisme des factures : cocher une échéance = le geste que tu connais déjà.

Contenu

  • Migration commitments + commitment_payments : RLS, policies, index et trigger clonés 1:1 sur charges/charge_payments. Deux CHECKs encodent les invariants. Additive, aucune table existante touchée.
  • Domaine pur : installmentPeriods, endPeriod, isDueInPeriod, remainingBalance, installmentsPaid, isFinished. Le solde est borné à [0, total] : la dernière échéance absorbe l'arrondi (3 × 33,33 € sur 100 € tombe pile sur 0) et une coche parasite hors échéancier ne peut pas fausser la progression.
  • Contrats Zod miroirs des CHECKs SQL (la base ne peut pas refuser ce que le schéma a accepté).
  • supabase/types.ts régénéré depuis la prod via le CLI — remplace l'édition manuelle de feat(charges): watched marker + dashboard bills card rework (THI-329 PR-C) #227.

Décisions verrouillées avec toi (spec : docs/plans/epic-dettes-echeanciers-spec.md)

Nom « Engagements » · saisie « solde restant + échéances restantes » · bouton « convertir » depuis une charge (PR-2) · facture ponctuelle incluse.

Migration prod

Déjà appliquée (supabase db push, ledger vérifié). Comme aucune UI ne la consomme, la prod est inchangée dans les deux sens.

Tests

22 nouveaux (16 domaine + 6 schéma), suite complète 1513 verte, typecheck/lint/build clean.

🤖 Generated with Claude Code

Summary by Sourcery

Introduire un modèle de données fondamental et une logique métier pour des engagements financiers finis (dettes, plans de paiement échelonné et factures ponctuelles futures) sans aucun changement d’interface utilisateur.

Nouvelles fonctionnalités :

  • Ajouter les nouvelles tables commitments et commitment_payments, ainsi que les types Supabase associés et les politiques RLS, afin de modéliser les engagements financiers finis et leurs paiements.
  • Définir des schémas Zod pour valider les engagements en conformité avec les contraintes de la base de données.
  • Ajouter des fonctions de domaine pures pour calculer les calendriers d’engagement, les soldes restants et le statut de complétion.

Améliorations :

  • Régénérer les types TypeScript de Supabase à partir du schéma de production et supprimer les typings GraphQL manuels obsolètes.
  • Documenter l’epic « Dettes & échéanciers », y compris l’énoncé du problème, le modèle de données et le plan de déploiement.

Tests :

  • Ajouter des tests unitaires pour la nouvelle logique de domaine des calendriers d’engagements et les schémas Zod afin de garantir la justesse des calculs et des règles de validation.
Original summary in English

Summary by Sourcery

Introduce a foundational data model and domain logic for finite financial commitments (debts, installment plans, and one-off future bills) without any UI changes.

New Features:

  • Add new commitments and commitment_payments tables, plus related Supabase types and RLS policies, to model finite financial engagements and their payments.
  • Define Zod schemas for validating commitments in alignment with database constraints.
  • Add pure domain functions for computing commitment schedules, remaining balances, and completion status.

Enhancements:

  • Regenerate Supabase TypeScript types from the production schema and remove obsolete manual GraphQL typings.
  • Document the "Dettes & échéanciers" epic, including problem statement, data model, and rollout plan.

Tests:

  • Add unit tests for the new commitments schedule domain logic and Zod schemas to ensure correctness of calculations and validation rules.

… domain (épic Dettes & échéanciers PR-1)

Ankora only modelled INFINITE recurring charges. Three real needs did not fit
(@Thierry): a debt with a remaining balance (car loan), a FINITE instalment
plan (SPF arrangement), a one-off future bill. One object covers all three.

- Migration 20260719000001: commitments + commitment_payments. RLS/policies/
  indexes/trigger cloned 1:1 from charges/charge_payments. Two CHECKs encode
  the invariants (a one_off is exactly one instalment; multi-instalment kinds
  carry an instalment amount). ADDITIVE — no existing table touched.
- Pure domain (src/lib/domain/commitments): installmentPeriods, endPeriod,
  isDueInPeriod, remainingBalance, installmentsPaid, isFinished. Everything is
  DERIVED from anchor + cadence + count — the end date and the balance are
  never stored, so they cannot drift. Balance is clamped to [0, total]: the
  last instalment absorbs rounding (3 x 33.33 on 100 lands on exactly 0) and a
  stray off-schedule ledger tick can never over-count progress.
- Zod contracts mirroring the DB CHECKs. The update variant derives from an
  unrefined base (Zod v4 forbids .partial() on a refined schema) and only
  fires cross-field rules when both sides are present in the patch.
- supabase/types.ts regenerated from prod (CLI), replacing the hand-edited
  is_watched append from #227.

Migration APPLIED to prod (supabase db push, ledger verified) before merge —
the code ships behind no UI, so prod is unaffected either way.
Locked decisions in docs/plans/epic-dettes-echeanciers-spec.md.
22 new tests; full suite 1513 green.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@vercel

vercel Bot commented Jul 20, 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 Jul 20, 2026 4:40pm

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

sourcery-ai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Guide du relecteur

Présente le modèle de données fondamental, la logique métier pure et la couche de validation pour les « commitments » (dettes, plans de paiements échelonnés finis et factures ponctuelles futures), incluant de nouvelles tables Supabase et des types régénérés, sans aucun changement d’interface ou de comportement dans l’application existante.

Diagramme de relations d’entités pour le modèle de données des commitments

erDiagram
  commitments {
    uuid id
    text kind
    numeric total_amount
    numeric installment_amount
    smallint installments_total
    smallint start_year
    smallint start_month
    smallint payment_day
    text frequency
    boolean is_active
    uuid workspace_id
    uuid created_by
    uuid category_id
  }

  commitment_payments {
    uuid id
    uuid commitment_id
    uuid workspace_id
    smallint period_year
    smallint period_month
    numeric paid_amount
    timestamptz paid_at
    uuid created_by
  }

  users {
    uuid id
  }

  workspaces {
    uuid id
  }

  categories {
    uuid id
  }

  commitments ||--o{ commitment_payments : "has payments"
  workspaces ||--o{ commitments : "workspace_commitments"
  users ||--o{ commitments : "created_commitments"
  categories ||--o{ commitments : "categorized_commitments"
  workspaces ||--o{ commitment_payments : "workspace_commitment_payments"
  users ||--o{ commitment_payments : "created_commitment_payments"
Loading

Modifications au niveau des fichiers

Changement Détails Fichiers
Ajout des tables commitments et commitment_payments ainsi que des RLS/politiques pour modéliser les commitments finis et leurs paiements dans la base de données.
  • Créer la table public.commitments avec des champs pour le montant total, le montant/nombre des échéances, l’ancrage temporel, la fréquence, les métadonnées et des contraintes CHECK imposant les invariants pour les paiements ponctuels et échelonnés.
  • Créer la table public.commitment_payments calquée sur charge_payments, incluant l’unicité par (commitment, année, mois) et des index pour les recherches par espace de travail/période.
  • Activer et configurer la sécurité au niveau des lignes (row-level security) et les politiques éditeur/membre sur les nouvelles tables, en reflétant les contrats existants de charges/charge_payments, et ajouter un trigger updated_at pour commitments.
supabase/migrations/20260719000001_commitments.sql
Introduction d’un module métier pur pour calculer les échéanciers des commitments, le solde restant et l’état d’achèvement à partir des données stockées et des ticks de paiement.
  • Définir le type métier Commitment ainsi que les types de fréquence, de nature (kind) et Period pour représenter les commitments indépendamment de la couche de persistance.
  • Implémenter installmentPeriods, endPeriod, isDueInPeriod, installmentAmountOf, installmentsPaid, remainingBalance et isFinished comme fonctions pures qui dérivent l’échéancier et le solde, en bornant le solde restant à [0, total] et en ignorant les paiements hors calendrier.
  • Ajouter des tests unitaires couvrant les cadences mensuelles/trimestrielles, les commitments ponctuels, les règles d’exigibilité, le comportement d’arrondi et la robustesse face aux ticks de registre parasites.
src/lib/domain/commitments/schedule.ts
src/lib/domain/commitments/__tests__/schedule.test.ts
src/lib/domain/commitments/index.ts
Ajout de schémas Zod pour la création/mise à jour de commitments qui reflètent les contraintes SQL et encodent les mêmes invariants à la frontière de l’API.
  • Définir les enums commitmentKindSchema et commitmentFrequencySchema ainsi qu’un commitmentBaseSchema partagé avec des bornes qui correspondent aux contraintes CHECK SQL pour les montants, dates et durées.
  • Implémenter commitmentInputSchema avec des raffinements imposant que les commitments one_off aient exactement une échéance et que les commitments multi-échéances fournissent un installmentAmount, en reflétant les CHECKs de la base de données.
  • Fournir un commitmentUpdateSchema partiel pour les mises à jour partielles (patch) qui n’applique les règles inter-champs que lorsque tous les champs concernés sont présents, et ajouter des tests vérifiant l’acceptation/le rejet des formes valides/invalides et le comportement de valorisation par défaut.
src/lib/schemas/commitment.ts
src/lib/schemas/__tests__/commitment.test.ts
Régénérer les types et fonctions TypeScript Supabase pour inclure les commitments et nettoyer les anciens correctifs manuels.
  • Étendre Database['public'].Tables avec des définitions typées de tables commitments et commitment_payments incluant les types Row/Insert/Update et les relations avec les utilisateurs, espaces de travail et catégories.
  • Ajouter la fonction seed_default_categories à la map de types public.Functions et supprimer le schéma obsolète graphql_public ainsi que les constantes associées.
  • Supprimer les commentaires/correctifs manuels désormais obsolètes dans les types générés, en s’appuyant à la place sur supabase db push + génération des types.
src/lib/supabase/types.ts
Documenter le cahier des charges de l’épopée « Dettes & échéanciers » et le rôle de cette PR en tant qu’étape fondatrice sans interface.
  • Ajouter un document de spécification décrivant le problème, les non-objectifs, le modèle unifié de commitments, les calculs métier dérivés, les surfaces d’interface prévues et une stratégie de déploiement en trois PR.
  • Capturer et verrouiller les décisions produit clés (terminologie, inclusion du ponctuel, modèle d’entrée et future voie de migration « conversion depuis une charge »).
docs/plans/epic-dettes-echeanciers-spec.md

Conseils et commandes

Interagir avec Sourcery

  • Déclencher une nouvelle revue : Commentez @sourcery-ai review sur la pull request.
  • Poursuivre les discussions : Répondez directement aux commentaires de revue de Sourcery.
  • Générer un ticket GitHub à partir d’un commentaire de revue : Demandez à Sourcery de créer un ticket à 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 un ticket à 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 le guide du relecteur : Commentez @sourcery-ai guide sur la pull request pour (re)générer le guide du relecteur à 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.
  • Rejeter toutes les revues Sourcery : Commentez @sourcery-ai dismiss sur la pull request pour rejeter 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 tableau de bord pour :

  • Activer ou désactiver des fonctionnalités de revue telles que le résumé de pull request généré par Sourcery, le guide du relecteur, 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 the foundational data model, pure domain logic, and validation layer for "commitments" (debts, finite installment plans, and one-off future bills), including new Supabase tables and regenerated types, without any UI or behavioral changes to the existing app.

Entity relationship diagram for commitments data model

erDiagram
  commitments {
    uuid id
    text kind
    numeric total_amount
    numeric installment_amount
    smallint installments_total
    smallint start_year
    smallint start_month
    smallint payment_day
    text frequency
    boolean is_active
    uuid workspace_id
    uuid created_by
    uuid category_id
  }

  commitment_payments {
    uuid id
    uuid commitment_id
    uuid workspace_id
    smallint period_year
    smallint period_month
    numeric paid_amount
    timestamptz paid_at
    uuid created_by
  }

  users {
    uuid id
  }

  workspaces {
    uuid id
  }

  categories {
    uuid id
  }

  commitments ||--o{ commitment_payments : "has payments"
  workspaces ||--o{ commitments : "workspace_commitments"
  users ||--o{ commitments : "created_commitments"
  categories ||--o{ commitments : "categorized_commitments"
  workspaces ||--o{ commitment_payments : "workspace_commitment_payments"
  users ||--o{ commitment_payments : "created_commitment_payments"
Loading

File-Level Changes

Change Details Files
Add commitments and commitment_payments tables plus RLS/policies to model finite commitments and their payments in the database.
  • Create public.commitments table with fields for total amount, installment amount/count, temporal anchor, frequency, metadata, and CHECK constraints enforcing one-off and installment invariants.
  • Create public.commitment_payments table mirroring charge_payments, including uniqueness per (commitment, year, month) and indices for workspace/period lookups.
  • Enable and configure row-level security and editor/member policies on the new tables, mirroring existing charges/charge_payments contracts, and add an updated_at trigger for commitments.
supabase/migrations/20260719000001_commitments.sql
Introduce pure domain module for computing commitment schedules, remaining balance, and completion state from stored data and payment ticks.
  • Define Commitment domain type plus frequency, kind, and Period types to represent commitments independently of persistence.
  • Implement installmentPeriods, endPeriod, isDueInPeriod, installmentAmountOf, installmentsPaid, remainingBalance, and isFinished as pure functions that derive schedule and balance, clamping remaining balance to [0, total] and ignoring off-schedule payments.
  • Add unit tests covering monthly/quarterly cadences, one-off commitments, due-ness rules, rounding behavior, and robustness to stray ledger ticks.
src/lib/domain/commitments/schedule.ts
src/lib/domain/commitments/__tests__/schedule.test.ts
src/lib/domain/commitments/index.ts
Add Zod schemas for commitment creation/update that mirror SQL constraints and encode the same invariants at the API boundary.
  • Define commitmentKindSchema and commitmentFrequencySchema enums and a shared commitmentBaseSchema with bounds that match the SQL CHECK constraints for amounts, dates, and lengths.
  • Implement commitmentInputSchema with refinements enforcing that one_off commitments have exactly one installment and that multi-installment commitments provide an installmentAmount, mirroring database CHECKs.
  • Provide a partial commitmentUpdateSchema for patch updates that only enforces cross-field rules when all relevant fields are present, and add tests verifying acceptance/rejection of valid/invalid shapes and defaulting behavior.
src/lib/schemas/commitment.ts
src/lib/schemas/__tests__/commitment.test.ts
Regenerate Supabase TypeScript types and functions to include commitments and cleanup previous manual patches.
  • Extend Database['public'].Tables with typed commitments and commitment_payments table definitions including Row/Insert/Update types and relationships to users, workspaces, and categories.
  • Add the seed_default_categories function to the public.Functions type map and remove the obsolete graphql_public schema and associated constants.
  • Remove now-obsolete manual comments/patches in the generated types, relying on supabase db push + types generation instead.
src/lib/supabase/types.ts
Document the "Dettes & échéanciers" epic spec and the role of this PR as the foundational, non-UI step.
  • Add a spec document describing the problem, non-goals, unified commitments model, derived domain computations, planned UI surfaces, and three-PR rollout strategy.
  • Capture and mark as locked the key product decisions (terminology, inclusion of one-off, input model, and future "convert from charge" migration path).
docs/plans/epic-dettes-echeanciers-spec.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é 8 problèmes et laissé quelques retours de haut niveau :

  • Dans commitmentUpdateSchema, tu ne réappliques que la règle « one_off » (un seul versement) ; pense aussi à refléter la condition CHECK installment_amount_required pour les patchs où installmentsTotal et installmentAmount sont tous les deux présents, afin que le schéma côté client n’accepte pas des mises à jour que la base refusera.
  • isDueInPeriod recalcule actuellement tout le tableau installmentPeriods à chaque appel ; si ça finit dans un chemin chaud (par ex. calculs de budget par ligne), tu pourrais le remplacer par un contrôle arithmétique direct basé sur startYear/startMonth, installmentsTotal et frequency pour éviter des allocations inutiles.
Prompt pour agents IA
Please address the comments from this code review:

## Overall Comments
- In `commitmentUpdateSchema` you only reapply the `one_off` single-installment rule; consider also mirroring the `installment_amount_required` CHECK condition for patches where both `installmentsTotal` and `installmentAmount` are present, so the client-side schema doesn’t accept updates the DB will reject.
- `isDueInPeriod` currently recomputes the full `installmentPeriods` array on every call; if this ends up in any hot path (e.g. per-row budget calculations), you might want to replace it with a direct arithmetic check against `startYear/startMonth`, `installmentsTotal` and `frequency` to avoid unnecessary allocations.

## Individual Comments

### Comment 1
<location path="src/lib/schemas/commitment.ts" line_range="18-20" />
<code_context>
+  error: 'commitment.frequency.invalid',
+});
+
+const amountSchema = (key: string) =>
+  z
+    .number({ error: key })
+    .finite({ message: key })
+    .min(0, { message: `${key}.negative` })
</code_context>
<issue_to_address>
**issue (bug_risk):** The Zod `number` factory option key `error` is likely ignored; use `invalid_type_error` / `required_error` instead.

As written, `z.number({ error: key })` will ignore `error` and use default messages, so your i18n key won’t be used. Use the specific options instead, e.g. `z.number({ invalid_type_error: key, required_error: key })`, depending on which cases you want to customize.
</issue_to_address>

### Comment 2
<location path="src/lib/schemas/commitment.ts" line_range="38" />
<code_context>
+    .max(120, { message: 'commitment.label.tooLong' }),
+  kind: commitmentKindSchema,
+  /** Amount still engaged (locked decision D3: the REMAINING balance). */
+  totalAmount: amountSchema('commitment.totalAmount.invalid'),
+  /** One instalment; omitted for a one-off (the total is due at once). */
+  installmentAmount: amountSchema('commitment.installmentAmount.invalid').optional(),
</code_context>
<issue_to_address>
**suggestion (bug_risk):** `totalAmount` schema upper bound does not match the SQL constraints despite the comment claiming parity.

The DB `total_amount` column only has `CHECK (total_amount >= 0)` with no max, while `amountSchema` applies `.max(10_000_000, ...)`. So values between 10M and the DB numeric limit are allowed in SQL but rejected by the schema, conflicting with the claim that bounds mirror the SQL CHECKs. Please either add a matching DB upper bound, relax/remove the Zod max, and/or update the comment so it no longer implies full parity.

Suggested implementation:

```typescript
  /**
   * Amount still engaged (locked decision D3: the REMAINING balance).
   *
   * Note: the lower bound mirrors the DB `CHECK (total_amount >= 0)`.
   * The upper bound comes from `amountSchema` and is a stricter frontend-only constraint,
   * not enforced by the database.
   */

```

If there are other comments in this file (or nearby schema modules) that state or imply that *all* Zod bounds mirror the SQL `CHECK` constraints, they should be updated similarly to clarify that only the non-negativity of `totalAmount` matches the DB constraint, while the upper bound is frontend-only.
</issue_to_address>

### Comment 3
<location path="src/lib/schemas/commitment.ts" line_range="90-92" />
<code_context>
+ * are present in the patch — a lone `label` edit must never be rejected for a
+ * rule about fields it does not touch. The DB CHECKs remain the backstop.
+ */
+export const commitmentUpdateSchema = commitmentBaseSchema
+  .partial()
+  .refine(
+    (v) => v.kind !== 'one_off' || v.installmentsTotal === undefined || v.installmentsTotal === 1,
+    {
</code_context>
<issue_to_address>
**issue (bug_risk):** `commitmentUpdateSchema` does not re-apply the `installmentAmount` cross-field requirement, diverging from the create rules and DB CHECK.

Creates enforce `installmentsTotal === 1 || installmentAmount !== undefined`, matching the `commitments_installment_amount_required` CHECK, but the update schema only enforces the one-off/single-installment rule. That means a patch with `installmentsTotal > 1` and no `installmentAmount` will pass Zod and then fail at the DB layer. If you want “same invariants as create, but only when the fields are being patched”, you could use a `superRefine` that validates the `installmentsTotal`/`installmentAmount` relationship when either field is present in the patch object.
</issue_to_address>

### Comment 4
<location path="src/lib/domain/commitments/__tests__/schedule.test.ts" line_range="31" />
<code_context>
+const paidSet = (periods: Array<[number, number]>): ReadonlySet<string> =>
+  new Set(periods.map(([y, m]) => `${y}-${m}`));
+
+describe('installmentPeriods', () => {
+  it('lists every monthly instalment from the anchor', () => {
+    const periods = installmentPeriods(
</code_context>
<issue_to_address>
**suggestion (testing):** Add coverage for `semiannual` and `annual` frequencies in `installmentPeriods` and `isDueInPeriod`

Right now the tests only cover `monthly`, `quarterly`, and `one_off`, but the domain also supports `semiannual` and `annual`. Please add tests that:
- assert the generated periods for `semiannual` and `annual` (including year roll-over), and
- verify `isDueInPeriod` only returns true for the correctly aligned months.
This will guard against regressions in the `MONTHS_BETWEEN` mapping and the month/year arithmetic for longer cadences.

Suggested implementation:

```typescript
describe('installmentPeriods – longer cadences', () => {
  it('lists semiannual installments including year roll-over', () => {
    const periods = installmentPeriods(
      commitment({
        frequency: 'semiannual',
        installmentsTotal: 4,
        startYear: 2026,
        startMonth: 7,
      }),
    );

    expect(periods).toEqual([
      { year: 2026, month: 7 },
      { year: 2027, month: 1 },
      { year: 2027, month: 7 },
      { year: 2028, month: 1 },
    ]);
  });

  it('lists annual installments including multi-year span', () => {
    const periods = installmentPeriods(
      commitment({
        frequency: 'annual',
        installmentsTotal: 3,
        startYear: 2025,
        startMonth: 11,
      }),
    );

    expect(periods).toEqual([
      { year: 2025, month: 11 },
      { year: 2026, month: 11 },
      { year: 2027, month: 11 },
    ]);
  });
});

const paidSet = (periods: Array<[number, number]>): ReadonlySet<string> =>
  new Set(periods.map(([y, m]) => `${y}-${m}`));

describe('installmentPeriods', () => {

```

To fully implement your comment, you should also extend the `isDueInPeriod` tests (not shown in the snippet). Following the existing style in that file:

1. In the `describe('isDueInPeriod', ...)` block (or create one if it does not exist yet), add tests along these lines:
   - For **semiannual**:
     - A commitment with `frequency: 'semiannual'`, e.g. `startYear: 2026, startMonth: 2`.
     - Assert that `isDueInPeriod` returns `true` only for months spaced by 6 (e.g. 2026‑02, 2026‑08, 2027‑02, …) and `false` for misaligned months (e.g. 2026‑03, 2026‑07, etc.).
   - For **annual**:
     - A commitment with `frequency: 'annual'`, e.g. `startYear: 2025, startMonth: 11`.
     - Assert that `isDueInPeriod` returns `true` for 2025‑11, 2026‑11, 2027‑11 and `false` for other months/years (e.g. 2026‑10, 2026‑12).
2. Prefer using the existing `paidSet` helper if there is a pattern of computing all due periods and comparing sets, e.g.:
   - Iterate over a range of `[year, month]` pairs, call `isDueInPeriod` for each, collect the `(year, month)` where it is `true`, and compare to an expected set using `paidSet`.
3. Keep naming consistent with existing tests, e.g. `"returns true for correctly aligned semiannual periods"` and `"returns false for misaligned semiannual periods"`, and likewise for annual.
</issue_to_address>

### Comment 5
<location path="src/lib/domain/commitments/__tests__/schedule.test.ts" line_range="62" />
<code_context>
+  });
+});
+
+describe('endPeriod', () => {
+  it('derives the LAST instalment period (never stored)', () => {
+    expect(
</code_context>
<issue_to_address>
**suggestion (testing):** Exercise `endPeriod` for non-monthly commitments and a longer schedule

Since `endPeriod` depends on `installmentPeriods`, consider adding coverage for a non-monthly plan (e.g. `quarterly` or `semiannual`) to verify the final period matches the expected cadence, and another test where `installmentsTotal` spans multiple years to validate long-running “SPF-like” schedules from the spec.

Suggested implementation:

```typescript
describe('endPeriod', () => {
  it('derives the LAST instalment period (never stored)', () => {
    expect(
      endPeriod(commitment({ installmentsTotal: 3, startYear: 2026, startMonth: 11 })),
    ).toEqual({ year: 2027, month: 1 });
  });

  it('equals the anchor for a one-off', () => {
    expect(endPeriod(commitment({ kind: 'one_off', installmentsTotal: 1 }))).toEqual({
      year: 2026,
      month: 8,
    });
  });

  it('respects non-monthly cadence for the final period', () => {
    expect(
      endPeriod(
        commitment({
          installmentsTotal: 8,
          startYear: 2026,
          startMonth: 2,
          cadence: 'quarterly',
        }),
      ),
    ).toEqual({ year: 2027, month: 11 });
  });

  it('handles long-running schedules spanning multiple years', () => {
    expect(
      endPeriod(
        commitment({
          installmentsTotal: 60,
          startYear: 2024,
          startMonth: 6,
        }),
      ),
    ).toEqual({ year: 2029, month: 5 });
  });

```

1. Ensure the `commitment` helper supports a `cadence` (or similar) property and that `'quarterly'` is the correct value for a 3‑month interval. If the existing API uses a different key (e.g. `frequency`, `interval`, `periodicity`) or different enum/string values, update the property name and value in the new `cadence: 'quarterly'` usage accordingly.
2. Confirm that the default cadence used by `commitment(...)` when `cadence` is omitted is monthly; if not, explicitly set the monthly cadence in the "long-running schedules spanning multiple years" test to align with how SPF-like schedules are defined in your spec.
3. If `endPeriod` or `installmentPeriods` currently only support monthly cadences, you will need to extend their implementations to handle quarterly (or other non-monthly) cadences so that these new tests pass.
</issue_to_address>

### Comment 6
<location path="src/lib/domain/commitments/__tests__/schedule.test.ts" line_range="120" />
<code_context>
+  });
+});
+
+describe('remainingBalance / installmentsPaid / isFinished', () => {
+  const c = commitment({ installmentsTotal: 3, startYear: 2026, startMonth: 11, totalAmount: 750 });
+
</code_context>
<issue_to_address>
**suggestion (testing):** Consider an explicit unit test for `periodKey` and for stray ticks before the schedule start

The current tests cover rounding, extra ticks after the schedule, and the link between `remainingBalance`, `installmentsPaid`, and `isFinished`. To tighten this:
- Add a focused test for `periodKey` to lock in the `year-month` ledger key format.
- Add a case where the paid set includes a period before `startYear/startMonth`, asserting it’s ignored by `installmentsPaid` (mirroring the off-schedule future tick behavior).
These will fully specify what counts as a scheduled tick from the ledger’s perspective.

Suggested implementation:

```typescript
  });
});

describe('periodKey', () => {
  it('formats as year-month with zero-padded month', () => {
    expect(periodKey({ year: 2026, month: 1 })).toBe('2026-01');
    expect(periodKey({ year: 2026, month: 11 })).toBe('2026-11');
  });
});

describe('installmentsPaid with stray pre-start ticks', () => {
  const c: Commitment = {
    kind: 'installments',
    startYear: 2026,
    startMonth: 3,
    installmentsTotal: 3,
    totalAmount: 750,
    installmentAmount: null,
  };

  it('ignores ledger ticks before the schedule start period', () => {
    const paid = new Set<string>([
      periodKey({ year: 2026, month: 1 }), // before start
      periodKey({ year: 2026, month: 2 }), // before start
      periodKey({ year: 2026, month: 3 }),
      periodKey({ year: 2026, month: 4 }),
    ]);

    expect(installmentsPaid(c, paid)).toBe(2);
  });
});

import {

```

```typescript
  installmentAmountOf,
  remainingBalance,
  installmentsPaid,
  isFinished,
  periodKey,
  type Commitment,
} from '../schedule';

```
</issue_to_address>

### Comment 7
<location path="src/lib/schemas/__tests__/commitment.test.ts" line_range="3" />
<code_context>
+import { describe, it, expect } from 'vitest';
+
+import { commitmentInputSchema } from '../commitment';
+
+const validDebt = {
</code_context>
<issue_to_address>
**issue (testing):** Add tests for `commitmentUpdateSchema`, especially the partial cross-field rules

The update spec says cross-field rules only re-apply when both fields are present, but there are no tests for `commitmentUpdateSchema`. Please import `commitmentUpdateSchema` here and add tests that verify:
- patches changing only `label` or `notes` succeed regardless of other fields,
- patches with `kind: 'one_off'` and `installmentsTotal > 1` are rejected,
- patches where only one of `kind` or `installmentsTotal` is provided behave correctly.
This helps ensure partial updates aren’t blocked by constraints on untouched fields and that SQL CHECKs and Zod stay consistent for updates as well as creates.
</issue_to_address>

### Comment 8
<location path="src/lib/schemas/__tests__/commitment.test.ts" line_range="50-55" />
<code_context>
+    expect(r.success).toBe(false);
+  });
+
+  it('rejects a negative balance and an out-of-range month', () => {
+    expect(commitmentInputSchema.safeParse({ ...validDebt, totalAmount: -1 }).success).toBe(false);
+    expect(commitmentInputSchema.safeParse({ ...validDebt, startMonth: 13 }).success).toBe(false);
+  });
+
+  it('rejects an empty label and more than 600 instalments', () => {
+    expect(commitmentInputSchema.safeParse({ ...validDebt, label: '  ' }).success).toBe(false);
+    expect(commitmentInputSchema.safeParse({ ...validDebt, installmentsTotal: 601 }).success).toBe(
</code_context>
<issue_to_address>
**suggestion (testing):** Broaden schema tests to cover boundary values and key invariants (frequency, paymentDay, categoryId, max amount)

Existing tests already cover several key constraints. To better mirror the SQL CHECKs, consider adding targeted cases for:
- `paymentDay` at 1/31 (accepted) and 0/32 (rejected)
- `installmentsTotal` at 1 and 600 (accepted) and 0 (rejected)
- an invalid `frequency` value (e.g. `'weekly'`) to verify enum validation
- a non-UUID `categoryId` to verify UUID validation
Optionally, also test that a `totalAmount` just above `10_000_000` is rejected to confirm the upper bound.
</issue_to_address>

Sourcery est gratuit pour l’open source - si vous aimez nos revues, pensez à les partager ✨
Aidez-moi à être plus utile ! Merci de cliquer sur 👍 ou 👎 sur chaque commentaire et j’utiliserai vos retours pour améliorer les revues.
Original comment in English

Hey - I've found 8 issues, and left some high level feedback:

  • In commitmentUpdateSchema you only reapply the one_off single-installment rule; consider also mirroring the installment_amount_required CHECK condition for patches where both installmentsTotal and installmentAmount are present, so the client-side schema doesn’t accept updates the DB will reject.
  • isDueInPeriod currently recomputes the full installmentPeriods array on every call; if this ends up in any hot path (e.g. per-row budget calculations), you might want to replace it with a direct arithmetic check against startYear/startMonth, installmentsTotal and frequency to avoid unnecessary allocations.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `commitmentUpdateSchema` you only reapply the `one_off` single-installment rule; consider also mirroring the `installment_amount_required` CHECK condition for patches where both `installmentsTotal` and `installmentAmount` are present, so the client-side schema doesn’t accept updates the DB will reject.
- `isDueInPeriod` currently recomputes the full `installmentPeriods` array on every call; if this ends up in any hot path (e.g. per-row budget calculations), you might want to replace it with a direct arithmetic check against `startYear/startMonth`, `installmentsTotal` and `frequency` to avoid unnecessary allocations.

## Individual Comments

### Comment 1
<location path="src/lib/schemas/commitment.ts" line_range="18-20" />
<code_context>
+  error: 'commitment.frequency.invalid',
+});
+
+const amountSchema = (key: string) =>
+  z
+    .number({ error: key })
+    .finite({ message: key })
+    .min(0, { message: `${key}.negative` })
</code_context>
<issue_to_address>
**issue (bug_risk):** The Zod `number` factory option key `error` is likely ignored; use `invalid_type_error` / `required_error` instead.

As written, `z.number({ error: key })` will ignore `error` and use default messages, so your i18n key won’t be used. Use the specific options instead, e.g. `z.number({ invalid_type_error: key, required_error: key })`, depending on which cases you want to customize.
</issue_to_address>

### Comment 2
<location path="src/lib/schemas/commitment.ts" line_range="38" />
<code_context>
+    .max(120, { message: 'commitment.label.tooLong' }),
+  kind: commitmentKindSchema,
+  /** Amount still engaged (locked decision D3: the REMAINING balance). */
+  totalAmount: amountSchema('commitment.totalAmount.invalid'),
+  /** One instalment; omitted for a one-off (the total is due at once). */
+  installmentAmount: amountSchema('commitment.installmentAmount.invalid').optional(),
</code_context>
<issue_to_address>
**suggestion (bug_risk):** `totalAmount` schema upper bound does not match the SQL constraints despite the comment claiming parity.

The DB `total_amount` column only has `CHECK (total_amount >= 0)` with no max, while `amountSchema` applies `.max(10_000_000, ...)`. So values between 10M and the DB numeric limit are allowed in SQL but rejected by the schema, conflicting with the claim that bounds mirror the SQL CHECKs. Please either add a matching DB upper bound, relax/remove the Zod max, and/or update the comment so it no longer implies full parity.

Suggested implementation:

```typescript
  /**
   * Amount still engaged (locked decision D3: the REMAINING balance).
   *
   * Note: the lower bound mirrors the DB `CHECK (total_amount >= 0)`.
   * The upper bound comes from `amountSchema` and is a stricter frontend-only constraint,
   * not enforced by the database.
   */

```

If there are other comments in this file (or nearby schema modules) that state or imply that *all* Zod bounds mirror the SQL `CHECK` constraints, they should be updated similarly to clarify that only the non-negativity of `totalAmount` matches the DB constraint, while the upper bound is frontend-only.
</issue_to_address>

### Comment 3
<location path="src/lib/schemas/commitment.ts" line_range="90-92" />
<code_context>
+ * are present in the patch — a lone `label` edit must never be rejected for a
+ * rule about fields it does not touch. The DB CHECKs remain the backstop.
+ */
+export const commitmentUpdateSchema = commitmentBaseSchema
+  .partial()
+  .refine(
+    (v) => v.kind !== 'one_off' || v.installmentsTotal === undefined || v.installmentsTotal === 1,
+    {
</code_context>
<issue_to_address>
**issue (bug_risk):** `commitmentUpdateSchema` does not re-apply the `installmentAmount` cross-field requirement, diverging from the create rules and DB CHECK.

Creates enforce `installmentsTotal === 1 || installmentAmount !== undefined`, matching the `commitments_installment_amount_required` CHECK, but the update schema only enforces the one-off/single-installment rule. That means a patch with `installmentsTotal > 1` and no `installmentAmount` will pass Zod and then fail at the DB layer. If you want “same invariants as create, but only when the fields are being patched”, you could use a `superRefine` that validates the `installmentsTotal`/`installmentAmount` relationship when either field is present in the patch object.
</issue_to_address>

### Comment 4
<location path="src/lib/domain/commitments/__tests__/schedule.test.ts" line_range="31" />
<code_context>
+const paidSet = (periods: Array<[number, number]>): ReadonlySet<string> =>
+  new Set(periods.map(([y, m]) => `${y}-${m}`));
+
+describe('installmentPeriods', () => {
+  it('lists every monthly instalment from the anchor', () => {
+    const periods = installmentPeriods(
</code_context>
<issue_to_address>
**suggestion (testing):** Add coverage for `semiannual` and `annual` frequencies in `installmentPeriods` and `isDueInPeriod`

Right now the tests only cover `monthly`, `quarterly`, and `one_off`, but the domain also supports `semiannual` and `annual`. Please add tests that:
- assert the generated periods for `semiannual` and `annual` (including year roll-over), and
- verify `isDueInPeriod` only returns true for the correctly aligned months.
This will guard against regressions in the `MONTHS_BETWEEN` mapping and the month/year arithmetic for longer cadences.

Suggested implementation:

```typescript
describe('installmentPeriods – longer cadences', () => {
  it('lists semiannual installments including year roll-over', () => {
    const periods = installmentPeriods(
      commitment({
        frequency: 'semiannual',
        installmentsTotal: 4,
        startYear: 2026,
        startMonth: 7,
      }),
    );

    expect(periods).toEqual([
      { year: 2026, month: 7 },
      { year: 2027, month: 1 },
      { year: 2027, month: 7 },
      { year: 2028, month: 1 },
    ]);
  });

  it('lists annual installments including multi-year span', () => {
    const periods = installmentPeriods(
      commitment({
        frequency: 'annual',
        installmentsTotal: 3,
        startYear: 2025,
        startMonth: 11,
      }),
    );

    expect(periods).toEqual([
      { year: 2025, month: 11 },
      { year: 2026, month: 11 },
      { year: 2027, month: 11 },
    ]);
  });
});

const paidSet = (periods: Array<[number, number]>): ReadonlySet<string> =>
  new Set(periods.map(([y, m]) => `${y}-${m}`));

describe('installmentPeriods', () => {

```

To fully implement your comment, you should also extend the `isDueInPeriod` tests (not shown in the snippet). Following the existing style in that file:

1. In the `describe('isDueInPeriod', ...)` block (or create one if it does not exist yet), add tests along these lines:
   - For **semiannual**:
     - A commitment with `frequency: 'semiannual'`, e.g. `startYear: 2026, startMonth: 2`.
     - Assert that `isDueInPeriod` returns `true` only for months spaced by 6 (e.g. 2026‑02, 2026‑08, 2027‑02, …) and `false` for misaligned months (e.g. 2026‑03, 2026‑07, etc.).
   - For **annual**:
     - A commitment with `frequency: 'annual'`, e.g. `startYear: 2025, startMonth: 11`.
     - Assert that `isDueInPeriod` returns `true` for 2025‑11, 2026‑11, 2027‑11 and `false` for other months/years (e.g. 2026‑10, 2026‑12).
2. Prefer using the existing `paidSet` helper if there is a pattern of computing all due periods and comparing sets, e.g.:
   - Iterate over a range of `[year, month]` pairs, call `isDueInPeriod` for each, collect the `(year, month)` where it is `true`, and compare to an expected set using `paidSet`.
3. Keep naming consistent with existing tests, e.g. `"returns true for correctly aligned semiannual periods"` and `"returns false for misaligned semiannual periods"`, and likewise for annual.
</issue_to_address>

### Comment 5
<location path="src/lib/domain/commitments/__tests__/schedule.test.ts" line_range="62" />
<code_context>
+  });
+});
+
+describe('endPeriod', () => {
+  it('derives the LAST instalment period (never stored)', () => {
+    expect(
</code_context>
<issue_to_address>
**suggestion (testing):** Exercise `endPeriod` for non-monthly commitments and a longer schedule

Since `endPeriod` depends on `installmentPeriods`, consider adding coverage for a non-monthly plan (e.g. `quarterly` or `semiannual`) to verify the final period matches the expected cadence, and another test where `installmentsTotal` spans multiple years to validate long-running “SPF-like” schedules from the spec.

Suggested implementation:

```typescript
describe('endPeriod', () => {
  it('derives the LAST instalment period (never stored)', () => {
    expect(
      endPeriod(commitment({ installmentsTotal: 3, startYear: 2026, startMonth: 11 })),
    ).toEqual({ year: 2027, month: 1 });
  });

  it('equals the anchor for a one-off', () => {
    expect(endPeriod(commitment({ kind: 'one_off', installmentsTotal: 1 }))).toEqual({
      year: 2026,
      month: 8,
    });
  });

  it('respects non-monthly cadence for the final period', () => {
    expect(
      endPeriod(
        commitment({
          installmentsTotal: 8,
          startYear: 2026,
          startMonth: 2,
          cadence: 'quarterly',
        }),
      ),
    ).toEqual({ year: 2027, month: 11 });
  });

  it('handles long-running schedules spanning multiple years', () => {
    expect(
      endPeriod(
        commitment({
          installmentsTotal: 60,
          startYear: 2024,
          startMonth: 6,
        }),
      ),
    ).toEqual({ year: 2029, month: 5 });
  });

```

1. Ensure the `commitment` helper supports a `cadence` (or similar) property and that `'quarterly'` is the correct value for a 3‑month interval. If the existing API uses a different key (e.g. `frequency`, `interval`, `periodicity`) or different enum/string values, update the property name and value in the new `cadence: 'quarterly'` usage accordingly.
2. Confirm that the default cadence used by `commitment(...)` when `cadence` is omitted is monthly; if not, explicitly set the monthly cadence in the "long-running schedules spanning multiple years" test to align with how SPF-like schedules are defined in your spec.
3. If `endPeriod` or `installmentPeriods` currently only support monthly cadences, you will need to extend their implementations to handle quarterly (or other non-monthly) cadences so that these new tests pass.
</issue_to_address>

### Comment 6
<location path="src/lib/domain/commitments/__tests__/schedule.test.ts" line_range="120" />
<code_context>
+  });
+});
+
+describe('remainingBalance / installmentsPaid / isFinished', () => {
+  const c = commitment({ installmentsTotal: 3, startYear: 2026, startMonth: 11, totalAmount: 750 });
+
</code_context>
<issue_to_address>
**suggestion (testing):** Consider an explicit unit test for `periodKey` and for stray ticks before the schedule start

The current tests cover rounding, extra ticks after the schedule, and the link between `remainingBalance`, `installmentsPaid`, and `isFinished`. To tighten this:
- Add a focused test for `periodKey` to lock in the `year-month` ledger key format.
- Add a case where the paid set includes a period before `startYear/startMonth`, asserting it’s ignored by `installmentsPaid` (mirroring the off-schedule future tick behavior).
These will fully specify what counts as a scheduled tick from the ledger’s perspective.

Suggested implementation:

```typescript
  });
});

describe('periodKey', () => {
  it('formats as year-month with zero-padded month', () => {
    expect(periodKey({ year: 2026, month: 1 })).toBe('2026-01');
    expect(periodKey({ year: 2026, month: 11 })).toBe('2026-11');
  });
});

describe('installmentsPaid with stray pre-start ticks', () => {
  const c: Commitment = {
    kind: 'installments',
    startYear: 2026,
    startMonth: 3,
    installmentsTotal: 3,
    totalAmount: 750,
    installmentAmount: null,
  };

  it('ignores ledger ticks before the schedule start period', () => {
    const paid = new Set<string>([
      periodKey({ year: 2026, month: 1 }), // before start
      periodKey({ year: 2026, month: 2 }), // before start
      periodKey({ year: 2026, month: 3 }),
      periodKey({ year: 2026, month: 4 }),
    ]);

    expect(installmentsPaid(c, paid)).toBe(2);
  });
});

import {

```

```typescript
  installmentAmountOf,
  remainingBalance,
  installmentsPaid,
  isFinished,
  periodKey,
  type Commitment,
} from '../schedule';

```
</issue_to_address>

### Comment 7
<location path="src/lib/schemas/__tests__/commitment.test.ts" line_range="3" />
<code_context>
+import { describe, it, expect } from 'vitest';
+
+import { commitmentInputSchema } from '../commitment';
+
+const validDebt = {
</code_context>
<issue_to_address>
**issue (testing):** Add tests for `commitmentUpdateSchema`, especially the partial cross-field rules

The update spec says cross-field rules only re-apply when both fields are present, but there are no tests for `commitmentUpdateSchema`. Please import `commitmentUpdateSchema` here and add tests that verify:
- patches changing only `label` or `notes` succeed regardless of other fields,
- patches with `kind: 'one_off'` and `installmentsTotal > 1` are rejected,
- patches where only one of `kind` or `installmentsTotal` is provided behave correctly.
This helps ensure partial updates aren’t blocked by constraints on untouched fields and that SQL CHECKs and Zod stay consistent for updates as well as creates.
</issue_to_address>

### Comment 8
<location path="src/lib/schemas/__tests__/commitment.test.ts" line_range="50-55" />
<code_context>
+    expect(r.success).toBe(false);
+  });
+
+  it('rejects a negative balance and an out-of-range month', () => {
+    expect(commitmentInputSchema.safeParse({ ...validDebt, totalAmount: -1 }).success).toBe(false);
+    expect(commitmentInputSchema.safeParse({ ...validDebt, startMonth: 13 }).success).toBe(false);
+  });
+
+  it('rejects an empty label and more than 600 instalments', () => {
+    expect(commitmentInputSchema.safeParse({ ...validDebt, label: '  ' }).success).toBe(false);
+    expect(commitmentInputSchema.safeParse({ ...validDebt, installmentsTotal: 601 }).success).toBe(
</code_context>
<issue_to_address>
**suggestion (testing):** Broaden schema tests to cover boundary values and key invariants (frequency, paymentDay, categoryId, max amount)

Existing tests already cover several key constraints. To better mirror the SQL CHECKs, consider adding targeted cases for:
- `paymentDay` at 1/31 (accepted) and 0/32 (rejected)
- `installmentsTotal` at 1 and 600 (accepted) and 0 (rejected)
- an invalid `frequency` value (e.g. `'weekly'`) to verify enum validation
- a non-UUID `categoryId` to verify UUID validation
Optionally, also test that a `totalAmount` just above `10_000_000` is rejected to confirm the upper bound.
</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/schemas/commitment.ts
Comment thread src/lib/schemas/commitment.ts
Comment thread src/lib/schemas/commitment.ts Outdated
Comment thread src/lib/domain/commitments/__tests__/schedule.test.ts
Comment thread src/lib/domain/commitments/__tests__/schedule.test.ts
Comment thread src/lib/domain/commitments/__tests__/schedule.test.ts
Comment thread src/lib/schemas/__tests__/commitment.test.ts Outdated
Comment thread src/lib/schemas/__tests__/commitment.test.ts
… coverage

Sourcery #233 review applied:
- commitmentUpdateSchema now mirrors commitments_installment_amount_required
  for the patch case (superRefine): raising the count above 1 in the SAME
  patch that clears the amount is rejected client-side instead of failing at
  the DB. A lone count bump still passes — the stored row keeps its amount.
- isDueInPeriod switched to direct arithmetic (offset % step, no array
  allocation): it runs per row in the cockpit budget pass. Equivalence with
  installmentPeriods() is locked by a test scanning 4 cadences x 96 months.
- Header comment no longer claims full Zod<->SQL parity: every DB rule is
  enforced here, but the 10M ceilings are UI-side guards with no SQL
  counterpart.
- Coverage: semiannual/annual periods + alignment, endPeriod on a non-monthly
  and a 60-instalment schedule, periodKey format, a stray PRE-start ledger
  tick, commitmentUpdateSchema partial rules, and boundary values (paymentDay
  1/31 vs 0/32, instalments 1/600 vs 0, unknown frequency, non-UUID category,
  amount ceiling).

Declined with proof: z.number({ error }) IS the Zod v4 API (repo runs 4.3.6,
charge.ts:15 uses it) — the suggested invalid_type_error is v3 and would
silently drop the i18n key. Verified by executing the schema.
41 commitment tests; full suite 1532 green.
@thierryvm

Copy link
Copy Markdown
Owner Author

Retours globaux traités (3b9ed47) :

  1. commitmentUpdateSchema / règle installment_amount_required — appliqué. superRefine rejette désormais un patch qui monte installmentsTotal au-dessus de 1 tout en vidant installmentAmount (ce que la DB aurait refusé). Volontairement non déclenché quand seul le compteur bouge : la ligne stockée porte déjà un montant valide, l'exiger dans chaque patch bloquerait des éditions légitimes. Les deux cas sont testés.

  2. isDueInPeriod allouait tout le tableau — appliqué : arithmétique directe (offset % step, zéro allocation). C'est effectivement un chemin chaud (appelé par ligne dans la passe budget du cockpit en PR-3). L'équivalence avec installmentPeriods() est verrouillée par un test qui balaie 4 cadences × 96 mois — la perf ne peut pas diverger du calendrier en silence.

Le seul point décliné est le z.number({ error }) : c'est l'API Zod v4 (repo en 4.3.6), détail + preuve d'exécution sur le thread concerné.

@thierryvm
thierryvm merged commit 4f85f0e into main Jul 20, 2026
9 of 10 checks passed
@thierryvm
thierryvm deleted the feat/commitments-foundations branch July 20, 2026 18:19
thierryvm added a commit that referenced this pull request Jul 22, 2026
… finite commitments

Pure domain (ADR-021 Task 1). An active multi-instalment commitment contributes
installmentAmount / cycleMonths while its window is open and it is not settled;
single payments and one-offs contribute nothing. Ordinal-arithmetic window
avoids the per-row schedule allocation (Sourcery #233). 13 tests.
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