Skip to content

feat(payment-terms): structured payment terms on billing entity and customer - #4179

Draft
sarkissianraffi wants to merge 8 commits into
mainfrom
feat/payment-terms-front
Draft

sarkissianraffi wants to merge 8 commits into
mainfrom
feat/payment-terms-front

Conversation

@sarkissianraffi

@sarkissianraffi sarkissianraffi commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Replaces the single net_payment_term integer with the structured payment_term object from spec #1, across the two levels the API supports today.

Tickets

Covers every front-end ticket of the [Core] Migration to new payment term milestone and, because all six types ship at once rather than the parity subset, the [Feature] Add more payment terms options milestone too.

Ticket Delivered as
BIL-552 — F1: payment term GraphQL types + constants src/core/constants/paymentTerm.ts. No SUPPORTED_TERM_TYPES gate: all six types ship together, so it has nothing to hide
BIL-553 — F2: due-date + label helpers src/core/utils/paymentTerm.ts (paymentTermDueDate, buildPaymentTermInput, resolvePaymentTerm) and src/hooks/usePaymentTerm.ts for the label copy, which needs translate
BIL-554 — F3: translation keys 39 new keys, 18 legacy ones removed, 9/9 locales consistent
BIL-555 — F4: rework the dialog EditPaymentTermDialog.tsx + paymentTerms/PaymentTermFormContent.tsx. Superset of its scope — all six types, not the due_on_receipt + net subset
BIL-556 — F5: both settings surfaces BillingEntityInvoiceSettings.tsx, CustomerSettings.tsx, DeleteCustomerPaymentTermDialog.tsx
BIL-557 — F6: Cypress harness cypress/e2e/10-resources/t100-payment-terms.cy.ts. Its data-test hooks were not already in place, so this adds them
BIL-558 — F7: quote payment term label QuotePaymentTermLine takes the structured term; resolution happens once in EditQuote.tsx
BIL-560 — F9: drop the last legacy reads No production reader of netPaymentTerm left. src/generated/graphql.tsx keeps its own, by design — BIL-526 keeps the alias as a permanent mirror
BIL-561 — F10: End of month end_of_month
BIL-562 — F11: Net days after end of month net_end_of_month. Its own flat enum value, not an anchor on a shared type as the ticket assumes
BIL-563 — F12: Days end of month days_end_of_month, likewise its own value, so it is not blocked by F11
BIL-564 — F13: Month following invoice day_of_month + dayOfMonth / monthOffset. Bounds mirror PaymentTerms::ValidateService exactly (1–31, 0–12)

BIL-559 (F8) is deliberately left open — the dunning sentence is deleted rather than reworded, which needs product sign-off. Details below.

Follow-ups this PR does not attempt, on the [Feature] Add payment term options to more billing items milestone: BIL-673 (subscription, unblocked), BIL-674 and BIL-675 (both still blocked on the API).

What is here

Billing entity → Invoice settings → Payment terms. The row renders the resolved term and the dialog edits it across all six types: due on receipt, net, end of month, net days after end of month, days end of month, and month following invoice.

Customer settings. Same dialog, plus inheritance. The row shows the effective term marked (inherit from billing entity) when the customer has none. The term type list leads with an inherit choice labelled with the value it would inherit, and picking it sends paymentTerm: null.

Shared pieces. PaymentTermFormContent is the one editor every level renders. core/utils/paymentTerm.ts mirrors the API's PaymentTerm#due_date_for, including the day-of-month clamp and roll-forward, which drives the live due-date preview in the dialog. The two end-of-month conventions take the same days and give different dates, so the preview is what tells them apart before saving.

