From fd2f755166b87f10f3eeffb7980a531a68e179db Mon Sep 17 00:00:00 2001 From: Damien ALEXANDRE Date: Tue, 18 Aug 2026 15:53:22 +0200 Subject: [PATCH] feat: add Luhn validator constraint 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 --- Tests/app/src/Form/TestForm.php | 5 ++ cypress/integration/form_spec.js | 12 +++++ src/Resources/public/js/constraints/Luhn.js | 54 +++++++++++++++++++ .../public/js/constraints/Luhn.test.js | 36 +++++++++++++ src/Resources/public/js/constraints/index.js | 1 + 5 files changed, 108 insertions(+) create mode 100644 src/Resources/public/js/constraints/Luhn.js create mode 100644 src/Resources/public/js/constraints/Luhn.test.js diff --git a/Tests/app/src/Form/TestForm.php b/Tests/app/src/Form/TestForm.php index 909823da..e7cd5318 100755 --- a/Tests/app/src/Form/TestForm.php +++ b/Tests/app/src/Form/TestForm.php @@ -118,6 +118,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void new Constraints\Url(message: 'Please fill valid url'), ], ]) + ->add('luhn', TextType::class, [ + 'constraints' => [ + new Constraints\Luhn(message: 'Please fill valid card number'), + ], + ]) ->add('save', SubmitType::class) ; diff --git a/cypress/integration/form_spec.js b/cypress/integration/form_spec.js index 349641a8..10ef2f62 100755 --- a/cypress/integration/form_spec.js +++ b/cypress/integration/form_spec.js @@ -209,5 +209,17 @@ context('JsFormValidatorBundle', () => { getErrors(fieldId).should('have.length', 0); }); + it('test luhn', () => { + const fieldId = 'test_form_luhn'; + cy.get('#' + fieldId).type('1234567890').should('have.value', '1234567890'); + submitForm(); + getErrors(fieldId).should('have.length', 1); + cy.get('.form-error-test-form-luhn').contains('Please fill valid card number'); + + cy.get('#' + fieldId).clear().type('79927398713').should('have.value', '79927398713'); + submitForm(); + getErrors(fieldId).should('have.length', 0); + }); + }); }); diff --git a/src/Resources/public/js/constraints/Luhn.js b/src/Resources/public/js/constraints/Luhn.js new file mode 100644 index 00000000..6260dd3e --- /dev/null +++ b/src/Resources/public/js/constraints/Luhn.js @@ -0,0 +1,54 @@ +//noinspection JSUnusedGlobalSymbols +/** + * Validates that a value passes the Luhn algorithm (typically a credit card number) + * @constructor + * @author dalexandre@jolicode.com + */ +export default function SymfonyComponentValidatorConstraintsLuhn() { + this.message = ''; + + this.validate = function (value) { + var errors = []; + var f = FpJsFormValidator; + + if (f.isValueEmty(value)) { + return errors; + } + + // Work with strings only + var strValue = String(value); + + // Check if the value contains only digits + if (!/^\d+$/.test(strValue)) { + errors.push(this.message.replace('{{ value }}', FpJsBaseConstraint.formatValue(value))); + return errors; + } + + // Luhn algorithm + var checkSum = 0; + var length = strValue.length; + + for (var i = length - 1; i >= 0; i--) { + var digit = parseInt(strValue.charAt(i), 10); + + if ((i % 2) ^ (length % 2)) { + // Add every second digit starting from the last + checkSum += digit; + } else { + // Double every second digit and add it to the check sum + // For doubles greater than 9, sum the individual digits + var doubled = digit * 2; + checkSum += (doubled >= 10) ? (Math.floor(doubled / 10) + (doubled % 10)) : doubled; + } + } + + // Checksum must be non-zero and a multiple of 10 + if (0 === checkSum || 0 !== checkSum % 10) { + errors.push(this.message.replace('{{ value }}', FpJsBaseConstraint.formatValue(value))); + } + + return errors; + } +} + +window.SymfonyComponentValidatorConstraintsLuhn = SymfonyComponentValidatorConstraintsLuhn; diff --git a/src/Resources/public/js/constraints/Luhn.test.js b/src/Resources/public/js/constraints/Luhn.test.js new file mode 100644 index 00000000..56accea2 --- /dev/null +++ b/src/Resources/public/js/constraints/Luhn.test.js @@ -0,0 +1,36 @@ +import '../FpJsFormValidator'; +import SymfonyComponentValidatorConstraintsLuhn from './Luhn'; + +const constraintsLuhn = new SymfonyComponentValidatorConstraintsLuhn(); +constraintsLuhn.message = '{{ value }} is not a valid card number'; + +test.each([ + // Valid Luhn numbers + ['79927398713', []], // Valid Luhn checksum + ['4532015112830366', []], // Example credit card number + ['6011111111111117', []], // Example credit card number + ['378282246310005', []], // Example AMEX number + ['5105105105105100', []], // Example MasterCard number + ['4111111111111111', []], // Example Visa number + + // Invalid Luhn numbers + ['79927398712', ['"79927398712" is not a valid card number']], + ['1234567890123456', ['"1234567890123456" is not a valid card number']], + ['1111111111111111', ['"1111111111111111" is not a valid card number']], + ['0', ['"0" is not a valid card number']], // Single zero fails Luhn + ['00', ['"00" is not a valid card number']], // Multiple zeros fail Luhn + + // Non-numeric strings + ['abc', ['"abc" is not a valid card number']], + ['4532-0151-1283-0366', ['"4532-0151-1283-0366" is not a valid card number']], // Contains dashes + ['4532 0151 1283 0366', ['"4532 0151 1283 0366" is not a valid card number']], // Contains spaces + ['', []], // Empty string + [null, []], // null + [undefined, []], // undefined + [false, []], // false +])( + 'SymfonyComponentValidatorConstraintsLuhn', + (value, expected) => { + expect(constraintsLuhn.validate(value)).toStrictEqual(expected); + }, +); diff --git a/src/Resources/public/js/constraints/index.js b/src/Resources/public/js/constraints/index.js index 9d709f56..06ec167c 100755 --- a/src/Resources/public/js/constraints/index.js +++ b/src/Resources/public/js/constraints/index.js @@ -16,6 +16,7 @@ import './IsTrue.js'; import './Length.js'; import './LessThan.js'; import './LessThanOrEqual.js'; +import './Luhn.js'; import './NotBlank.js'; import './NotEqualTo.js'; import './NotIdenticalTo.js';