diff --git a/README.md b/README.md index 9451966..3568c6c 100644 --- a/README.md +++ b/README.md @@ -56,20 +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²) | -| [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/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/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/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..80c5728 100644 --- a/src/Mixin/Builder.php +++ b/src/Mixin/Builder.php @@ -18,6 +18,10 @@ interface Builder { 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 780ba0b..00e8f17 100644 --- a/src/Mixin/Chain.php +++ b/src/Mixin/Chain.php @@ -18,6 +18,10 @@ interface Chain extends Formatter { public function area(string $unit): FormatterBuilder; + public function creditCard(): FormatterBuilder; + + public function secureCreditCard(string $maskChar = '*'): FormatterBuilder; + public function imperialArea(string $unit): FormatterBuilder; public function imperialLength(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/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'], + ]; + } +} 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'], + ]; + } +}