Skip to content

feat: give each credit note a language (4.3.0) - #26

Merged
ThomasDaSilva merged 12 commits into
thelia-modules:mainfrom
ThomasDaSilva:feat/credit-note-language
Oct 19, 2026
Merged

ThomasDaSilva merged 12 commits into
thelia-modules:mainfrom
ThomasDaSilva:feat/credit-note-language

Conversation

@ThomasDaSilva

@ThomasDaSilva ThomasDaSilva commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

A credit note has no language of its own: CreditNoteDocumentLocale prints it in the language its customer has at
print time. The back office cannot choose it when it writes the credit note, and a customer who changes language
changes the language of every credit note they already received.

This PR gives each credit note a language, chosen in the back-office modal. On the way it fixes four defects of the
Twig back office: the side menu entry never showed, a save landed on the cart tab of the order, success-url was
followed without any check, and badge colors reached style attributes unchecked.

Change

Language of a credit note

  • New column credit_note.lang_id (schema.xml, thelia.sql, Config/Update/sql/4.3.0.sql), foreign key to lang
    with ON DELETE SET NULL, indexed.
  • CreditNoteLanguage::defaultFor(): the language of the order, of the customer for a credit note without order, the
    default language of the shop otherwise. CreditNoteListener sets it before the insert when the writer gave none, so
    a credit note written outside the controller (another module, the API, a command) gets one too.
  • The modal has a "Language" select: the active languages, plus the one the credit note already has if the shop
    turned it off since. On a new credit note the default language comes first, without value, and is not repeated
    below: it follows the order or customer picked in the modal until the administrator chooses another one.
  • The language stays editable once the credit note is numbered (performLang() runs outside the frozen block, like
    the comment). Number, lines and amounts stay as they are; only the printed document follows the language. A lang_id the
    credit note cannot have (unknown, or inactive and not its own) is ignored.
  • CreditNoteDocumentLocale::forCreditNote() reads the language of the credit note first, still within
    document_locales. A credit note without a language, written before 4.3.0, keeps the language of its customer.
  • The 4.3.0 update fills the existing credit notes with the language they were printed in until now: the one of their
    customer, of their order when the customer has none, only when that language exists.