Legacy alias readers. net_payment_term stays as a two-way alias, but the API returns it as NULL for the four types it cannot express. Every remaining reader of the integer would have rendered - or wrong copy the moment an account picked one of those types:

  • Quote documents now resolve customer → billing entity → due on receipt and format with the shared helper. Quotes carry no term of their own, per the product decision in the BE dive-in, so this is a resolved read-only display.
  • The dunning email drops its payment-terms sentence outright (spec Merge first version #1 §7). With terms set per customer and billing entity, one generic sentence cannot state one truthfully. The API branch already removed it from the mailer. Open question for product — see BIL-559; if the copy should come back instead, it is a small follow-up over formatPaymentTerm on the resolved term.

End-to-end. cypress/e2e/10-resources/t100-payment-terms.cy.ts drives every term type off a single TERM_CASES table: the billing entity sets each of the six and asserts its settings row, then the customer overrides its billing entity and deletes the override to inherit again. A new term option is a new row in that table, never a new spec file.

Review fixes

Four defects reported on this PR, plus one found alongside them:

Where Defect
usePaymentTerm.ts The due-date preview took the browser zone, where endOf('month') is 23:59:59.999, so every End-of-month term previewed as the 1st of the next month anywhere west of Greenwich. It is now anchored to a fixed example issuing date and computed as a calendar date in UTC. Two tests pin it, one of them under a negative-offset zone.
usePaymentTerm.ts The inherit option carried value: '', which ComboBoxField normalises to undefined and ComboBox renders as value={value || null} — it could never show as selected, and picking it visibly cleared the input. It now carries an inherit sentinel mapped back to null on submit.
EditPaymentTermDialog.tsx An absent term type passed validation, so a billing entity that had never had a term spun its Save button forever with no field error anywhere. termType is now required.
EditPaymentTermDialog.tsx Same root cause: the customer Add flow sent a no-op clear and toasted "successfully deleted". Inheriting a level that carries no term of its own now closes without a mutation.
PaymentTermFormContent.tsx The schema's four message: '' placeholders are real keys now — Zod v4 substitutes its own untranslated "Invalid input" for an empty message.

Codegen

The types were previously generated from a locally composed schema, gitignored as schema.local.graphql. lago-api#6118 is green and mergeable, so src/generated/graphql.tsx is now a plain pnpm codegen against api main merged with that branch, and both the composed file and its .gitignore entry are gone.

That refresh also picks up api-main schema drift that front main's generated file had not caught up with — hence the one-line permissionsConst snapshot update for the new ContractsUpdate permission.

Blocked on the API, in two places

This PR cannot leave draft until lago-api#6118 merges. .github/workflows/codegen.yml checks lago-api out at its default branch for a pull_request event, regenerates, and runs tsc. Against api main that regeneration drops PaymentTermTypeEnum and the typecheck fails on everything here. The job is gated on draft == false, which is why every check currently reports SKIPPED. Same for the e2e spec: cy.signup() hits a real API.

Two of the four billing-object surfaces still have no GraphQL. Since this PR was opened the API branch gained Subscription.paymentTerm with paymentTerm on both subscription inputs, and Invoice.paymentTerm (read-only, off snapshotted_payment_term). Still absent: Wallet.paymentTerm with its wallet inputs (BIL-522 / T16) and CreateInvoiceInput.paymentTerm (BIL-521 / T15), both unstarted.

Correcting an earlier version of this description: the subscription override is not one central edit. PaymentSettingsDrawer.tsx is mounted only by subscriptions/form/PaymentSettingsSection.tsx and subscriptions/details-v2/SubscriptionPaymentSection.tsx. One-off invoice, the three wallet top-up surfaces and quote invoicing mount PaymentSettingsSelector.tsx instead. Those are two components, so the [Feature] Add payment term options to more billing items milestone is two changes, tracked on BIL-673, BIL-674 and BIL-675 rather than here.

Quote-level overrides are out of scope by product decision (BE dive-in, "Quotes"). Quotes show the resolved term instead, which this PR delivers.

Verification

Command Result
pnpm codegen ✅ against the live local API, no composed schema
pnpm types ✅ zero errors
pnpm lint ✅ zero errors (54 pre-existing formik-deprecation warnings elsewhere)
pnpm translations:inspect ✅ no missing keys, no unused keys
pnpm translations:ensure-consistency ✅ 9/9 files
pnpm test ✅ 1028 suites, 12946 tests, 136 snapshots
TZ=America/New_York pnpm test ✅ pins the preview timezone regression
pnpm test:e2e ⏳ not run — needs an API serving payment_term; blocked with the codegen job above

TZ=Europe/Paris fails one unrelated test, DatePicker › GIVEN a year with fewer than four digits: for year 26 AD Paris carries a +00:09 LMT offset. It predates this branch (from #4220) and passes in CI's UTC.

Also noticed while typechecking the specs, unrelated and left alone: cypress/e2e/t40-wallet-top-up.cy.ts imports DESKTOP_ACTIONS_BLOCK_TEST_ID, which mainHeaderTestIds.ts no longer exports. Broken on main too.

Fixes BIL-552
Fixes BIL-553
Fixes BIL-554
Fixes BIL-555
Fixes BIL-556
Fixes BIL-557
Fixes BIL-558
Fixes BIL-560
Fixes BIL-561
Fixes BIL-562
Fixes BIL-563
Fixes BIL-564

Ref BIL-559
Ref BIL-673
Ref BIL-674
Ref BIL-675

@AllanMichay AllanMichay 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.

Reviewed the full diff. Types are clean, the deleted translation keys have no remaining production references, and the date-math unit tests are solid. Four comments inline, plus one process note: the generated types were produced against a locally-derived schema (schema.local.graphql, per the new .gitignore entry) that includes the unmerged payment-terms API branch. UpdateCustomerInput.paymentTerm / UpdateBillingEntityInput.paymentTerm aren't confirmed live on staging. If this merges first, the unknown-field validation error kills the whole operation rather than just the row, so GetCustomerSettings, getBillingEntitySettings and getQuote all blank out. Worth gating the merge on the API side landing.

Comment thread src/hooks/usePaymentTerm.ts
Comment thread src/hooks/usePaymentTerm.ts Outdated
Comment thread src/components/settings/invoices/EditPaymentTermDialog.tsx Outdated
Comment thread src/components/settings/invoices/EditPaymentTermDialog.tsx Outdated
Raffi and others added 8 commits September 10, 2026 14:09
The term type list on a customer now leads with an inherit choice labelled with
the value the billing entity would give, and picking it clears the override the
same way the delete dialog does. Until now the shared form already accepted an
`inheritedFrom` option but nothing passed it, so clearing an override was only
reachable through a separate dialog.

The billing entity is the last level of the chain, so it gets no such choice.

Also aligns the six term-type descriptions with the copy in the design, and
points the customer settings query at the dialog's fragment instead of a
hand-written copy of the same selection.
…ias was read

`net_payment_term` survives as a two-way alias, but the API returns it as NULL
for the four term types it cannot express. Every remaining reader of the integer
would therefore render "-" or the wrong copy as soon as an account picks one of
those types.

The quote document now resolves the term through customer -> billing entity ->
due on receipt and formats it with the shared helper, so all six types read
correctly. Quotes carry no term of their own, per the product decision in the
backend dive-in, so this stays a resolved read-only display.

The dunning email drops its payment-terms sentence outright (spec #1 §7): with
terms set per customer and billing entity, one generic sentence can no longer
state one truthfully. The API branch already removed it from the mailer.

Removes the legacy copy left unused by this migration, in base and every locale.
…aphQL

The committed types were derived from a locally composed schema, which the
branch gitignored as schema.local.graphql. lago-api#6118 is now mergeable, so
the local API can serve api main plus the payment-terms GraphQL directly and
codegen no longer needs the composed file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ype required

Four defects in the dialog and its preview, all reported on the pull request.

The inherit option carried an empty value, which `ComboBoxField` normalises to
`undefined` and `ComboBox` renders as `value={value || null}`: it could never
show as selected, and picking it visibly cleared the input. It now carries an
`inherit` sentinel, mapped back to a `null` payment term on submit.

An absent term type passed validation, so a billing entity that had never had a
term spun its Save button forever with no field error. `termType` is now
required, which also stops the customer Add flow from sending a no-op clear and
reporting "successfully deleted": inheriting a level that carries no term of its
own closes without a mutation.

The due-date preview took the browser zone, where `endOf('month')` is
23:59:59.999, so every End-of-month term previewed as the 1st of the next month
anywhere west of Greenwich. The preview is now anchored to a fixed example
issuing date and computed as a calendar date in UTC.

The schema's `message: ''` placeholders are replaced with real keys — Zod v4
substitutes its own untranslated "Invalid input" for an empty message.

Also drops the last `netPaymentTerm` fixtures from the quote tests, which the
structured term replaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One spec drives every term type off a TERM_CASES table, so a new option is a new
row rather than a new spec file: the billing entity sets each of the six types
and asserts the settings row, and the customer overrides its billing entity then
deletes the override to inherit again.

Neither settings row carried a test hook, so `SettingsListItem` takes an optional
`dataTest` and the payment-term rows and their actions expose ids from a shared
`paymentTerms/dataTestConstants`. The billing-entity page passes each row's own
id through, giving every invoice-setting row a hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AllanMichay
AllanMichay force-pushed the feat/payment-terms-front branch from d3a2074 to 4864411 Compare September 10, 2026 12:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants