Skip to content

feat: add Luhn validator constraint - #212

Merged
66Ton99 merged 1 commit into
formapro:masterfrom
damienalexandre:addLuhn
Aug 19, 2026
Merged

66Ton99 merged 1 commit into
formapro:masterfrom
damienalexandre:addLuhn

Conversation

@damienalexandre

Copy link
Copy Markdown
Contributor

Add Luhn validator for client-side validation of credit card numbers and other values (like SIRET) that must pass the Luhn algorithm.

  • Add Luhn.js constraint following the existing pattern
  • Add comprehensive tests in Luhn.test.js
  • Update index.js to import the new constraint

The implementation follows Symfony's LuhnValidator.php logic:

  • Validates that the value contains only digits
  • Applies the Luhn algorithm to verify the checksum
  • Rejects values with checksum of 0 or not divisible by 10

@66Ton99

66Ton99 commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Review

Overall this is a solid PR, ready to merge after a few cosmetic fixes. The implementation is algorithmically equivalent to Symfony\Component\Validator\Constraints\LuhnValidator, the style matches the existing constraints, and the tests pass.

What I verified locally on the PR branch:

  • npx jest — 233 tests, 31 suites, all green (18 of them new).
  • Differential fuzzing against the PHP LuhnValidator logic (50,000 random digit strings of length 1–20) — 0 divergences.
  • Constraint registration: the PHP side (JsFormValidatorFactory::parseConstraints) emits get_class($item), and the JS side (FpJsFormValidator.js:749) does name.replace(/\\/g, '') → SymfonyComponentValidatorConstraintsLuhn. The global is exported correctly, so no PHP-side changes are needed.

What's done right

  • The (i % 2) ^ (length % 2) condition is a faithful port of vendor/symfony/validator/Constraints/LuhnValidator.php, including the parity nuance.
  • Doubling via Math.floor(doubled / 10) + (doubled % 10) is a correct equivalent of array_sum(str_split(...)).
  • 0 === checkSum || 0 !== checkSum % 10 matches Symfony exactly — the zero-checksum case is not forgotten.
  • /^\d+$/ is a correct equivalent of ctype_digit(): in JS, \d without the u flag is exactly ASCII 0-9, and $ without m does not allow a trailing \n (unlike PCRE).
  • f.isValueEmty(value) follows the same convention as Ip.js, Email.js, Url.js.
  • The test file matches the style of Ip.test.js / Email.test.js (test.each with a shared name).
  • window.<ClassName> = ... at the end of the file, like every other constraint.

Findings

1. Wrong position in index.js (worth fixing)

 import './Length.js';
+import './Luhn.js';
 import './LessThan.js';

The file is alphabetically sorted; Luhn should come after LessThanOrEqual.js, not between Length and LessThan. Harmless functionally, but it breaks the one obvious invariant of that file.

2. Comment contradicts the code — Luhn.js

// Checksum must be a multiple of 10 (and not zero, unless the input is "0")

The parenthetical is wrong: "0" produces checkSum === 0 and is rejected — as the PR's own test asserts (['0', ['"0" is not a valid card number']]). Suggest simply // Checksum must be non-zero and a multiple of 10, or porting Symfony's explanatory ASCII diagrams for the loop instead.

3. Trailing whitespace in Luhn.test.js — lines 15 and 22 are blank lines containing . There is no ESLint in the project so CI won't fail, but worth cleaning up.

4. No trailing newline in Luhn.js — although the other constraints (Ip.js etc.) don't have one either, so this is consistent rather than wrong. Ignore if you'd rather not touch it.

5. Missing @author tag in the docblock — every existing constraint carries @author dev.ymalcev@gmail.com. Minor, maintainer's call.

Observations (non-blocking)

  • {{ value }} will not actually fire. Symfony's default message is 'Invalid card number.', with no placeholder. So this.message.replace('{{ value }}', ...) is a no-op in production, and the tests use an artificial message. Not a bug — IsTrue.js uses the same pattern — but the tests don't reflect the real scenario.
  • Non-string types. Symfony throws UnexpectedValueException for non-string/non-Stringable values, whereas String(value) here silently coerces anything. For DOM values (always strings) this is a non-issue; the theoretical edge is a number > 2^53 becoming "1e+21" and failing the regex. Unreachable in practice.
  • No e2e coverage. The repo's convention is to add a field to Tests/app/src/Form/TestForm.php plus an assertion in cypress/integration/form_spec.js. That coverage is already partial though (Length, Regex, Type, Count, Callback, IdenticalTo, NotNull are missing too), so this is a nice-to-have, not a requirement.

Verdict

Approve with nits. Only items 1 (index.js ordering) and 2 (incorrect comment) really need fixing; the rest is optional. The validation logic has been verified and is correct.

@damienalexandre

Copy link
Copy Markdown
Contributor Author

Comments are addressed and one e2e test is added 👍

Do you think #211 and this #212 patches can land in 1.7 as well as 1.8 ? I'm using this bundle on an old Symfony version. Thanks! 🙏

@66Ton99

66Ton99 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Yes, please provide MRs to 1.7 branch

@66Ton99

66Ton99 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Re-checked after a3b53c40. All the findings are addressed, and thanks for adding the e2e test — that was only a nice-to-have.

Finding Status
1. index.js ordering Fixed — Luhn.js now sits after LessThanOrEqual.js
2. Incorrect checksum comment Fixed
3. Trailing whitespace in the test Fixed
4. Missing trailing newline Left alone — correct, the other constraints match
5. @author tag Added, but see below
e2e coverage Added: TestForm.php field + test luhn in form_spec.js

Verified locally on the PR branch (inside nix develop)

  • npx jest — 233 tests / 31 suites, green
  • composer test (PHPUnit 13.2, PHP 8.5) — 22 tests, 86 assertions, OK
  • npm run test:e2e (Cypress) — 17 passing, 0 failing, including the new ✓ test luhn (768ms)

I also checked the e2e values themselves: 1234567890 → checksum 43 (invalid, error expected), 79927398713 → checksum 70 (valid). The spec follows the neighbouring test url pattern exactly, and since the empty luhn field is filtered out by isValueEmty, none of the existing 16 tests are affected.

One leftover nit — @author

 * @author dev.ymalcev@gmail.com

This one is on me: my earlier wording was ambiguous. That address is the original bundle author's, and this is a new file you wrote, so the attribution is incorrect. Either use @author dalexandre@jolicode.com or just drop the tag — it isn't required. Cosmetic, not a merge blocker.

Everything else looks good to me.

Add Luhn validator for client-side validation of credit card numbers
and other values (like SIRET) that must pass the Luhn algorithm.

- Add Luhn.js constraint following the existing pattern
- Add comprehensive tests in Luhn.test.js
- Update index.js to import the new constraint

The implementation follows Symfony's LuhnValidator.php logic:
- Validates that the value contains only digits
- Applies the Luhn algorithm to verify the checksum
- Rejects values with checksum of 0 or not divisible by 10

Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
@damienalexandre

Copy link
Copy Markdown
Contributor Author

Last leftover fixed, and commits squashed.

@66Ton99
66Ton99 merged commit a9e4db3 into formapro:master Aug 19, 2026
5 of 6 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.

2 participants