Twig back office

  • Side menu: the script inserted the "Credit Notes" section after #orders_menu, an entry of the Smarty back office
    that default-twig does not have, so the section never showed. It now follows [data-testid="bo-nav-order"] with
    the markup of the side menu (default-twig has no hook between sections).
  • Return after a save: the modal script sent the page to go back to as success-url in the request body, but the
    modal only read it from the query string, so a save always landed on the order page, where default-twig ignores
    ?tab=credit-note and opens the cart tab. The modal now keeps that page with the tab holding the trigger as
    fragment (#credit-note on an order), the script reopens that tab on load, and create, update and delete share one
    redirectAfterSave().
  • success-url was passed to RedirectResponse as is. Only a path of the shop in printable ASCII is followed now
    (#^/(?!/)[\x21-\x5B\x5D-\x7E]*$#D): an absolute or protocol-relative address, a backslash, or a tab or line break
    that the browser drops from an address (/<tab>/host becomes //host) sends back to the credit notes of the order.
  • A credit note created or saved without an invoice address (no customer picked, or "Other" left empty) saved an
    empty address, which a database in strict mode refuses with an error 500 (Field 'firstname' doesn't have a default value). Create and update now stop before any write and go back to the page with a message. The module's messages
    use the danger type the Twig back office styles (error rendered as alert-error, unstyled).
  • Badge colors of statuses and types go into style attributes, a context HTML escaping does not protect.
    BadgeColor::css() keeps a hexadecimal color and falls back to grey otherwise. The colors the module installs are
    all hexadecimal, so nothing changes on screen.

Update scripts

  • update() ran every script of Config/Update/sql/ above the installed version, compared by file name, whatever
    the target. It now runs the scripts above the installed version up to the target one, compared by version (the
    file name without .sql). CreditNoteOrderCeilingTest simulates a 3.0.2 to 4.1.0 update on a shop installed at
    4.3.0: without the bound it replays 4.3.0.sql and fails on the existing column.

Module version 4.3.0, CHANGELOG entries under Unreleased.

Upgrading

module:refresh runs 4.3.0.sql. Its ALTER TABLE statements are not guarded: if the script stops after the first
one, a second run fails on the existing column. Back up credit_note before the update and restore it if the update
fails. Afterwards, SELECT COUNT(*) FROM credit_note WHERE lang_id IS NULL returns 0 on a shop whose customers all
have a language.

Going back to 4.2 leaves the column in place; the previous version ignores it.

Tests

Each new test was seen failing with the code it covers broken, then passing.

  • CreditNoteLanguageTest: the language of the order, of the customer without order, the default language of the
    shop; a language given by the writer is kept; active languages selectable, an inactive one refused unless it is
    the language of the credit note.
  • CreditNoteLanguageRouteTest: the language of a numbered credit note changes through _update with its number,
    invoice number, total and lines unchanged; an inactive or unknown language is ignored.
  • CreditNoteDocumentLocaleTest: the language of the credit note wins over the one of its customer, the document is
    rendered in it whatever the order and the customer; the existing cases now describe a credit note without a
    language. The class sets document_locales and the default PDF template inside its transaction, so it no longer
    depends on the configuration of the shop it runs in.
  • CreditNoteSaveRedirectTest: back to the page and tab of the modal; no address, an absolute one, a
    protocol-relative one, a backslash, a tab, a line break, a space and a trailing line break all send back to the
    order.
  • BadgeColorTest: short, long, upper case and alpha hexadecimal colors kept; a name, an empty string, an injected
    declaration and a trailing line break fall back to grey.
  • CreditNoteInvoiceAddressTest: a credit note created without invoice address is refused with a message, and
    neither the credit note nor an empty address is written (error 500 before the fix).
  • CreditNoteLanguageRouteTest::theModalOfANewCreditNoteOffersEachLanguageOnce: the default language comes first,
    without value, and no language is offered twice.

Unit suite: 32 tests. Integration tests above: 24 tests, run against a Thelia 3.3 shop with the module installed.

Checked in a browser on default-twig: the menu and its status filters; a credit note created from an order in
another language gets that language and the save lands back on the "Credit note" tab; edited from the list and from
the customer page, each save lands back on the page it came from; deleted from the order tab, back on that tab; the
document of a numbered credit note switches language with it.

Not in this PR

Found while reading the controller, unchanged here, worth a separate PR:

  • createAction, viewAction, updateAction, deleteAction, ajaxModalCreateAction and the customer and order
    searches call no checkAuth(): any administrator session can read, write and delete credit notes whatever its
    profile.
  • The create form disables CSRF protection and the delete form has no token.
  • _update on an unknown {id} creates a new credit note; _delete on an unknown one ends in an error 500.

A credit note gets the language of its order, of its customer without an
order, or the default language of the shop when it is written, and the
back-office modal lets the administrator change it, numbered or not. The
document is printed in that language, still within document_locales. The
update fills the existing credit notes with the language of their customer,
the one they were printed in until now.
Its script inserted the section after #orders_menu, an entry of the Smarty back-office that default-twig does not have, so the menu never showed. It now follows the Orders section, with the markup of the side menu.
The modal script sent that page as success-url in the request body, but the modal only read it from the query string, so a save always landed on the cart tab of the order. The page now carries the tab holding the trigger as fragment, the script reopens that tab, and only a path of the shop is followed: an absolute or protocol-relative success-url sends back to the order.
Browsers drop tabs and line breaks from an address, so "/<tab>/host" passed the filter and became the protocol-relative "//host" of another site; a trailing line break passed the "$" anchor. The success-url is now a path in printable ASCII, without backslash, anchored with the D modifier.
HTML escaping does not protect a style attribute: a status or type color that is not a hexadecimal color now falls back to grey (BadgeColor), in the side menu, the lists, the order lines and the modal.
…es only

The update runs with the foreign key checks off: a customer or order pointing to a deleted language would have left a dangling lang_id.
Also say why a credit note without a language still prints in the language of its customer.
@ThomasDaSilva
ThomasDaSilva merged commit af98146 into thelia-modules:main Oct 19, 2026
5 checks passed
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.

1 participant