From fc79dc72a767225a1dc44e0b94ffd2076bc62e29 Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Sat, 31 Jan 2026 01:50:58 +0100 Subject: [PATCH 1/2] Add CreditcardFormatter with automatic card type detection The new CreditcardFormatter automatically detects major credit card types (Visa, MasterCard, Amex, Discover, JCB) based on card prefix and length, applying the appropriate formatting pattern. This formatter is essential for payment processing applications that need to display credit card numbers in a consistent, readable format while supporting different card types with their specific formatting requirements (e.g., Amex uses 4-6-5 format while others use 4-4-4-4). Input is automatically cleaned by removing non-digit characters, making it flexible for real-world usage where cards may have spaces, dashes, or other separators. Includes comprehensive tests covering all major card types, invalid cards, custom patterns, input cleaning, and edge cases. Assisted-by: OpenCode (GLM-4.7) --- README.md | 1 + docs/CreditCardFormatter.md | 114 +++++++++++++ src/CreditCardFormatter.php | 66 ++++++++ src/Mixin/Builder.php | 2 + src/Mixin/Chain.php | 2 + tests/Unit/CreditCardFormatterTest.php | 212 +++++++++++++++++++++++++ 6 files changed, 397 insertions(+) create mode 100644 docs/CreditCardFormatter.md create mode 100644 src/CreditCardFormatter.php create mode 100644 tests/Unit/CreditCardFormatterTest.php diff --git a/README.md b/README.md index 9451966..02ad21a 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ See the [PlaceholderFormatter documentation](docs/PlaceholderFormatter.md) and [ | Formatter | Description | | ---------------------------------------------------------- | ---------------------------------------------------------------- | | [AreaFormatter](docs/AreaFormatter.md) | Metric area promotion (mm², cm², m², a, ha, km²) | +| [CreditCardFormatter](docs/CreditCardFormatter.md) | Credit card number formatting with auto-detection | | [DateFormatter](docs/DateFormatter.md) | Date and time formatting with flexible parsing | | [ImperialAreaFormatter](docs/ImperialAreaFormatter.md) | Imperial area promotion (in², ft², yd², ac, mi²) | | [ImperialLengthFormatter](docs/ImperialLengthFormatter.md) | Imperial length promotion (in, ft, yd, mi) | diff --git a/docs/CreditCardFormatter.md b/docs/CreditCardFormatter.md new file mode 100644 index 0000000..6ffde55 --- /dev/null +++ b/docs/CreditCardFormatter.md @@ -0,0 +1,114 @@ + + +# CreditCardFormatter + +The `CreditCardFormatter` formats credit card numbers with automatic card type detection. It supports major card networks including Visa, MasterCard, American Express, Discover, JCB, Diners Club, UnionPay, and RuPay. + +## Usage + +### Basic Usage with Auto-Detection + +```php +use Respect\StringFormatter\CreditCardFormatter; + +$formatter = new CreditCardFormatter(); + +echo $formatter->format('4123456789012345'); +// Outputs: "4123 4567 8901 2345" (Visa detected) + +echo $formatter->format('371234567890123'); +// Outputs: "3712 345678 90123" (Amex, 4-6-5 format) + +echo $formatter->format('5112345678901234'); +// Outputs: "5112 3456 7890 1234" (MasterCard detected) + +echo $formatter->format('36123456789012'); +// Outputs: "3612 345678 9012" (Diners Club, 4-6-4 format) + +echo $formatter->format('4123456789012345678'); +// Outputs: "4123 4567 8901 2345 678" (Visa 19-digit) +``` + +### Input Cleaning + +The formatter automatically removes non-digit characters from the input: + +```php +use Respect\StringFormatter\CreditCardFormatter; + +$formatter = new CreditCardFormatter(); + +echo $formatter->format('4123-4567-8901-2345'); +// Outputs: "4123 4567 8901 2345" + +echo $formatter->format('4123 4567 8901 2345'); +// Outputs: "4123 4567 8901 2345" + +echo $formatter->format('4123.4567.8901.2345'); +// Outputs: "4123 4567 8901 2345" +``` + +## API + +### `format` + +- `format(string $input): string` + +Formats the input credit card number. + +**Parameters:** + +- `$input`: The credit card number (can include spaces, dashes, dots, etc.) + +**Returns:** The formatted credit card number + +## Auto-Detection + +The formatter automatically detects card type based on prefix and length: + +| Card Type | Prefix Ranges | Length | Format | +| -------------------- | -------------------- | ---------- | --------------------- | +| **American Express** | 34, 37 | 15 | `#### ###### #####` | +| **Diners Club** | 300-305, 309, 36, 38 | 14 | `#### ###### ####` | +| **Diners Club** | 36 | 16 | `#### #### #### ####` | +| **Visa** | 4 | 13, 16 | `#### #### #### ####` | +| **Visa** | 4 | 19 | `#### #### #### #### ###` | +| **MasterCard** | 51-55, 2221-2720 | 16 | `#### #### #### ####` | +| **Discover** | 6011, 644-649, 65 | 16 | `#### #### #### ####` | +| **Discover** | 6011, 644-649, 65 | 19 | `#### #### #### #### ###` | +| **JCB** | 3528-3589 | 16 | `#### #### #### ####` | +| **JCB** | 3528-3589 | 19 | `#### #### #### #### ###` | +| **UnionPay** | 62 | 16 | `#### #### #### ####` | +| **UnionPay** | 62 | 19 | `#### #### #### #### ###` | +| **RuPay** | 60, 65, 81, 82, 508 | 16 | `#### #### #### ####` | + +Cards with more than 16 digits automatically use the 19-digit pattern: `#### #### #### #### ###` + +## Examples + +| Input | Output | Card Type | +| --------------------- | ------------------------- | ------------ | +| `4123456789012345` | `4123 4567 8901 2345` | Visa | +| `4123456789012345678` | `4123 4567 8901 2345 678` | Visa (19) | +| `5112345678901234` | `5112 3456 7890 1234` | MasterCard | +| `341234567890123` | `3412 345678 90123` | Amex | +| `371234567890123` | `3712 345678 90123` | Amex | +| `6011000990139424` | `6011 0009 9013 9424` | Discover | +| `3528000012345678` | `3528 0000 1234 5678` | JCB | +| `36123456789012` | `3612 345678 9012` | Diners Club | +| `6212345678901234` | `6212 3456 7890 1234` | UnionPay | +| `8112345678901234` | `8112 3456 7890 1234` | RuPay | +| `1234567890123456` | `1234 5678 9012 3456` | Unknown | +| `4123-4567-8901-2345` | `4123 4567 8901 2345` | Visa (clean) | + +## Notes + +- Non-digit characters are automatically removed from the input +- Card type detection is based on card prefix and length (not Luhn validation) +- If card type cannot be determined, uses the default 4-4-4-4 pattern +- Uses `PatternFormatter` internally for formatting +- For custom formatting patterns, use `PatternFormatter` directly diff --git a/src/CreditCardFormatter.php b/src/CreditCardFormatter.php new file mode 100644 index 0000000..174d8b4 --- /dev/null +++ b/src/CreditCardFormatter.php @@ -0,0 +1,66 @@ + + */ + +declare(strict_types=1); + +namespace Respect\StringFormatter; + +use function mb_strlen; +use function mb_substr; +use function preg_replace; + +final readonly class CreditCardFormatter implements Formatter +{ + private const string DEFAULT_16 = '#### #### #### ####'; + private const string DEFAULT_19 = '#### #### #### #### ###'; + private const string AMEX = '#### ###### #####'; + private const string DINERS_14 = '#### ###### ####'; + + public function format(string $input): string + { + $cleaned = $this->cleanInput($input); + $pattern = $this->detectPattern($cleaned); + + $formatter = new PatternFormatter($pattern); + + return $formatter->format($cleaned); + } + + public function cleanInput(string $input): string + { + return preg_replace('/[^0-9]/', '', $input) ?? ''; + } + + public function detectPattern(string $input): string + { + $length = mb_strlen($input); + $firstTwo = mb_substr($input, 0, 2); + $firstThree = mb_substr($input, 0, 3); + + // American Express: starts with 34 or 37 (15 digits, 4-6-5 format) + if ($firstTwo === '34' || $firstTwo === '37') { + return self::AMEX; + } + + // Diners Club International: 14 digits, starts with 300-305, 309, 36, 38 + if ($length === 14) { + $prefix3 = (int) $firstThree; + if (($prefix3 >= 300 && $prefix3 <= 305) || $prefix3 === 309 || $firstTwo === '36' || $firstTwo === '38') { + return self::DINERS_14; + } + } + + // 19-digit cards (some Visa, Discover, JCB, UnionPay) + if ($length > 16) { + return self::DEFAULT_19; + } + + // Default 4-4-4-4: Visa, Mastercard, Discover, JCB, UnionPay, RuPay, etc. + return self::DEFAULT_16; + } +} diff --git a/src/Mixin/Builder.php b/src/Mixin/Builder.php index 539fd0d..c02bd68 100644 --- a/src/Mixin/Builder.php +++ b/src/Mixin/Builder.php @@ -18,6 +18,8 @@ interface Builder { public static function area(string $unit): FormatterBuilder; + public static function creditCard(): FormatterBuilder; + public static function imperialArea(string $unit): FormatterBuilder; public static function imperialLength(string $unit): FormatterBuilder; diff --git a/src/Mixin/Chain.php b/src/Mixin/Chain.php index 780ba0b..1a1b2c2 100644 --- a/src/Mixin/Chain.php +++ b/src/Mixin/Chain.php @@ -18,6 +18,8 @@ interface Chain extends Formatter { public function area(string $unit): FormatterBuilder; + public function creditCard(string|null $pattern = null): FormatterBuilder; + public function imperialArea(string $unit): FormatterBuilder; public function imperialLength(string $unit): FormatterBuilder; diff --git a/tests/Unit/CreditCardFormatterTest.php b/tests/Unit/CreditCardFormatterTest.php new file mode 100644 index 0000000..92701de --- /dev/null +++ b/tests/Unit/CreditCardFormatterTest.php @@ -0,0 +1,212 @@ + + */ + +declare(strict_types=1); + +namespace Respect\StringFormatter\Test\Unit; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Respect\StringFormatter\CreditCardFormatter; + +#[CoversClass(CreditCardFormatter::class)] +final class CreditCardFormatterTest extends TestCase +{ + #[Test] + #[DataProvider('providerForVisaCards')] + #[DataProvider('providerForMasterCard')] + #[DataProvider('providerForAmexCards')] + #[DataProvider('providerForDiscoverCards')] + #[DataProvider('providerForJcbCards')] + #[DataProvider('providerForDinersClubCards')] + #[DataProvider('providerForUnionPayCards')] + #[DataProvider('providerForRuPayCards')] + #[DataProvider('providerForUnrecognizedCards')] + #[DataProvider('providerForInputCleaning')] + #[DataProvider('providerForEdgeCases')] + public function itShouldFormatCreditCards(string $input, string $expected): void + { + $formatter = new CreditCardFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + public function itShouldHandleEmptyString(): void + { + $formatter = new CreditCardFormatter(); + + $actual = $formatter->format(''); + + self::assertSame(' ', $actual); + } + + #[Test] + #[DataProvider('providerForVisaDifferentLengths')] + public function itShouldHandleVisaDifferentLengths(string $input, string $expected): void + { + $formatter = new CreditCardFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + /** @return array */ + public static function providerForVisaCards(): array + { + return [ + 'visa 16 digits' => ['4123456789012345', '4123 4567 8901 2345'], + 'visa 16 digits with dashes' => ['4123-4567-8901-2345', '4123 4567 8901 2345'], + 'visa 16 digits with spaces' => ['4123 4567 8901 2345', '4123 4567 8901 2345'], + 'visa another' => ['4532015112830366', '4532 0151 1283 0366'], + 'visa starts with 4' => ['4916409457367128', '4916 4094 5736 7128'], + ]; + } + + /** @return array */ + public static function providerForMasterCard(): array + { + return [ + 'mastercard 51' => ['5112345678901234', '5112 3456 7890 1234'], + 'mastercard 55' => ['5512345678901234', '5512 3456 7890 1234'], + 'mastercard 52' => ['5212345678901234', '5212 3456 7890 1234'], + 'mastercard 53' => ['5312345678901234', '5312 3456 7890 1234'], + 'mastercard 54' => ['5412345678901234', '5412 3456 7890 1234'], + 'mastercard 2221' => ['2221123456789012', '2221 1234 5678 9012'], + 'mastercard 2720' => ['2720123456789012', '2720 1234 5678 9012'], + ]; + } + + /** @return array */ + public static function providerForAmexCards(): array + { + return [ + 'amex 34' => ['341234567890123', '3412 345678 90123'], + 'amex 37' => ['371234567890123', '3712 345678 90123'], + 'amex another 34' => ['347856241795641', '3478 562417 95641'], + 'amex another 37' => ['378282246310005', '3782 822463 10005'], + ]; + } + + /** @return array */ + public static function providerForDiscoverCards(): array + { + return [ + 'discover 6011' => ['6011000990139424', '6011 0009 9013 9424'], + 'discover 65' => ['6512345678901234', '6512 3456 7890 1234'], + 'discover 644' => ['6441234567890123', '6441 2345 6789 0123'], + 'discover 645' => ['6451234567890123', '6451 2345 6789 0123'], + 'discover 646' => ['6461234567890123', '6461 2345 6789 0123'], + 'discover 647' => ['6471234567890123', '6471 2345 6789 0123'], + 'discover 648' => ['6481234567890123', '6481 2345 6789 0123'], + 'discover 649' => ['6491234567890123', '6491 2345 6789 0123'], + 'discover 19 digits' => ['6011000990139424123', '6011 0009 9013 9424 123'], + ]; + } + + /** @return array */ + public static function providerForJcbCards(): array + { + return [ + 'jcb 3528' => ['3528000012345678', '3528 0000 1234 5678'], + 'jcb 3536' => ['3536000012345678', '3536 0000 1234 5678'], + 'jcb 3558' => ['3558000012345678', '3558 0000 1234 5678'], + 'jcb 3589' => ['3589000012345678', '3589 0000 1234 5678'], + 'jcb 19 digits' => ['3528000012345678901', '3528 0000 1234 5678 901'], + ]; + } + + /** @return array */ + public static function providerForDinersClubCards(): array + { + return [ + 'diners 300' => ['30012345678901', '3001 234567 8901'], + 'diners 301' => ['30112345678901', '3011 234567 8901'], + 'diners 305' => ['30512345678901', '3051 234567 8901'], + 'diners 309' => ['30912345678901', '3091 234567 8901'], + 'diners 36' => ['36123456789012', '3612 345678 9012'], + 'diners 38' => ['38123456789012', '3812 345678 9012'], + 'diners 16 digits (mastercard co-brand)' => ['3612345678901234', '3612 3456 7890 1234'], + ]; + } + + /** @return array */ + public static function providerForUnionPayCards(): array + { + return [ + 'unionpay 62 16 digits' => ['6212345678901234', '6212 3456 7890 1234'], + 'unionpay 62 19 digits' => ['6212345678901234567', '6212 3456 7890 1234 567'], + ]; + } + + /** @return array */ + public static function providerForRuPayCards(): array + { + return [ + 'rupay 60' => ['6012345678901234', '6012 3456 7890 1234'], + 'rupay 65' => ['6512345678901234', '6512 3456 7890 1234'], + 'rupay 81' => ['8112345678901234', '8112 3456 7890 1234'], + 'rupay 82' => ['8212345678901234', '8212 3456 7890 1234'], + 'rupay 508' => ['5081234567890123', '5081 2345 6789 0123'], + ]; + } + + /** @return array */ + public static function providerForUnrecognizedCards(): array + { + return [ + 'unknown 16 digit' => ['1234567890123456', '1234 5678 9012 3456'], + 'unknown starts with 1' => ['1111222233334444', '1111 2222 3333 4444'], + 'unknown starts with 2' => ['2111222233334444', '2111 2222 3333 4444'], + 'unknown starts with 3' => ['3111222233334444', '3111 2222 3333 4444'], + ]; + } + + /** @return array */ + public static function providerForInputCleaning(): array + { + return [ + 'with spaces' => ['4123 4567 8901 2345', '4123 4567 8901 2345'], + 'with dashes' => ['4123-4567-8901-2345', '4123 4567 8901 2345'], + 'with dots' => ['4123.4567.8901.2345', '4123 4567 8901 2345'], + 'mixed separators' => ['4123-4567.8901 2345', '4123 4567 8901 2345'], + 'with letters' => ['4123A4567B8901C2345', '4123 4567 8901 2345'], + 'with special chars' => ['4123!4567@8901#2345', '4123 4567 8901 2345'], + ]; + } + + /** @return array */ + public static function providerForEdgeCases(): array + { + return [ + 'empty string' => ['', ' '], + 'only spaces' => [' ', ' '], + 'only dashes' => ['----', ' '], + 'only dots' => ['....', ' '], + 'only letters' => ['abcd', ' '], + 'short number' => ['123', '123 '], + 'mixed content' => ['abcd4123456789012345abcd', '4123 4567 8901 2345'], + 'numbers longer than pattern' => ['41234567890123456789', '4123 4567 8901 2345 678'], + ]; + } + + /** @return array */ + public static function providerForVisaDifferentLengths(): array + { + return [ + 'visa 13 digits' => ['4123456789012', '4123 4567 8901 2'], + 'visa 16 digits' => ['4123456789012345', '4123 4567 8901 2345'], + 'visa 19 digits' => ['4123456789012345678', '4123 4567 8901 2345 678'], + ]; + } +} From 315169d1333f4ccca043d6e872083edc940c7321 Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Sat, 31 Jan 2026 02:33:52 +0100 Subject: [PATCH 2/2] Add SecureCreditCardFormatter with masked credit card display Composes CreditCardFormatter and MaskFormatter to display credit card numbers with only the first and last digit groups visible. Automatically detects card type and applies the appropriate mask range for each format. Assisted-By: Claude Code (Claude Opus 4.6) --- README.md | 31 +-- docs/SecureCreditCardFormatter.md | 123 +++++++++++ src/Mixin/Builder.php | 2 + src/Mixin/Chain.php | 4 +- src/SecureCreditCardFormatter.php | 65 ++++++ tests/Unit/SecureCreditCardFormatterTest.php | 205 +++++++++++++++++++ 6 files changed, 414 insertions(+), 16 deletions(-) create mode 100644 docs/SecureCreditCardFormatter.md create mode 100644 src/SecureCreditCardFormatter.php create mode 100644 tests/Unit/SecureCreditCardFormatterTest.php diff --git a/README.md b/README.md index 02ad21a..3568c6c 100644 --- a/README.md +++ b/README.md @@ -56,21 +56,22 @@ See the [PlaceholderFormatter documentation](docs/PlaceholderFormatter.md) and [ ## Formatters -| Formatter | Description | -| ---------------------------------------------------------- | ---------------------------------------------------------------- | -| [AreaFormatter](docs/AreaFormatter.md) | Metric area promotion (mm², cm², m², a, ha, km²) | -| [CreditCardFormatter](docs/CreditCardFormatter.md) | Credit card number formatting with auto-detection | -| [DateFormatter](docs/DateFormatter.md) | Date and time formatting with flexible parsing | -| [ImperialAreaFormatter](docs/ImperialAreaFormatter.md) | Imperial area promotion (in², ft², yd², ac, mi²) | -| [ImperialLengthFormatter](docs/ImperialLengthFormatter.md) | Imperial length promotion (in, ft, yd, mi) | -| [ImperialMassFormatter](docs/ImperialMassFormatter.md) | Imperial mass promotion (oz, lb, st, ton) | -| [MaskFormatter](docs/MaskFormatter.md) | Range-based string masking with Unicode support | -| [MassFormatter](docs/MassFormatter.md) | Metric mass promotion (mg, g, kg, t) | -| [MetricFormatter](docs/MetricFormatter.md) | Metric length promotion (mm, cm, m, km) | -| [NumberFormatter](docs/NumberFormatter.md) | Number formatting with thousands and decimal separators | -| [PatternFormatter](docs/PatternFormatter.md) | Pattern-based string filtering with placeholders | -| [PlaceholderFormatter](docs/PlaceholderFormatter.md) | Template interpolation with placeholder replacement | -| [TimeFormatter](docs/TimeFormatter.md) | Time promotion (mil, c, dec, y, mo, w, d, h, min, s, ms, us, ns) | +| Formatter | Description | +| -------------------------------------------------------------- | ---------------------------------------------------------------- | +| [AreaFormatter](docs/AreaFormatter.md) | Metric area promotion (mm², cm², m², a, ha, km²) | +| [CreditCardFormatter](docs/CreditCardFormatter.md) | Credit card number formatting with auto-detection | +| [DateFormatter](docs/DateFormatter.md) | Date and time formatting with flexible parsing | +| [ImperialAreaFormatter](docs/ImperialAreaFormatter.md) | Imperial area promotion (in², ft², yd², ac, mi²) | +| [ImperialLengthFormatter](docs/ImperialLengthFormatter.md) | Imperial length promotion (in, ft, yd, mi) | +| [ImperialMassFormatter](docs/ImperialMassFormatter.md) | Imperial mass promotion (oz, lb, st, ton) | +| [MaskFormatter](docs/MaskFormatter.md) | Range-based string masking with Unicode support | +| [MassFormatter](docs/MassFormatter.md) | Metric mass promotion (mg, g, kg, t) | +| [MetricFormatter](docs/MetricFormatter.md) | Metric length promotion (mm, cm, m, km) | +| [NumberFormatter](docs/NumberFormatter.md) | Number formatting with thousands and decimal separators | +| [PatternFormatter](docs/PatternFormatter.md) | Pattern-based string filtering with placeholders | +| [PlaceholderFormatter](docs/PlaceholderFormatter.md) | Template interpolation with placeholder replacement | +| [SecureCreditCardFormatter](docs/SecureCreditCardFormatter.md) | Masked credit card formatting for secure display | +| [TimeFormatter](docs/TimeFormatter.md) | Time promotion (mil, c, dec, y, mo, w, d, h, min, s, ms, us, ns) | ## Contributing diff --git a/docs/SecureCreditCardFormatter.md b/docs/SecureCreditCardFormatter.md new file mode 100644 index 0000000..31f9840 --- /dev/null +++ b/docs/SecureCreditCardFormatter.md @@ -0,0 +1,123 @@ + + +# SecureCreditCardFormatter + +The `SecureCreditCardFormatter` formats and masks credit card numbers for secure display. It automatically detects card types, formats them appropriately, and masks sensitive portions. + +## Usage + +### Basic Usage + +```php +use Respect\StringFormatter\SecureCreditCardFormatter; + +$formatter = new SecureCreditCardFormatter(); + +echo $formatter->format('4123456789012345'); +// Outputs: "4123 **** **** 2345" (Visa) + +echo $formatter->format('341234567890123'); +// Outputs: "3412 ****** 90123" (Amex, 4-6-5 format) + +echo $formatter->format('5112345678901234'); +// Outputs: "5112 **** **** 1234" (MasterCard) + +echo $formatter->format('36123456789012'); +// Outputs: "3612 ****** 9012" (Diners Club, 4-6-4 format) + +echo $formatter->format('4123456789012345678'); +// Outputs: "4123 **** **** **** 678" (Visa 19-digit) +``` + +### Custom Mask Character + +```php +use Respect\StringFormatter\SecureCreditCardFormatter; + +$formatter = new SecureCreditCardFormatter('X'); + +echo $formatter->format('4123456789012345'); +// Outputs: "4123 XXXX XXXX 2345" +``` + +### Input Cleaning + +The formatter automatically removes non-digit characters from the input: + +```php +use Respect\StringFormatter\SecureCreditCardFormatter; + +$formatter = new SecureCreditCardFormatter(); + +echo $formatter->format('4123-4567-8901-2345'); +// Outputs: "4123 **** **** 2345" +``` + +## API + +### `SecureCreditCardFormatter::__construct` + +- `__construct(string $maskChar = '*')` + +Creates a new secure credit card formatter instance. + +**Parameters:** + +- `$maskChar`: Character to use for masking (default: '\*') + +### `format` + +- `format(string $input): string` + +Formats and masks the input credit card number. + +**Parameters:** + +- `$input`: The credit card number (can include spaces, dashes, dots, etc.) + +**Returns:** The formatted and masked credit card number + +## Masking + +The formatter applies masking after formatting to ensure predictable positions: + +| Card Type | Example Input | Mask Range | Output | +| -------------------- | --------------------- | ----------------- | ------------------------- | +| **Visa** (16) | `4123456789012345` | `6-9,11-14` | `4123 **** **** 2345` | +| **Visa** (19) | `4123456789012345678` | `6-9,11-14,16-19` | `4123 **** **** **** 678` | +| **MasterCard** | `5112345678901234` | `6-9,11-14` | `5112 **** **** 1234` | +| **American Express** | `341234567890123` | `6-11` | `3412 ****** 90123` | +| **Discover** | `6011000990139424` | `6-9,11-14` | `6011 **** **** 9424` | +| **JCB** | `3528000012345678` | `6-9,11-14` | `3528 **** **** 5678` | +| **Diners Club** (14) | `36123456789012` | `6-11` | `3612 ****** 9012` | +| **UnionPay** | `6212345678901234` | `6-9,11-14` | `6212 **** **** 1234` | +| **RuPay** | `8112345678901234` | `6-9,11-14` | `8112 **** **** 1234` | + +## Examples + +| Input | Output | Card Type | +| --------------------- | ------------------------- | ------------ | +| `4123456789012345` | `4123 **** **** 2345` | Visa | +| `4123456789012345678` | `4123 **** **** **** 678` | Visa (19) | +| `5112345678901234` | `5112 **** **** 1234` | MasterCard | +| `341234567890123` | `3412 ****** 90123` | Amex | +| `371234567890123` | `3712 ****** 90123` | Amex | +| `6011000990139424` | `6011 **** **** 9424` | Discover | +| `3528000012345678` | `3528 **** **** 5678` | JCB | +| `36123456789012` | `3612 ****** 9012` | Diners Club | +| `6212345678901234` | `6212 **** **** 1234` | UnionPay | +| `8112345678901234` | `8112 **** **** 1234` | RuPay | +| `4123-4567-8901-2345` | `4123 **** **** 2345` | Visa (clean) | + +## Notes + +- Composes `CreditCardFormatter` for formatting and `MaskFormatter` for masking +- Formats the card number first, then applies masking to the formatted string +- Mask ranges are applied to 1-based positions in the formatted string +- Non-digit characters are automatically removed from input +- Inputs with fewer than 9 digits are returned as cleaned digits without formatting or masking +- Uses `CreditCardFormatter` for card type detection and formatting diff --git a/src/Mixin/Builder.php b/src/Mixin/Builder.php index c02bd68..80c5728 100644 --- a/src/Mixin/Builder.php +++ b/src/Mixin/Builder.php @@ -20,6 +20,8 @@ public static function area(string $unit): FormatterBuilder; public static function creditCard(): FormatterBuilder; + public static function secureCreditCard(string $maskChar = '*'): FormatterBuilder; + public static function imperialArea(string $unit): FormatterBuilder; public static function imperialLength(string $unit): FormatterBuilder; diff --git a/src/Mixin/Chain.php b/src/Mixin/Chain.php index 1a1b2c2..00e8f17 100644 --- a/src/Mixin/Chain.php +++ b/src/Mixin/Chain.php @@ -18,7 +18,9 @@ interface Chain extends Formatter { public function area(string $unit): FormatterBuilder; - public function creditCard(string|null $pattern = null): FormatterBuilder; + public function creditCard(): FormatterBuilder; + + public function secureCreditCard(string $maskChar = '*'): FormatterBuilder; public function imperialArea(string $unit): FormatterBuilder; diff --git a/src/SecureCreditCardFormatter.php b/src/SecureCreditCardFormatter.php new file mode 100644 index 0000000..e944ca6 --- /dev/null +++ b/src/SecureCreditCardFormatter.php @@ -0,0 +1,65 @@ + + */ + +declare(strict_types=1); + +namespace Respect\StringFormatter; + +use function mb_strlen; +use function mb_substr; + +final readonly class SecureCreditCardFormatter implements Formatter +{ + public function __construct( + private string $maskChar = '*', + ) { + } + + public function format(string $input): string + { + $creditCardFormatter = new CreditCardFormatter(); + $cleaned = $creditCardFormatter->cleanInput($input); + + if (mb_strlen($cleaned) < 9) { + return $cleaned; + } + + $formatted = $creditCardFormatter->format($cleaned); + $maskRange = $this->detectMaskRange($cleaned); + + return (new MaskFormatter($maskRange, $this->maskChar))->format($formatted); + } + + private function detectMaskRange(string $cleaned): string + { + $length = mb_strlen($cleaned); + $firstTwo = mb_substr($cleaned, 0, 2); + + // AMEX (4-6-5 format): mask middle group (positions 6-11) + if ($firstTwo === '34' || $firstTwo === '37') { + return '6-11'; + } + + // Diners Club 14-digit (4-6-4 format): mask middle group (positions 6-11) + if ($length === 14) { + $firstThree = mb_substr($cleaned, 0, 3); + $prefix3 = (int) $firstThree; + if (($prefix3 >= 300 && $prefix3 <= 305) || $prefix3 === 309 || $firstTwo === '36' || $firstTwo === '38') { + return '6-11'; + } + } + + // 19-digit cards (4-4-4-4-3 format): mask groups 2-4 (positions 6-9, 11-14, 16-19) + if ($length > 16) { + return '6-9,11-14,16-19'; + } + + // Default 16-digit (4-4-4-4 format): mask groups 2-3 (positions 6-9, 11-14) + return '6-9,11-14'; + } +} diff --git a/tests/Unit/SecureCreditCardFormatterTest.php b/tests/Unit/SecureCreditCardFormatterTest.php new file mode 100644 index 0000000..9da680c --- /dev/null +++ b/tests/Unit/SecureCreditCardFormatterTest.php @@ -0,0 +1,205 @@ + + */ + +declare(strict_types=1); + +namespace Respect\StringFormatter\Test\Unit; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Respect\StringFormatter\SecureCreditCardFormatter; + +#[CoversClass(SecureCreditCardFormatter::class)] +final class SecureCreditCardFormatterTest extends TestCase +{ + #[Test] + #[DataProvider('providerForVisaCards')] + #[DataProvider('providerForMasterCard')] + #[DataProvider('providerForAmexCards')] + #[DataProvider('providerForDiscoverCards')] + #[DataProvider('providerForJcbCards')] + #[DataProvider('providerForDinersClubCards')] + #[DataProvider('providerForUnionPayCards')] + #[DataProvider('providerForRuPayCards')] + #[DataProvider('providerForUnrecognizedCards')] + #[DataProvider('providerForInputCleaning')] + #[DataProvider('providerForEdgeCases')] + public function itShouldFormatAndMaskCreditCards(string $input, string $expected): void + { + $formatter = new SecureCreditCardFormatter(); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForCustomMaskChar')] + public function itShouldUseCustomMaskChar(string $input, string $maskChar, string $expected): void + { + $formatter = new SecureCreditCardFormatter($maskChar); + + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + #[Test] + public function itShouldHandleEmptyString(): void + { + $formatter = new SecureCreditCardFormatter(); + + $actual = $formatter->format(''); + + self::assertSame('', $actual); + } + + /** @return array */ + public static function providerForVisaCards(): array + { + return [ + 'visa 16 digits' => ['4123456789012345', '4123 **** **** 2345'], + 'visa with dashes' => ['4123-4567-8901-2345', '4123 **** **** 2345'], + 'visa with spaces' => ['4123 4567 8901 2345', '4123 **** **** 2345'], + 'visa another' => ['4532015112830366', '4532 **** **** 0366'], + 'visa starts with 4' => ['4916409457367128', '4916 **** **** 7128'], + 'visa 19 digits' => ['4123456789012345678', '4123 **** **** **** 678'], + ]; + } + + /** @return array */ + public static function providerForMasterCard(): array + { + return [ + 'mastercard 51' => ['5112345678901234', '5112 **** **** 1234'], + 'mastercard 55' => ['5512345678901234', '5512 **** **** 1234'], + 'mastercard 52' => ['5212345678901234', '5212 **** **** 1234'], + 'mastercard 53' => ['5312345678901234', '5312 **** **** 1234'], + 'mastercard 54' => ['5412345678901234', '5412 **** **** 1234'], + ]; + } + + /** @return array */ + public static function providerForAmexCards(): array + { + return [ + 'amex 34' => ['341234567890123', '3412 ****** 90123'], + 'amex 37' => ['371234567890123', '3712 ****** 90123'], + 'amex another 34' => ['347856241795641', '3478 ****** 95641'], + 'amex another 37' => ['378282246310005', '3782 ****** 10005'], + ]; + } + + /** @return array */ + public static function providerForDiscoverCards(): array + { + return [ + 'discover 6011' => ['6011000990139424', '6011 **** **** 9424'], + 'discover 65' => ['6512345678901234', '6512 **** **** 1234'], + 'discover 644' => ['6441234567890123', '6441 **** **** 0123'], + 'discover 645' => ['6451234567890123', '6451 **** **** 0123'], + 'discover 646' => ['6461234567890123', '6461 **** **** 0123'], + 'discover 19 digits' => ['6011000990139424123', '6011 **** **** **** 123'], + ]; + } + + /** @return array */ + public static function providerForJcbCards(): array + { + return [ + 'jcb 3528' => ['3528000012345678', '3528 **** **** 5678'], + 'jcb 3536' => ['3536000012345678', '3536 **** **** 5678'], + 'jcb 3558' => ['3558000012345678', '3558 **** **** 5678'], + 'jcb 3589' => ['3589000012345678', '3589 **** **** 5678'], + ]; + } + + /** @return array */ + public static function providerForDinersClubCards(): array + { + return [ + 'diners 300' => ['30012345678901', '3001 ****** 8901'], + 'diners 301' => ['30112345678901', '3011 ****** 8901'], + 'diners 305' => ['30512345678901', '3051 ****** 8901'], + 'diners 309' => ['30912345678901', '3091 ****** 8901'], + 'diners 36' => ['36123456789012', '3612 ****** 9012'], + 'diners 38' => ['38123456789012', '3812 ****** 9012'], + 'diners 16 digits (mastercard co-brand)' => ['3612345678901234', '3612 **** **** 1234'], + ]; + } + + /** @return array */ + public static function providerForUnionPayCards(): array + { + return [ + 'unionpay 62 16 digits' => ['6212345678901234', '6212 **** **** 1234'], + 'unionpay 62 19 digits' => ['6212345678901234567', '6212 **** **** **** 567'], + ]; + } + + /** @return array */ + public static function providerForRuPayCards(): array + { + return [ + 'rupay 60' => ['6012345678901234', '6012 **** **** 1234'], + 'rupay 81' => ['8112345678901234', '8112 **** **** 1234'], + 'rupay 82' => ['8212345678901234', '8212 **** **** 1234'], + 'rupay 508' => ['5081234567890123', '5081 **** **** 0123'], + ]; + } + + /** @return array */ + public static function providerForUnrecognizedCards(): array + { + return [ + 'unknown 16 digit' => ['1234567890123456', '1234 **** **** 3456'], + 'unknown starts with 1' => ['1111222233334444', '1111 **** **** 4444'], + 'unknown starts with 2' => ['2111222233334444', '2111 **** **** 4444'], + ]; + } + + /** @return array */ + public static function providerForCustomMaskChar(): array + { + return [ + 'mask with X' => ['4123456789012345', 'X', '4123 XXXX XXXX 2345'], + 'mask with #' => ['4123456789012345', '#', '4123 #### #### 2345'], + 'mask with -' => ['4123456789012345', '-', '4123 ---- ---- 2345'], + ]; + } + + /** @return array */ + public static function providerForInputCleaning(): array + { + return [ + 'with spaces' => ['4123 4567 8901 2345', '4123 **** **** 2345'], + 'with dashes' => ['4123-4567-8901-2345', '4123 **** **** 2345'], + 'with dots' => ['4123.4567.8901.2345', '4123 **** **** 2345'], + 'mixed separators' => ['4123-4567.8901 2345', '4123 **** **** 2345'], + 'with letters' => ['4123A4567B8901C2345', '4123 **** **** 2345'], + 'with special chars' => ['4123!4567@8901#2345', '4123 **** **** 2345'], + ]; + } + + /** @return array */ + public static function providerForEdgeCases(): array + { + return [ + 'empty string' => ['', ''], + 'only spaces' => [' ', ''], + 'only dashes' => ['----', ''], + 'only dots' => ['....', ''], + 'only letters' => ['abcd', ''], + 'short number' => ['123', '123'], + 'mixed content' => ['abcd4123456789012345abcd', '4123 **** **** 2345'], + 'numbers longer than pattern' => ['41234567890123456789', '4123 **** **** **** 678'], + ]; + } +}