From b940d577f8c7e4e12e9519048c7a542dd07a7aba Mon Sep 17 00:00:00 2001 From: Baptiste Leduc Date: Thu, 9 Apr 2026 22:00:55 +0200 Subject: [PATCH] Add Samsung Wallet --- README.md | 61 ++-- castor.php | 12 + docs/builder-examples.md | 126 +++++-- src/Builder/BuiltWalletPass.php | 12 + src/Builder/CommonWalletBuilderTrait.php | 66 ++++ .../EventTicket/EventTicketWalletBuilder.php | 27 +- src/Builder/Flight/FlightWalletBuilder.php | 28 +- src/Builder/Generic/GenericWalletBuilder.php | 23 +- .../GiftCard/GiftCardWalletBuilder.php | 21 +- src/Builder/Internal/CommonWalletState.php | 4 + src/Builder/Internal/SamsungBarcodeMapper.php | 38 +++ .../SamsungBoardingPassSubTypeMapper.php | 20 ++ src/Builder/Loyalty/LoyaltyWalletBuilder.php | 21 +- src/Builder/Offer/OfferWalletBuilder.php | 23 +- src/Builder/SamsungWalletContext.php | 17 + src/Builder/Transit/TransitWalletBuilder.php | 21 +- src/Builder/WalletPlatformContext.php | 134 ++++---- .../InvalidWalletPlatformContextException.php | 2 +- .../SamsungCardNotAvailableException.php | 13 + ...amsungPlatformContextRequiredException.php | 13 + .../BoardingPass/BoardingPassAttributes.php | 80 +++++ src/Pass/Samsung/Model/Card.php | 28 ++ src/Pass/Samsung/Model/CardData.php | 35 ++ .../Samsung/Model/Coupon/CouponAttributes.php | 62 ++++ .../Model/DigitalId/DigitalIdAttributes.php | 82 +++++ .../EventTicket/EventTicketAttributes.php | 73 ++++ .../Model/Generic/GenericAttributes.php | 79 +++++ .../Model/GiftCard/GiftCardAttributes.php | 75 +++++ src/Pass/Samsung/Model/Localization.php | 26 ++ .../Model/Loyalty/LoyaltyAttributes.php | 71 ++++ .../Model/PayAsYouGo/PayAsYouGoAttributes.php | 71 ++++ .../Samsung/Model/Shared/CardSubTypeEnum.php | 26 ++ .../Samsung/Model/Shared/CardTypeEnum.php | 20 ++ src/Pass/Samsung/Model/Shared/Location.php | 19 ++ .../Samsung/Model/Shared/SamsungBarcode.php | 22 ++ .../Samsung/Model/Shared/SamsungImage.php | 17 + .../Samsung/Model/Shared/SerialTypeEnum.php | 17 + .../BoardingPassAttributesNormalizer.php | 144 ++++++++ .../Samsung/Normalizer/CardDataNormalizer.php | 59 ++++ .../Samsung/Normalizer/CardNormalizer.php | 54 +++ .../Coupon/CouponAttributesNormalizer.php | 108 ++++++ .../DigitalIdAttributesNormalizer.php | 148 +++++++++ .../EventTicketAttributesNormalizer.php | 115 +++++++ .../Generic/GenericAttributesNormalizer.php | 133 ++++++++ .../GiftCard/GiftCardAttributesNormalizer.php | 131 ++++++++ .../Normalizer/LocalizationNormalizer.php | 46 +++ .../Loyalty/LoyaltyAttributesNormalizer.php | 123 +++++++ .../PayAsYouGoAttributesNormalizer.php | 120 +++++++ .../Normalizer/Shared/LocationNormalizer.php | 52 +++ .../Shared/SamsungBarcodeNormalizer.php | 59 ++++ .../Shared/SamsungImageNormalizer.php | 49 +++ .../Builder/BuilderTestSerializerFactory.php | 28 ++ tests/Builder/DualWalletBuilderTest.php | 243 +++++++++++--- .../Normalizer/SamsungNormalizerTest.php | 311 ++++++++++++++++++ tools/spec/samsung-wallet-keyset.json | 250 ++++++++++++++ tools/spec/samsung-wallet-keyset.php | 237 +++++++++++++ 56 files changed, 3723 insertions(+), 172 deletions(-) create mode 100644 src/Builder/Internal/SamsungBarcodeMapper.php create mode 100644 src/Builder/Internal/SamsungBoardingPassSubTypeMapper.php create mode 100644 src/Builder/SamsungWalletContext.php create mode 100644 src/Exception/SamsungCardNotAvailableException.php create mode 100644 src/Exception/SamsungPlatformContextRequiredException.php create mode 100644 src/Pass/Samsung/Model/BoardingPass/BoardingPassAttributes.php create mode 100644 src/Pass/Samsung/Model/Card.php create mode 100644 src/Pass/Samsung/Model/CardData.php create mode 100644 src/Pass/Samsung/Model/Coupon/CouponAttributes.php create mode 100644 src/Pass/Samsung/Model/DigitalId/DigitalIdAttributes.php create mode 100644 src/Pass/Samsung/Model/EventTicket/EventTicketAttributes.php create mode 100644 src/Pass/Samsung/Model/Generic/GenericAttributes.php create mode 100644 src/Pass/Samsung/Model/GiftCard/GiftCardAttributes.php create mode 100644 src/Pass/Samsung/Model/Localization.php create mode 100644 src/Pass/Samsung/Model/Loyalty/LoyaltyAttributes.php create mode 100644 src/Pass/Samsung/Model/PayAsYouGo/PayAsYouGoAttributes.php create mode 100644 src/Pass/Samsung/Model/Shared/CardSubTypeEnum.php create mode 100644 src/Pass/Samsung/Model/Shared/CardTypeEnum.php create mode 100644 src/Pass/Samsung/Model/Shared/Location.php create mode 100644 src/Pass/Samsung/Model/Shared/SamsungBarcode.php create mode 100644 src/Pass/Samsung/Model/Shared/SamsungImage.php create mode 100644 src/Pass/Samsung/Model/Shared/SerialTypeEnum.php create mode 100644 src/Pass/Samsung/Normalizer/BoardingPass/BoardingPassAttributesNormalizer.php create mode 100644 src/Pass/Samsung/Normalizer/CardDataNormalizer.php create mode 100644 src/Pass/Samsung/Normalizer/CardNormalizer.php create mode 100644 src/Pass/Samsung/Normalizer/Coupon/CouponAttributesNormalizer.php create mode 100644 src/Pass/Samsung/Normalizer/DigitalId/DigitalIdAttributesNormalizer.php create mode 100644 src/Pass/Samsung/Normalizer/EventTicket/EventTicketAttributesNormalizer.php create mode 100644 src/Pass/Samsung/Normalizer/Generic/GenericAttributesNormalizer.php create mode 100644 src/Pass/Samsung/Normalizer/GiftCard/GiftCardAttributesNormalizer.php create mode 100644 src/Pass/Samsung/Normalizer/LocalizationNormalizer.php create mode 100644 src/Pass/Samsung/Normalizer/Loyalty/LoyaltyAttributesNormalizer.php create mode 100644 src/Pass/Samsung/Normalizer/PayAsYouGo/PayAsYouGoAttributesNormalizer.php create mode 100644 src/Pass/Samsung/Normalizer/Shared/LocationNormalizer.php create mode 100644 src/Pass/Samsung/Normalizer/Shared/SamsungBarcodeNormalizer.php create mode 100644 src/Pass/Samsung/Normalizer/Shared/SamsungImageNormalizer.php create mode 100644 tests/Pass/Samsung/Normalizer/SamsungNormalizerTest.php create mode 100644 tools/spec/samsung-wallet-keyset.json create mode 100644 tools/spec/samsung-wallet-keyset.php diff --git a/README.md b/README.md index 3045511..15f6b28 100644 --- a/README.md +++ b/README.md @@ -15,49 +15,59 @@ ## Overview -Wallet Kit helps you build the **JSON payloads** wallet platforms expect. It focuses on **modeling and normalization** (via Symfony Serializer): it does **not** sign Apple passes, bundle `.pkpass` files, or call Google Wallet APIs. +Wallet Kit helps you build the **JSON payloads** wallet platforms expect. It focuses on **modeling and normalization** (via Symfony Serializer): it does **not** sign Apple passes, bundle `.pkpass` files, call Google Wallet APIs, or tokenize Samsung Wallet payloads. - **PHP** 8.3+ - **symfony/serializer** ^7.4 || ^8.0 ## đŸ› ïž Builder -The **`Jolicode\WalletKit\Builder`** namespace provides a fluent API centered on [`WalletPass`](src/Builder/WalletPass.php). Whether you need to build passes for **Apple, Google, or both platforms simultaneously**, use [`WalletPlatformContext::both(...)`](src/Builder/WalletPlatformContext.php) and then call `build()` to obtain a [`BuiltWalletPass`](src/Builder/BuiltWalletPass.php) (`apple()`, `google()`). You can then normalize these models using Symfony Serializer along with this package’s normalizers. +The **`Jolicode\WalletKit\Builder`** namespace provides a fluent API centered on [`WalletPass`](src/Builder/WalletPass.php). Build a [`WalletPlatformContext`](src/Builder/WalletPlatformContext.php) with `->withApple(...)`, `->withGoogle(...)`, and/or `->withSamsung(...)`, then call `build()` to obtain a [`BuiltWalletPass`](src/Builder/BuiltWalletPass.php) (`apple()`, `google()`, `samsung()`). You can then normalize these models using Symfony Serializer along with this package’s normalizers. **Cookbook** (single-store `appleOnly` / `googleOnly`, every vertical, shared options, exceptions): [docs/builder-examples.md](docs/builder-examples.md). -### Example — dual platform +### Example — all platforms ```php -$context = WalletPlatformContext::both( - appleTeamIdentifier: 'ABCDE12345', - applePassTypeIdentifier: 'pass.com.example.coupon', - appleSerialNumber: 'COUPON-001', - appleOrganizationName: 'Example Shop', - appleDescription: 'Spring sale coupon', - googleClassId: '3388000000012345.example_offer_class', - googleObjectId: '3388000000012345.example_offer_object', - defaultGoogleReviewStatus: ReviewStatusEnum::APPROVED, - defaultGoogleObjectState: StateEnum::ACTIVE, -); +$context = (new WalletPlatformContext()) + ->withApple( + teamIdentifier: ‘ABCDE12345’, + passTypeIdentifier: ‘pass.com.example.coupon’, + serialNumber: ‘COUPON-001’, + organizationName: ‘Example Shop’, + description: ‘Spring sale coupon’, + ) + ->withGoogle( + classId: ‘3388000000012345.example_offer_class’, + objectId: ‘3388000000012345.example_offer_object’, + defaultReviewStatus: ReviewStatusEnum::APPROVED, + defaultGoogleObjectState: StateEnum::ACTIVE, + ) + ->withSamsung( + refId: ‘coupon-samsung-001’, + appLinkLogo: ‘https://example.com/logo.png’, + appLinkName: ‘Example Shop’, + appLinkData: ‘https://example.com’, + ); $built = WalletPass::offer( $context, - title: '15% off', - provider: 'Example Shop', + title: ‘15% off’, + provider: ‘Example Shop’, redemptionChannel: RedemptionChannelEnum::BOTH, ) - ->withBackgroundColorRgb('rgb(30, 60, 90)') + ->withBackgroundColorRgb(‘rgb(30, 60, 90)’) ->addAppleBarcode(new Barcode( - altText: 'Coupon', + altText: ‘Coupon’, format: BarcodeFormatEnum::QR, - message: 'SAVE15-2026', - messageEncoding: 'utf-8', + message: ‘SAVE15-2026’, + messageEncoding: ‘utf-8’, )) ->build(); // $built->apple() → Pass (coupon) // $built->google() → OfferClass + OfferObject +// $built->samsung() → Card (coupon) // Then normalize with Symfony Serializer + this library’s normalizers. ``` @@ -69,6 +79,10 @@ Apple’s model maps to a **single** tree: either use the **builder** above or b Google’s API splits each pass type into **two** resources: a **class** (shared template) and an **object** (one per holder). The object references the class through **`classId`**. This library exposes both sides under `src/Pass/Android/` for: EventTicket, Flight, Generic, GiftCard, Loyalty, Offer, and Transit. The **builder** returns that pair from `BuiltWalletPass::google()`. +### đŸ“± Samsung Wallet + +Samsung Wallet uses a **single unified JSON** envelope: a `Card` containing `type`, `subType`, and a `data` array of card entries with `attributes` (type-specific fields). This library models 8 Samsung card types under `src/Pass/Samsung/`: BoardingPass, EventTicket, Coupon, GiftCard, Loyalty, Generic, DigitalId, and PayAsYouGo. The **builder** maps the 7 cross-platform verticals to their Samsung equivalents and returns a `Card` from `BuiltWalletPass::samsung()`. DigitalId and PayAsYouGo are Samsung-only types — build them directly via the model classes. + ## Install ```bash @@ -79,12 +93,13 @@ composer require jolicode/wallet-kit - `Jolicode\WalletKit\Pass\Apple` — Apple Wallet `pass.json` payloads - `Jolicode\WalletKit\Pass\Android` — Google Wallet class and object payloads -- `Jolicode\WalletKit\Builder` — Fluent builders (`WalletPass`, 
) for Apple, Google, or both +- `Jolicode\WalletKit\Pass\Samsung` — Samsung Wallet card payloads +- `Jolicode\WalletKit\Builder` — Fluent builders (`WalletPass`, 
) for Apple, Google, Samsung, or all - `Jolicode\WalletKit\Exception` — Builder context and `BuiltWalletPass` accessor exceptions ## API spec checks (with Castor) -When [Castor](https://github.com/jolicode/castor) is available, you can verify that tracked baselines still match the **Google Wallet discovery document** and the **Apple `pass.json` phpstan shapes** in this repo: +When [Castor](https://github.com/jolicode/castor) is available, you can verify that tracked baselines still match the **Google Wallet discovery document**, the **Apple `pass.json` phpstan shapes**, and the **Samsung Wallet model shapes** in this repo: | Command | Purpose | | --- | --- | @@ -92,6 +107,8 @@ When [Castor](https://github.com/jolicode/castor) is available, you can verify t | `castor spec:baseline:google` | After you update Android models for a new discovery revision, refreshes that JSON baseline. | | `castor spec:check:apple` | Regenerates a key list from `src/Pass/Apple/Model` `@phpstan-type` array shapes and diffs it against [`tools/spec/apple-pass-keyset.json`](tools/spec/apple-pass-keyset.json). | | `castor spec:baseline:apple` | Rewrites `apple-pass-keyset.json` from the current phpstan definitions (run after intentional model changes). | +| `castor spec:check:samsung` | Regenerates a key list from `src/Pass/Samsung/Model` `@phpstan-type` array shapes and diffs it against [`tools/spec/samsung-wallet-keyset.json`](tools/spec/samsung-wallet-keyset.json). | +| `castor spec:baseline:samsung` | Rewrites `samsung-wallet-keyset.json` from the current phpstan definitions (run after intentional model changes). | Scripts live under [`tools/spec/`](tools/spec/) and are also invoked by CI (`spec-check` job). diff --git a/castor.php b/castor.php index 9c8f964..9540036 100644 --- a/castor.php +++ b/castor.php @@ -79,3 +79,15 @@ function spec_baseline_google(): void { run([spec_tools_php(), __DIR__ . '/tools/spec/google-wallet-spec.php', 'baseline']); } + +#[AsTask('check:samsung', namespace: 'spec', description: 'Compare Samsung Wallet phpstan keyset to tools/spec/samsung-wallet-keyset.json')] +function spec_check_samsung(): void +{ + run([spec_tools_php(), __DIR__ . '/tools/spec/samsung-wallet-keyset.php', 'check']); +} + +#[AsTask('baseline:samsung', namespace: 'spec', description: 'Regenerate tools/spec/samsung-wallet-keyset.json from Samsung Model phpstan types')] +function spec_baseline_samsung(): void +{ + run([spec_tools_php(), __DIR__ . '/tools/spec/samsung-wallet-keyset.php', 'baseline']); +} diff --git a/docs/builder-examples.md b/docs/builder-examples.md index 5814463..ebcf717 100644 --- a/docs/builder-examples.md +++ b/docs/builder-examples.md @@ -14,12 +14,13 @@ - [Portable options (all builders)](#portable-options-all-builders) - [Limitations](#limitations) -This page shows one **end-to-end example** per vertical supported by [`WalletPass`](../src/Builder/WalletPass.php). Examples below use a **dual-platform** context; you can also use [`WalletPlatformContext::appleOnly`](../src/Builder/WalletPlatformContext.php) or [`::googleOnly`](../src/Builder/WalletPlatformContext.php) when you only target one store. +This page shows one **end-to-end example** per vertical supported by [`WalletPass`](../src/Builder/WalletPass.php). Examples below use a **dual-platform** context; you can target any combination by chaining [`->withApple(...)`](../src/Builder/WalletPlatformContext.php), [`->withGoogle(...)`](../src/Builder/WalletPlatformContext.php), and/or [`->withSamsung(...)`](../src/Builder/WalletPlatformContext.php). After `build()`, you get a [`BuiltWalletPass`](../src/Builder/BuiltWalletPass.php): - `$built->apple()` → Apple [`Pass`](../src/Pass/Apple/Model/Pass.php) for `pass.json` (throws [`ApplePassNotAvailableException`](../src/Exception/ApplePassNotAvailableException.php) if the context had no Apple slice) - `$built->google()->issuerClass` / `$built->google()->passObject` → Google class and object (throws [`GoogleWalletPairNotAvailableException`](../src/Exception/GoogleWalletPairNotAvailableException.php) if there was no Google slice) +- `$built->samsung()` → Samsung [`Card`](../src/Pass/Samsung/Model/Card.php) envelope (throws [`SamsungCardNotAvailableException`](../src/Exception/SamsungCardNotAvailableException.php) if there was no Samsung slice) Serialize with **Symfony Serializer** and the normalizers from this package (see [`tests/Builder/BuilderTestSerializerFactory.php`](../tests/Builder/BuilderTestSerializerFactory.php) for a full list). @@ -36,24 +37,27 @@ use Jolicode\WalletKit\Builder\WalletPlatformContext; use Jolicode\WalletKit\Pass\Android\Model\Shared\ReviewStatusEnum; use Jolicode\WalletKit\Pass\Android\Model\Shared\StateEnum; -$context = WalletPlatformContext::both( - appleTeamIdentifier: 'YOUR_TEAM_ID', - applePassTypeIdentifier: 'pass.com.example.app', - appleSerialNumber: 'UNIQUE-SERIAL-001', - appleOrganizationName: 'Example Airlines', - appleDescription: 'Boarding pass SFO → LHR', - googleClassId: '3388000000012345.example_flight_class', - googleObjectId: '3388000000012345.example_flight_object', - defaultGoogleReviewStatus: ReviewStatusEnum::APPROVED, - defaultGoogleObjectState: StateEnum::ACTIVE, -); +$context = (new WalletPlatformContext()) + ->withApple( + teamIdentifier: 'YOUR_TEAM_ID', + passTypeIdentifier: 'pass.com.example.app', + serialNumber: 'UNIQUE-SERIAL-001', + organizationName: 'Example Airlines', + description: 'Boarding pass SFO → LHR', + ) + ->withGoogle( + classId: '3388000000012345.example_flight_class', + objectId: '3388000000012345.example_flight_object', + defaultReviewStatus: ReviewStatusEnum::APPROVED, + defaultObjectState: StateEnum::ACTIVE, + ); ``` --- ## Apple-only and Google-only snippets -**Apple-only** (no Google IDs required). After `build()`, use only `$built->apple()`; `$built->google()` throws. +**Apple-only** (no Google or Samsung needed). After `build()`, use only `$built->apple()`; `$built->google()` and `$built->samsung()` throw. ```php use Jolicode\WalletKit\Builder\WalletPlatformContext; @@ -62,12 +66,12 @@ use Jolicode\WalletKit\Pass\Apple\Model\Field; use Jolicode\WalletKit\Pass\Apple\Model\PassStructure; use Jolicode\WalletKit\Pass\Android\Model\Generic\GenericTypeEnum; -$appleContext = WalletPlatformContext::appleOnly( - appleTeamIdentifier: 'YOUR_TEAM_ID', - applePassTypeIdentifier: 'pass.com.example.app', - appleSerialNumber: 'SN-APPLE-ONLY', - appleOrganizationName: 'Example Org', - appleDescription: 'Membership', +$appleContext = (new WalletPlatformContext())->withApple( + teamIdentifier: 'YOUR_TEAM_ID', + passTypeIdentifier: 'pass.com.example.app', + serialNumber: 'SN-APPLE-ONLY', + organizationName: 'Example Org', + description: 'Membership', ); $built = WalletPass::generic($appleContext) @@ -80,7 +84,7 @@ $built = WalletPass::generic($appleContext) $pass = $built->apple(); ``` -**Google-only** (requires `issuerName` on the context for class payloads). After `build()`, use `$built->google()`; `$built->apple()` throws. You can still call `addAppleBarcode()` to supply a barcode image for the Google object. +**Google-only** (requires `issuerName` for class payloads when no Apple context provides an organization name). After `build()`, use `$built->google()`; `$built->apple()` throws. You can still call `addAppleBarcode()` to supply a barcode for the Google object. ```php use Jolicode\WalletKit\Builder\WalletPlatformContext; @@ -89,9 +93,9 @@ use Jolicode\WalletKit\Pass\Android\Model\Offer\RedemptionChannelEnum; use Jolicode\WalletKit\Pass\Apple\Model\Barcode; use Jolicode\WalletKit\Pass\Apple\Model\BarcodeFormatEnum; -$googleContext = WalletPlatformContext::googleOnly( - googleClassId: '3388000000012345.example_offer_class', - googleObjectId: '3388000000012345.example_offer_object', +$googleContext = (new WalletPlatformContext())->withGoogle( + classId: '3388000000012345.example_offer_class', + objectId: '3388000000012345.example_offer_object', issuerName: 'Example Shop', ); @@ -110,6 +114,74 @@ $built = WalletPass::offer( $pair = $built->google(); ``` +**Samsung-only** (Samsung requires `appLinkLogo`, `appLinkName`, `appLinkData` on all card types). After `build()`, use `$built->samsung()`; `$built->apple()` and `$built->google()` throw. + +```php +use Jolicode\WalletKit\Builder\WalletPlatformContext; +use Jolicode\WalletKit\Builder\WalletPass; +use Jolicode\WalletKit\Pass\Android\Model\Offer\RedemptionChannelEnum; +use Jolicode\WalletKit\Pass\Apple\Model\Barcode; +use Jolicode\WalletKit\Pass\Apple\Model\BarcodeFormatEnum; + +$samsungContext = (new WalletPlatformContext())->withSamsung( + refId: 'coupon-samsung-001', + appLinkLogo: 'https://example.com/logo.png', + appLinkName: 'Example Shop', + appLinkData: 'https://example.com', +); + +$built = WalletPass::offer( + $samsungContext, + title: '10% off', + provider: 'Example Shop', + redemptionChannel: RedemptionChannelEnum::INSTORE, +)->addAppleBarcode(new Barcode( + altText: 'Promo', + format: BarcodeFormatEnum::QR, + message: 'SAVE10', + messageEncoding: 'utf-8', +))->build(); + +$card = $built->samsung(); +``` + +**All three platforms** — Apple + Google + Samsung in a single build. + +```php +use Jolicode\WalletKit\Builder\WalletPlatformContext; +use Jolicode\WalletKit\Builder\WalletPass; +use Jolicode\WalletKit\Pass\Android\Model\Offer\RedemptionChannelEnum; +use Jolicode\WalletKit\Pass\Android\Model\Shared\ReviewStatusEnum; +use Jolicode\WalletKit\Pass\Android\Model\Shared\StateEnum; + +$allContext = (new WalletPlatformContext()) + ->withApple( + teamIdentifier: 'YOUR_TEAM_ID', + passTypeIdentifier: 'pass.com.example.app', + serialNumber: 'SN-ALL-001', + organizationName: 'Example Shop', + description: 'Promotional offer', + ) + ->withGoogle( + classId: '3388000000012345.example_offer_class', + objectId: '3388000000012345.example_offer_object', + defaultReviewStatus: ReviewStatusEnum::APPROVED, + defaultObjectState: StateEnum::ACTIVE, + ) + ->withSamsung( + refId: 'offer-samsung-001', + appLinkLogo: 'https://example.com/logo.png', + appLinkName: 'Example Shop', + appLinkData: 'https://example.com', + ); + +$built = WalletPass::offer($allContext, '20% off', 'Example Shop', RedemptionChannelEnum::BOTH)->build(); + +$applePass = $built->apple(); +$googlePair = $built->google(); +$samsungCard = $built->samsung(); +``` + --- ## 1. Generic pass @@ -341,11 +413,13 @@ These methods come from [`CommonWalletBuilderTrait`](../src/Builder/CommonWallet | `withGoogleReviewStatus` / `withGoogleObjectState` | Overrides context defaults for Google class/object lifecycle. | | `withAppLinkData` / `withGoogleLinksModuleData` | Links and app deep links (Google; Apple where mapped). | | `mutateApple(callable)` | Escape hatch to tweak the Apple `Pass` before `build()` returns. | +| `mutateSamsung(callable)` | Escape hatch to tweak the Samsung `Card` before `build()` returns. | --- ## Limitations -- The library does **not** sign `.pkpass` bundles or call Google Wallet REST APIs. -- Apple and Google models differ: not every field exists on both sides. Use `mutateApple` or adjust the returned Google class/object after `build()` for platform-specific details. -- A [`WalletPlatformContext`](../src/Builder/WalletPlatformContext.php) with **no** Apple and **no** Google slice throws [`InvalidWalletPlatformContextException`](../src/Exception/InvalidWalletPlatformContextException.php). Google-only contexts must include a non-empty `issuerName` (or use `::googleOnly(...)`, which enforces it). +- The library does **not** sign `.pkpass` bundles, call Google Wallet REST APIs, or tokenize Samsung Wallet payloads. +- Apple, Google, and Samsung models differ: not every field exists on all sides. Use `mutateApple`, `mutateSamsung`, or adjust the returned Google class/object after `build()` for platform-specific details. +- A [`WalletPlatformContext`](../src/Builder/WalletPlatformContext.php) with **no** platform slice will produce a `BuiltWalletPass` where all accessors throw. Google contexts without an `issuerName` must have an Apple context to fall back on (via `organizationName`). +- Samsung **Digital ID** and **Pay As You Go** card types have no Apple/Google equivalent and are not exposed through `WalletPass` factory methods. Build them directly via the Samsung model classes under `src/Pass/Samsung/Model/`. diff --git a/src/Builder/BuiltWalletPass.php b/src/Builder/BuiltWalletPass.php index 18031af..9f0fd96 100644 --- a/src/Builder/BuiltWalletPass.php +++ b/src/Builder/BuiltWalletPass.php @@ -6,13 +6,16 @@ use Jolicode\WalletKit\Exception\ApplePassNotAvailableException; use Jolicode\WalletKit\Exception\GoogleWalletPairNotAvailableException; +use Jolicode\WalletKit\Exception\SamsungCardNotAvailableException; use Jolicode\WalletKit\Pass\Apple\Model\Pass; +use Jolicode\WalletKit\Pass\Samsung\Model\Card; final class BuiltWalletPass { public function __construct( private readonly ?Pass $apple, private readonly ?GoogleWalletPair $google, + private readonly ?Card $samsung = null, ) { } @@ -38,4 +41,13 @@ public function googleVertical(): GoogleVerticalEnum { return $this->google()->vertical; } + + public function samsung(): Card + { + if (null === $this->samsung) { + throw new SamsungCardNotAvailableException(); + } + + return $this->samsung; + } } diff --git a/src/Builder/CommonWalletBuilderTrait.php b/src/Builder/CommonWalletBuilderTrait.php index 90a09f0..c8d4205 100644 --- a/src/Builder/CommonWalletBuilderTrait.php +++ b/src/Builder/CommonWalletBuilderTrait.php @@ -7,8 +7,10 @@ use Jolicode\WalletKit\Builder\Internal\BarcodeMapper; use Jolicode\WalletKit\Builder\Internal\ColorMapper; use Jolicode\WalletKit\Builder\Internal\CommonWalletState; +use Jolicode\WalletKit\Builder\Internal\SamsungBarcodeMapper; use Jolicode\WalletKit\Exception\ApplePlatformContextRequiredException; use Jolicode\WalletKit\Exception\GooglePlatformContextRequiredException; +use Jolicode\WalletKit\Exception\SamsungPlatformContextRequiredException; use Jolicode\WalletKit\Pass\Android\Model\Shared\AppLinkData; use Jolicode\WalletKit\Pass\Android\Model\Shared\Barcode as GoogleBarcode; use Jolicode\WalletKit\Pass\Android\Model\Shared\GoogleDateTime; @@ -19,6 +21,11 @@ use Jolicode\WalletKit\Pass\Android\Model\Shared\TimeInterval; use Jolicode\WalletKit\Pass\Apple\Model\Barcode as AppleBarcode; use Jolicode\WalletKit\Pass\Apple\Model\Pass; +use Jolicode\WalletKit\Pass\Samsung\Model\Card; +use Jolicode\WalletKit\Pass\Samsung\Model\CardData; +use Jolicode\WalletKit\Pass\Samsung\Model\Shared\CardSubTypeEnum; +use Jolicode\WalletKit\Pass\Samsung\Model\Shared\CardTypeEnum; +use Jolicode\WalletKit\Pass\Samsung\Model\Shared\SamsungBarcode; /** * Portable wallet options shared across vertical builders. @@ -185,6 +192,16 @@ public function mutateApple(callable $mutator): static return $this; } + /** + * @param callable(Card): void $mutator + */ + public function mutateSamsung(callable $mutator): static + { + $this->common->samsungCardMutator = $mutator; + + return $this; + } + protected function primaryGoogleBarcode(): ?GoogleBarcode { if (null !== $this->common->googleBarcodeOverride) { @@ -272,4 +289,53 @@ protected function createApplePass(\Jolicode\WalletKit\Pass\Apple\Model\PassType return $this->finishApplePass($pass); } + + protected function primarySamsungBarcode(): ?SamsungBarcode + { + return SamsungBarcodeMapper::fromFirstAppleBarcode($this->common->appleBarcodes); + } + + protected function resolvedSamsungHexColor(): ?string + { + return $this->common->googleHexBackgroundColor + ?? ColorMapper::appleRgbToGoogleHex($this->common->appleBackgroundColor); + } + + /** + * Builds the Samsung {@see Card} envelope shared by all verticals. + */ + protected function createSamsungCard(CardTypeEnum $type, CardSubTypeEnum $subType, object $attributes): Card + { + $samsung = $this->context->samsung; + if (null === $samsung) { + throw new SamsungPlatformContextRequiredException('createSamsungCard() requires a Samsung context.'); + } + + $now = (int) (microtime(true) * 1000); + + $card = new Card( + type: $type, + subType: $subType, + data: [ + new CardData( + refId: $samsung->refId, + createdAt: $now, + updatedAt: $now, + language: $samsung->language, + attributes: $attributes, + ), + ], + ); + + return $this->finishSamsungCard($card); + } + + protected function finishSamsungCard(Card $card): Card + { + if (null !== $this->common->samsungCardMutator) { + ($this->common->samsungCardMutator)($card); + } + + return $card; + } } diff --git a/src/Builder/EventTicket/EventTicketWalletBuilder.php b/src/Builder/EventTicket/EventTicketWalletBuilder.php index f205d2b..f52008e 100644 --- a/src/Builder/EventTicket/EventTicketWalletBuilder.php +++ b/src/Builder/EventTicket/EventTicketWalletBuilder.php @@ -14,6 +14,9 @@ use Jolicode\WalletKit\Pass\Apple\Model\Field; use Jolicode\WalletKit\Pass\Apple\Model\PassStructure; use Jolicode\WalletKit\Pass\Apple\Model\PassTypeEnum; +use Jolicode\WalletKit\Pass\Samsung\Model\EventTicket\EventTicketAttributes; +use Jolicode\WalletKit\Pass\Samsung\Model\Shared\CardSubTypeEnum; +use Jolicode\WalletKit\Pass\Samsung\Model\Shared\CardTypeEnum; final class EventTicketWalletBuilder extends AbstractWalletBuilder { @@ -94,6 +97,28 @@ classId: $g->classId, $googlePair = new GoogleWalletPair(GoogleVerticalEnum::EVENT_TICKET, $eventClass, $eventObject); } - return new BuiltWalletPass($applePass, $googlePair); + $samsungCard = null; + if ($this->context->hasSamsung()) { + $s = $this->context->samsung; + $now = (int) (microtime(true) * 1000); + $attributes = new EventTicketAttributes( + title: $this->eventName, + providerName: $this->context->hasApple() ? $this->context->apple->organizationName : ($this->context->hasGoogle() ? $this->context->googleIssuerName() : ''), + issueDate: $now, + reservationNumber: $this->ticketNumber ?? '', + startDate: $now, + noticeDesc: $this->context->hasApple() ? $this->context->apple->description : '', + appLinkLogo: $s->appLinkLogo ?? '', + appLinkName: $s->appLinkName ?? '', + appLinkData: $s->appLinkData ?? '', + holderName: $this->ticketHolderName, + seatNumber: null, + barcode: $this->primarySamsungBarcode(), + bgColor: $this->resolvedSamsungHexColor(), + ); + $samsungCard = $this->createSamsungCard(CardTypeEnum::TICKET, CardSubTypeEnum::PERFORMANCES, $attributes); + } + + return new BuiltWalletPass($applePass, $googlePair, $samsungCard); } } diff --git a/src/Builder/Flight/FlightWalletBuilder.php b/src/Builder/Flight/FlightWalletBuilder.php index e19a037..cb2ee0e 100644 --- a/src/Builder/Flight/FlightWalletBuilder.php +++ b/src/Builder/Flight/FlightWalletBuilder.php @@ -19,6 +19,9 @@ use Jolicode\WalletKit\Pass\Apple\Model\PassStructure; use Jolicode\WalletKit\Pass\Apple\Model\PassTypeEnum; use Jolicode\WalletKit\Pass\Apple\Model\TransitTypeEnum; +use Jolicode\WalletKit\Pass\Samsung\Model\BoardingPass\BoardingPassAttributes; +use Jolicode\WalletKit\Pass\Samsung\Model\Shared\CardSubTypeEnum; +use Jolicode\WalletKit\Pass\Samsung\Model\Shared\CardTypeEnum; final class FlightWalletBuilder extends AbstractWalletBuilder { @@ -101,6 +104,29 @@ classId: $g->classId, $googlePair = new GoogleWalletPair(GoogleVerticalEnum::FLIGHT, $flightClass, $flightObject); } - return new BuiltWalletPass($applePass, $googlePair); + $samsungCard = null; + if ($this->context->hasSamsung()) { + $s = $this->context->samsung; + $attributes = new BoardingPassAttributes( + title: 'Flight ' . ($this->flightHeader->flightNumber ?? ''), + providerName: $this->context->hasApple() ? $this->context->apple->organizationName : ($this->context->hasGoogle() ? $this->context->googleIssuerName() : ''), + bgColor: $this->resolvedSamsungHexColor() ?? '#000000', + appLinkLogo: $s->appLinkLogo ?? '', + appLinkName: $s->appLinkName ?? '', + appLinkData: $s->appLinkData ?? '', + user: $this->passengerName, + vehicleNumber: $this->flightHeader->flightNumber, + departCode: $this->origin->airportIataCode, + arriveCode: $this->destination->airportIataCode, + departTerminal: $this->origin->terminal, + arriveTerminal: $this->destination->terminal, + gate: $this->origin->gate, + reservationNumber: $this->reservationInfo->confirmationCode, + barcode: $this->primarySamsungBarcode(), + ); + $samsungCard = $this->createSamsungCard(CardTypeEnum::BOARDING_PASS, CardSubTypeEnum::AIRLINES, $attributes); + } + + return new BuiltWalletPass($applePass, $googlePair, $samsungCard); } } diff --git a/src/Builder/Generic/GenericWalletBuilder.php b/src/Builder/Generic/GenericWalletBuilder.php index 958768e..9fea998 100644 --- a/src/Builder/Generic/GenericWalletBuilder.php +++ b/src/Builder/Generic/GenericWalletBuilder.php @@ -15,6 +15,9 @@ use Jolicode\WalletKit\Pass\Android\Model\Generic\GenericTypeEnum; use Jolicode\WalletKit\Pass\Apple\Model\PassStructure; use Jolicode\WalletKit\Pass\Apple\Model\PassTypeEnum; +use Jolicode\WalletKit\Pass\Samsung\Model\Generic\GenericAttributes; +use Jolicode\WalletKit\Pass\Samsung\Model\Shared\CardSubTypeEnum; +use Jolicode\WalletKit\Pass\Samsung\Model\Shared\CardTypeEnum; final class GenericWalletBuilder extends AbstractWalletBuilder { @@ -91,6 +94,24 @@ classId: $g->classId, $googlePair = new GoogleWalletPair(GoogleVerticalEnum::GENERIC, $googleClass, $googleObject); } - return new BuiltWalletPass($applePass, $googlePair); + $samsungCard = null; + if ($this->context->hasSamsung()) { + $s = $this->context->samsung; + $attributes = new GenericAttributes( + title: $this->googleCardTitle ?? 'Card', + providerName: $this->context->hasApple() ? $this->context->apple->organizationName : ($this->googleCardTitle ?? 'Card'), + startDate: (int) (microtime(true) * 1000), + noticeDesc: $this->context->hasApple() ? $this->context->apple->description : '', + appLinkLogo: $s->appLinkLogo ?? '', + appLinkName: $s->appLinkName ?? '', + appLinkData: $s->appLinkData ?? '', + bgColor: $this->resolvedSamsungHexColor(), + serial1: $this->primarySamsungBarcode(), + groupingId: $this->common->groupingIdentifier, + ); + $samsungCard = $this->createSamsungCard(CardTypeEnum::GENERIC, CardSubTypeEnum::OTHERS, $attributes); + } + + return new BuiltWalletPass($applePass, $googlePair, $samsungCard); } } diff --git a/src/Builder/GiftCard/GiftCardWalletBuilder.php b/src/Builder/GiftCard/GiftCardWalletBuilder.php index 247e6a5..84d7edd 100644 --- a/src/Builder/GiftCard/GiftCardWalletBuilder.php +++ b/src/Builder/GiftCard/GiftCardWalletBuilder.php @@ -14,6 +14,9 @@ use Jolicode\WalletKit\Pass\Apple\Model\Field; use Jolicode\WalletKit\Pass\Apple\Model\PassStructure; use Jolicode\WalletKit\Pass\Apple\Model\PassTypeEnum; +use Jolicode\WalletKit\Pass\Samsung\Model\GiftCard\GiftCardAttributes; +use Jolicode\WalletKit\Pass\Samsung\Model\Shared\CardSubTypeEnum; +use Jolicode\WalletKit\Pass\Samsung\Model\Shared\CardTypeEnum; /** * Google Wallet gift cards map to Apple {@see PassTypeEnum::STORE_CARD} payloads; Apple has no dedicated gift-card pass type. @@ -84,6 +87,22 @@ classId: $g->classId, $googlePair = new GoogleWalletPair(GoogleVerticalEnum::GIFT_CARD, $giftClass, $giftObject); } - return new BuiltWalletPass($applePass, $googlePair); + $samsungCard = null; + if ($this->context->hasSamsung()) { + $s = $this->context->samsung; + $attributes = new GiftCardAttributes( + title: 'Gift Card', + providerName: $this->context->hasApple() ? $this->context->apple->organizationName : ($this->context->hasGoogle() ? $this->context->googleIssuerName() : ''), + appLinkLogo: $s->appLinkLogo ?? '', + appLinkName: $s->appLinkName ?? '', + appLinkData: $s->appLinkData ?? '', + barcode: $this->primarySamsungBarcode(), + bgColor: $this->resolvedSamsungHexColor(), + amount: $this->cardNumber, + ); + $samsungCard = $this->createSamsungCard(CardTypeEnum::GIFT_CARD, CardSubTypeEnum::OTHERS, $attributes); + } + + return new BuiltWalletPass($applePass, $googlePair, $samsungCard); } } diff --git a/src/Builder/Internal/CommonWalletState.php b/src/Builder/Internal/CommonWalletState.php index 10ebf43..21d4192 100644 --- a/src/Builder/Internal/CommonWalletState.php +++ b/src/Builder/Internal/CommonWalletState.php @@ -12,6 +12,7 @@ use Jolicode\WalletKit\Pass\Android\Model\Shared\TimeInterval; use Jolicode\WalletKit\Pass\Apple\Model\Barcode; use Jolicode\WalletKit\Pass\Apple\Model\Pass; +use Jolicode\WalletKit\Pass\Samsung\Model\Card; final class CommonWalletState { @@ -57,4 +58,7 @@ final class CommonWalletState /** @var callable(Pass): void|null */ public $applePassMutator; + + /** @var callable(Card): void|null */ + public $samsungCardMutator; } diff --git a/src/Builder/Internal/SamsungBarcodeMapper.php b/src/Builder/Internal/SamsungBarcodeMapper.php new file mode 100644 index 0000000..c7348e8 --- /dev/null +++ b/src/Builder/Internal/SamsungBarcodeMapper.php @@ -0,0 +1,38 @@ + $appleBarcodes + */ + public static function fromFirstAppleBarcode(array $appleBarcodes): ?SamsungBarcode + { + if ([] === $appleBarcodes) { + return null; + } + + return self::fromAppleBarcode($appleBarcodes[0]); + } + + public static function fromAppleBarcode(AppleBarcode $barcode): SamsungBarcode + { + $serialType = match ($barcode->format) { + BarcodeFormatEnum::QR => SerialTypeEnum::QRCODE, + BarcodeFormatEnum::PDF417, BarcodeFormatEnum::AZTEC, BarcodeFormatEnum::CODE_128 => SerialTypeEnum::BARCODE, + }; + + return new SamsungBarcode( + serialType: $serialType, + value: $barcode->message, + ); + } +} diff --git a/src/Builder/Internal/SamsungBoardingPassSubTypeMapper.php b/src/Builder/Internal/SamsungBoardingPassSubTypeMapper.php new file mode 100644 index 0000000..b4b579e --- /dev/null +++ b/src/Builder/Internal/SamsungBoardingPassSubTypeMapper.php @@ -0,0 +1,20 @@ + CardSubTypeEnum::BUSES, + TransitTypeEnum::RAIL, TransitTypeEnum::TRAM => CardSubTypeEnum::TRAINS, + TransitTypeEnum::FERRY, TransitTypeEnum::OTHER, TransitTypeEnum::UNSPECIFIED => CardSubTypeEnum::OTHERS, + }; + } +} diff --git a/src/Builder/Loyalty/LoyaltyWalletBuilder.php b/src/Builder/Loyalty/LoyaltyWalletBuilder.php index 33e642e..f28f5f5 100644 --- a/src/Builder/Loyalty/LoyaltyWalletBuilder.php +++ b/src/Builder/Loyalty/LoyaltyWalletBuilder.php @@ -14,6 +14,9 @@ use Jolicode\WalletKit\Pass\Apple\Model\Field; use Jolicode\WalletKit\Pass\Apple\Model\PassStructure; use Jolicode\WalletKit\Pass\Apple\Model\PassTypeEnum; +use Jolicode\WalletKit\Pass\Samsung\Model\Loyalty\LoyaltyAttributes; +use Jolicode\WalletKit\Pass\Samsung\Model\Shared\CardSubTypeEnum; +use Jolicode\WalletKit\Pass\Samsung\Model\Shared\CardTypeEnum; final class LoyaltyWalletBuilder extends AbstractWalletBuilder { @@ -82,6 +85,22 @@ classId: $g->classId, $googlePair = new GoogleWalletPair(GoogleVerticalEnum::LOYALTY, $loyaltyClass, $loyaltyObject); } - return new BuiltWalletPass($applePass, $googlePair); + $samsungCard = null; + if ($this->context->hasSamsung()) { + $s = $this->context->samsung; + $attributes = new LoyaltyAttributes( + title: $this->programName ?? 'Loyalty', + providerName: $this->context->hasApple() ? $this->context->apple->organizationName : ($this->context->hasGoogle() ? $this->context->googleIssuerName() : ''), + appLinkLogo: $s->appLinkLogo ?? '', + appLinkName: $s->appLinkName ?? '', + appLinkData: $s->appLinkData ?? '', + barcode: $this->primarySamsungBarcode(), + bgColor: $this->resolvedSamsungHexColor(), + merchantName: $this->programName, + ); + $samsungCard = $this->createSamsungCard(CardTypeEnum::LOYALTY, CardSubTypeEnum::OTHERS, $attributes); + } + + return new BuiltWalletPass($applePass, $googlePair, $samsungCard); } } diff --git a/src/Builder/Offer/OfferWalletBuilder.php b/src/Builder/Offer/OfferWalletBuilder.php index 3b7ed9e..10d6517 100644 --- a/src/Builder/Offer/OfferWalletBuilder.php +++ b/src/Builder/Offer/OfferWalletBuilder.php @@ -15,6 +15,9 @@ use Jolicode\WalletKit\Pass\Apple\Model\Field; use Jolicode\WalletKit\Pass\Apple\Model\PassStructure; use Jolicode\WalletKit\Pass\Apple\Model\PassTypeEnum; +use Jolicode\WalletKit\Pass\Samsung\Model\Coupon\CouponAttributes; +use Jolicode\WalletKit\Pass\Samsung\Model\Shared\CardSubTypeEnum; +use Jolicode\WalletKit\Pass\Samsung\Model\Shared\CardTypeEnum; final class OfferWalletBuilder extends AbstractWalletBuilder { @@ -71,6 +74,24 @@ classId: $g->classId, $googlePair = new GoogleWalletPair(GoogleVerticalEnum::OFFER, $offerClass, $offerObject); } - return new BuiltWalletPass($applePass, $googlePair); + $samsungCard = null; + if ($this->context->hasSamsung()) { + $s = $this->context->samsung; + $now = (int) (microtime(true) * 1000); + $attributes = new CouponAttributes( + title: $this->title, + appLinkLogo: $s->appLinkLogo ?? '', + appLinkName: $s->appLinkName ?? '', + appLinkData: $s->appLinkData ?? '', + issueDate: $now, + expiry: $now + 86400000 * 365, + brandName: $this->provider, + barcode: $this->primarySamsungBarcode(), + bgColor: $this->resolvedSamsungHexColor(), + ); + $samsungCard = $this->createSamsungCard(CardTypeEnum::COUPON, CardSubTypeEnum::OTHERS, $attributes); + } + + return new BuiltWalletPass($applePass, $googlePair, $samsungCard); } } diff --git a/src/Builder/SamsungWalletContext.php b/src/Builder/SamsungWalletContext.php new file mode 100644 index 0000000..2bf1b4b --- /dev/null +++ b/src/Builder/SamsungWalletContext.php @@ -0,0 +1,17 @@ +classId, $googlePair = new GoogleWalletPair(GoogleVerticalEnum::TRANSIT, $transitClass, $transitObject); } - return new BuiltWalletPass($applePass, $googlePair); + $samsungCard = null; + if ($this->context->hasSamsung()) { + $s = $this->context->samsung; + $samsungSubType = \Jolicode\WalletKit\Builder\Internal\SamsungBoardingPassSubTypeMapper::fromTransitType($this->googleTransitType); + $attributes = new BoardingPassAttributes( + title: 'Transit', + providerName: $this->context->hasApple() ? $this->context->apple->organizationName : ($this->context->hasGoogle() ? $this->context->googleIssuerName() : ''), + bgColor: $this->resolvedSamsungHexColor() ?? '#000000', + appLinkLogo: $s->appLinkLogo ?? '', + appLinkName: $s->appLinkName ?? '', + appLinkData: $s->appLinkData ?? '', + reservationNumber: $this->ticketNumber, + barcode: $this->primarySamsungBarcode(), + ); + $samsungCard = $this->createSamsungCard(CardTypeEnum::BOARDING_PASS, $samsungSubType, $attributes); + } + + return new BuiltWalletPass($applePass, $googlePair, $samsungCard); } private function appleTransitType(): TransitTypeEnum diff --git a/src/Builder/WalletPlatformContext.php b/src/Builder/WalletPlatformContext.php index 25eece3..5cf5c1d 100644 --- a/src/Builder/WalletPlatformContext.php +++ b/src/Builder/WalletPlatformContext.php @@ -5,30 +5,20 @@ namespace Jolicode\WalletKit\Builder; use Jolicode\WalletKit\Exception\GooglePlatformContextRequiredException; -use Jolicode\WalletKit\Exception\InvalidWalletPlatformContextException; use Jolicode\WalletKit\Exception\WalletKitInvariantViolationException; use Jolicode\WalletKit\Pass\Android\Model\Shared\ReviewStatusEnum; use Jolicode\WalletKit\Pass\Android\Model\Shared\StateEnum; /** - * Shared identifiers and defaults for wallet payloads. At least one of {@see $apple} or {@see $google} must be set. + * Shared identifiers and defaults for wallet payloads. At least one of {@see $apple}, {@see $google}, or {@see $samsung} must be set before passing to a builder. */ final class WalletPlatformContext { public function __construct( public readonly ?AppleWalletContext $apple = null, public readonly ?GoogleWalletContext $google = null, + public readonly ?SamsungWalletContext $samsung = null, ) { - if (null === $this->apple && null === $this->google) { - throw InvalidWalletPlatformContextException::missingPlatformSlice(); - } - - if (null !== $this->google && null === $this->apple) { - $issuer = $this->google->issuerName; - if (null === $issuer || '' === $issuer) { - throw InvalidWalletPlatformContextException::googleIssuerNameRequiredWhenAppleAbsent(); - } - } } public function hasApple(): bool @@ -41,11 +31,16 @@ public function hasGoogle(): bool return null !== $this->google; } + public function hasSamsung(): bool + { + return null !== $this->samsung; + } + /** * Issuer name for Google Wallet *Class* resources. Uses {@see GoogleWalletContext::$issuerName} when set; otherwise Apple {@see AppleWalletContext::$organizationName}. * * @throws GooglePlatformContextRequiredException when there is no Google context (callers should guard with {@see hasGoogle()}) - * @throws WalletKitInvariantViolationException if issuer cannot be resolved despite constructor rules + * @throws WalletKitInvariantViolationException if issuer cannot be resolved */ public function googleIssuerName(): string { @@ -61,75 +56,68 @@ public function googleIssuerName(): string return $this->apple->organizationName; } - throw new WalletKitInvariantViolationException('Google issuer name is missing; this should have been rejected in the constructor.'); + throw new WalletKitInvariantViolationException('Google issuer name is missing and no Apple context is available to fall back on.'); } - /** - * Same parameters as the historical single constructor: dual-platform context with issuer mirrored from Apple organization name. - */ - public static function both( - string $appleTeamIdentifier, - string $applePassTypeIdentifier, - string $appleSerialNumber, - string $appleOrganizationName, - string $appleDescription, - string $googleClassId, - string $googleObjectId, - int $appleFormatVersion = 1, - ReviewStatusEnum $defaultGoogleReviewStatus = ReviewStatusEnum::DRAFT, - StateEnum $defaultGoogleObjectState = StateEnum::ACTIVE, + public function withApple( + string $teamIdentifier, + string $passTypeIdentifier, + string $serialNumber, + string $organizationName, + string $description, + int $formatVersion = 1, ): self { - $apple = new AppleWalletContext( - teamIdentifier: $appleTeamIdentifier, - passTypeIdentifier: $applePassTypeIdentifier, - serialNumber: $appleSerialNumber, - organizationName: $appleOrganizationName, - description: $appleDescription, - formatVersion: $appleFormatVersion, - ); - - $google = new GoogleWalletContext( - classId: $googleClassId, - objectId: $googleObjectId, - defaultReviewStatus: $defaultGoogleReviewStatus, - defaultObjectState: $defaultGoogleObjectState, - issuerName: $appleOrganizationName, + return new self( + new AppleWalletContext( + teamIdentifier: $teamIdentifier, + passTypeIdentifier: $passTypeIdentifier, + serialNumber: $serialNumber, + organizationName: $organizationName, + description: $description, + formatVersion: $formatVersion, + ), + $this->google, + $this->samsung, ); - - return new self($apple, $google); } - public static function appleOnly( - string $appleTeamIdentifier, - string $applePassTypeIdentifier, - string $appleSerialNumber, - string $appleOrganizationName, - string $appleDescription, - int $appleFormatVersion = 1, + public function withGoogle( + string $classId, + string $objectId, + ReviewStatusEnum $defaultReviewStatus = ReviewStatusEnum::DRAFT, + StateEnum $defaultObjectState = StateEnum::ACTIVE, + ?string $issuerName = null, ): self { - return new self(new AppleWalletContext( - teamIdentifier: $appleTeamIdentifier, - passTypeIdentifier: $applePassTypeIdentifier, - serialNumber: $appleSerialNumber, - organizationName: $appleOrganizationName, - description: $appleDescription, - formatVersion: $appleFormatVersion, - ), null); + return new self( + $this->apple, + new GoogleWalletContext( + classId: $classId, + objectId: $objectId, + defaultReviewStatus: $defaultReviewStatus, + defaultObjectState: $defaultObjectState, + issuerName: $issuerName, + ), + $this->samsung, + ); } - public static function googleOnly( - string $googleClassId, - string $googleObjectId, - string $issuerName, - ReviewStatusEnum $defaultGoogleReviewStatus = ReviewStatusEnum::DRAFT, - StateEnum $defaultGoogleObjectState = StateEnum::ACTIVE, + public function withSamsung( + string $refId, + string $language = 'en', + ?string $appLinkLogo = null, + ?string $appLinkName = null, + ?string $appLinkData = null, ): self { - return new self(null, new GoogleWalletContext( - classId: $googleClassId, - objectId: $googleObjectId, - defaultReviewStatus: $defaultGoogleReviewStatus, - defaultObjectState: $defaultGoogleObjectState, - issuerName: $issuerName, - )); + return new self( + $this->apple, + $this->google, + new SamsungWalletContext( + refId: $refId, + language: $language, + appLinkLogo: $appLinkLogo, + appLinkName: $appLinkName, + appLinkData: $appLinkData, + ), + ); } } diff --git a/src/Exception/InvalidWalletPlatformContextException.php b/src/Exception/InvalidWalletPlatformContextException.php index 4a06634..867d369 100644 --- a/src/Exception/InvalidWalletPlatformContextException.php +++ b/src/Exception/InvalidWalletPlatformContextException.php @@ -8,7 +8,7 @@ final class InvalidWalletPlatformContextException extends \InvalidArgumentExcept { public static function missingPlatformSlice(): self { - return new self('WalletPlatformContext requires at least one of apple or google.'); + return new self('WalletPlatformContext requires at least one of apple, google, or samsung.'); } public static function googleIssuerNameRequiredWhenAppleAbsent(): self diff --git a/src/Exception/SamsungCardNotAvailableException.php b/src/Exception/SamsungCardNotAvailableException.php new file mode 100644 index 0000000..1c9e5f4 --- /dev/null +++ b/src/Exception/SamsungCardNotAvailableException.php @@ -0,0 +1,13 @@ +}} + */ +class Card +{ + /** + * @param list $data + */ + public function __construct( + public CardTypeEnum $type, + public CardSubTypeEnum $subType, + public array $data, + ) { + } +} diff --git a/src/Pass/Samsung/Model/CardData.php b/src/Pass/Samsung/Model/CardData.php new file mode 100644 index 0000000..23863a4 --- /dev/null +++ b/src/Pass/Samsung/Model/CardData.php @@ -0,0 +1,35 @@ +, localization?: list} + */ +class CardData +{ + /** + * @param list|null $localization + */ + public function __construct( + public string $refId, + public int $createdAt, + public int $updatedAt, + public string $language, + public BoardingPassAttributes|EventTicketAttributes|CouponAttributes|GiftCardAttributes|LoyaltyAttributes|GenericAttributes|DigitalIdAttributes|PayAsYouGoAttributes $attributes, + public ?array $localization = null, + ) { + } +} diff --git a/src/Pass/Samsung/Model/Coupon/CouponAttributes.php b/src/Pass/Samsung/Model/Coupon/CouponAttributes.php new file mode 100644 index 0000000..7fc5a86 --- /dev/null +++ b/src/Pass/Samsung/Model/Coupon/CouponAttributes.php @@ -0,0 +1,62 @@ +, + * preventCaptureYn?: string, + * noNetworkSupportYn?: string, + * reactivatableYn?: string, + * } + */ +class EventTicketAttributes +{ + /** + * @param list|null $locations + */ + public function __construct( + public string $title, + public string $providerName, + public int $issueDate, + public string $reservationNumber, + public int $startDate, + public string $noticeDesc, + public string $appLinkLogo, + public string $appLinkName, + public string $appLinkData, + public ?string $mainImg = null, + public ?SamsungImage $logoImage = null, + public ?int $endDate = null, + public ?string $holderName = null, + public ?string $grade = null, + public ?string $seatNumber = null, + public ?string $entrance = null, + public ?SamsungBarcode $barcode = null, + public ?string $bgColor = null, + public ?string $fontColor = null, + public ?array $locations = null, + public ?bool $preventCapture = null, + public ?bool $noNetworkSupport = null, + public ?bool $reactivatable = null, + ) { + } +} diff --git a/src/Pass/Samsung/Model/Generic/GenericAttributes.php b/src/Pass/Samsung/Model/Generic/GenericAttributes.php new file mode 100644 index 0000000..ad6b02a --- /dev/null +++ b/src/Pass/Samsung/Model/Generic/GenericAttributes.php @@ -0,0 +1,79 @@ +, + * preventCaptureYn?: string, + * noNetworkSupportYn?: string, + * privacyModeYn?: string, + * } + */ +class GenericAttributes +{ + /** + * @param list|null $locations + */ + public function __construct( + public string $title, + public string $providerName, + public int $startDate, + public string $noticeDesc, + public string $appLinkLogo, + public string $appLinkName, + public string $appLinkData, + public ?string $mainImg = null, + public ?string $subtitle = null, + public ?string $eventId = null, + public ?string $groupingId = null, + public ?int $endDate = null, + public ?SamsungImage $logoImage = null, + public ?string $coverImage = null, + public ?string $bgImage = null, + public ?string $bgColor = null, + public ?string $fontColor = null, + public ?string $blinkColor = null, + public ?SamsungBarcode $serial1 = null, + public ?SamsungBarcode $serial2 = null, + public ?string $csInfo = null, + public ?string $providerViewLink = null, + public ?array $locations = null, + public ?bool $preventCapture = null, + public ?bool $noNetworkSupport = null, + public ?bool $privacyMode = null, + ) { + } +} diff --git a/src/Pass/Samsung/Model/GiftCard/GiftCardAttributes.php b/src/Pass/Samsung/Model/GiftCard/GiftCardAttributes.php new file mode 100644 index 0000000..c720daa --- /dev/null +++ b/src/Pass/Samsung/Model/GiftCard/GiftCardAttributes.php @@ -0,0 +1,75 @@ +, + * preventCaptureYn?: string, + * } + */ +class GiftCardAttributes +{ + /** + * @param list|null $locations + */ + public function __construct( + public string $title, + public string $providerName, + public string $appLinkLogo, + public string $appLinkName, + public string $appLinkData, + public ?SamsungImage $logoImage = null, + public ?string $user = null, + public ?int $startDate = null, + public ?int $endDate = null, + public ?SamsungBarcode $barcode = null, + public ?string $bgColor = null, + public ?string $fontColor = null, + public ?string $bgImage = null, + public ?string $mainImg = null, + public ?string $blinkColor = null, + public ?string $noticeDesc = null, + public ?string $csInfo = null, + public ?string $merchantId = null, + public ?string $merchantName = null, + public ?string $amount = null, + public ?string $balance = null, + public ?string $summaryUrl = null, + public ?array $locations = null, + public ?bool $preventCapture = null, + ) { + } +} diff --git a/src/Pass/Samsung/Model/Localization.php b/src/Pass/Samsung/Model/Localization.php new file mode 100644 index 0000000..97715b0 --- /dev/null +++ b/src/Pass/Samsung/Model/Localization.php @@ -0,0 +1,26 @@ +} + */ +class Localization +{ + public function __construct( + public string $language, + public BoardingPassAttributes|EventTicketAttributes|CouponAttributes|GiftCardAttributes|LoyaltyAttributes|GenericAttributes|DigitalIdAttributes|PayAsYouGoAttributes $attributes, + ) { + } +} diff --git a/src/Pass/Samsung/Model/Loyalty/LoyaltyAttributes.php b/src/Pass/Samsung/Model/Loyalty/LoyaltyAttributes.php new file mode 100644 index 0000000..b91391e --- /dev/null +++ b/src/Pass/Samsung/Model/Loyalty/LoyaltyAttributes.php @@ -0,0 +1,71 @@ +, + * preventCaptureYn?: string, + * } + */ +class LoyaltyAttributes +{ + /** + * @param list|null $locations + */ + public function __construct( + public string $title, + public string $providerName, + public string $appLinkLogo, + public string $appLinkName, + public string $appLinkData, + public ?SamsungImage $logoImage = null, + public ?int $startDate = null, + public ?int $endDate = null, + public ?SamsungBarcode $barcode = null, + public ?string $bgColor = null, + public ?string $fontColor = null, + public ?string $bgImage = null, + public ?string $blinkColor = null, + public ?string $noticeDesc = null, + public ?string $csInfo = null, + public ?string $merchantId = null, + public ?string $merchantName = null, + public ?string $amount = null, + public ?string $balance = null, + public ?string $summaryUrl = null, + public ?array $locations = null, + public ?bool $preventCapture = null, + ) { + } +} diff --git a/src/Pass/Samsung/Model/PayAsYouGo/PayAsYouGoAttributes.php b/src/Pass/Samsung/Model/PayAsYouGo/PayAsYouGoAttributes.php new file mode 100644 index 0000000..52c9178 --- /dev/null +++ b/src/Pass/Samsung/Model/PayAsYouGo/PayAsYouGoAttributes.php @@ -0,0 +1,71 @@ +, + * preventCaptureYn?: string, + * } + */ +class PayAsYouGoAttributes +{ + /** + * @param list|null $locations + */ + public function __construct( + public string $title, + public string $noticeDesc, + public string $appLinkLogo, + public string $appLinkName, + public string $appLinkData, + public SamsungBarcode $barcode, + public ?string $subtitle1 = null, + public ?SamsungImage $logoImage = null, + public ?string $providerName = null, + public ?string $holderName = null, + public ?int $startDate = null, + public ?int $endDate = null, + public ?string $bgColor = null, + public ?string $fontColor = null, + public ?string $bgImage = null, + public ?string $blinkColor = null, + public ?string $csInfo = null, + public ?string $identifier = null, + public ?string $grade = null, + public ?string $summaryUrl = null, + public ?array $locations = null, + public ?bool $preventCapture = null, + ) { + } +} diff --git a/src/Pass/Samsung/Model/Shared/CardSubTypeEnum.php b/src/Pass/Samsung/Model/Shared/CardSubTypeEnum.php new file mode 100644 index 0000000..8d7ccc4 --- /dev/null +++ b/src/Pass/Samsung/Model/Shared/CardSubTypeEnum.php @@ -0,0 +1,26 @@ + $context + * + * @return BoardingPassAttributesType + */ + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + $data = [ + 'title' => $object->title, + 'providerName' => $object->providerName, + 'bgColor' => $object->bgColor, + 'appLinkLogo' => $object->appLinkLogo, + 'appLinkName' => $object->appLinkName, + 'appLinkData' => $object->appLinkData, + ]; + + if (null !== $object->providerLogo) { + $data['providerLogo'] = $this->normalizer->normalize($object->providerLogo, $format, $context); + } + + if (null !== $object->user) { + $data['user'] = $object->user; + } + + if (null !== $object->vehicleNumber) { + $data['vehicleNumber'] = $object->vehicleNumber; + } + + if (null !== $object->seatClass) { + $data['seatClass'] = $object->seatClass; + } + + if (null !== $object->seatNumber) { + $data['seatNumber'] = $object->seatNumber; + } + + if (null !== $object->reservationNumber) { + $data['reservationNumber'] = $object->reservationNumber; + } + + if (null !== $object->departName) { + $data['departName'] = $object->departName; + } + + if (null !== $object->departCode) { + $data['departCode'] = $object->departCode; + } + + if (null !== $object->departTerminal) { + $data['departTerminal'] = $object->departTerminal; + } + + if (null !== $object->arriveName) { + $data['arriveName'] = $object->arriveName; + } + + if (null !== $object->arriveCode) { + $data['arriveCode'] = $object->arriveCode; + } + + if (null !== $object->arriveTerminal) { + $data['arriveTerminal'] = $object->arriveTerminal; + } + + if (null !== $object->estimatedOrActualStartDate) { + $data['estimatedOrActualStartDate'] = $object->estimatedOrActualStartDate; + } + + if (null !== $object->estimatedOrActualEndDate) { + $data['estimatedOrActualEndDate'] = $object->estimatedOrActualEndDate; + } + + if (null !== $object->boardingTime) { + $data['boardingTime'] = $object->boardingTime; + } + + if (null !== $object->gateClosingTime) { + $data['gateClosingTime'] = $object->gateClosingTime; + } + + if (null !== $object->gate) { + $data['gate'] = $object->gate; + } + + if (null !== $object->boardingGroup) { + $data['boardingGroup'] = $object->boardingGroup; + } + + if (null !== $object->boardingSeqNo) { + $data['boardingSeqNo'] = $object->boardingSeqNo; + } + + if (null !== $object->baggageAllowance) { + $data['baggageAllowance'] = $object->baggageAllowance; + } + + if (null !== $object->barcode) { + $data['barcode'] = $this->normalizer->normalize($object->barcode, $format, $context); + } + + if (null !== $object->noticeDesc) { + $data['noticeDesc'] = $object->noticeDesc; + } + + if (null !== $object->preventCapture) { + $data['preventCaptureYn'] = $object->preventCapture ? 'Y' : 'N'; + } + + return $data; + } + + /** + * @param array $context + */ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof BoardingPassAttributes; + } + + /** @return array */ + public function getSupportedTypes(?string $format): array + { + return [BoardingPassAttributes::class => true]; + } +} diff --git a/src/Pass/Samsung/Normalizer/CardDataNormalizer.php b/src/Pass/Samsung/Normalizer/CardDataNormalizer.php new file mode 100644 index 0000000..e1e7cc1 --- /dev/null +++ b/src/Pass/Samsung/Normalizer/CardDataNormalizer.php @@ -0,0 +1,59 @@ + $context + * + * @return CardDataType + */ + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + $data = [ + 'refId' => $object->refId, + 'createdAt' => $object->createdAt, + 'updatedAt' => $object->updatedAt, + 'language' => $object->language, + 'attributes' => $this->normalizer->normalize($object->attributes, $format, $context), + ]; + + if (null !== $object->localization) { + $localization = []; + foreach ($object->localization as $entry) { + $localization[] = $this->normalizer->normalize($entry, $format, $context); + } + $data['localization'] = $localization; + } + + return $data; + } + + /** + * @param array $context + */ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof CardData; + } + + /** @return array */ + public function getSupportedTypes(?string $format): array + { + return [CardData::class => true]; + } +} diff --git a/src/Pass/Samsung/Normalizer/CardNormalizer.php b/src/Pass/Samsung/Normalizer/CardNormalizer.php new file mode 100644 index 0000000..c838f4e --- /dev/null +++ b/src/Pass/Samsung/Normalizer/CardNormalizer.php @@ -0,0 +1,54 @@ + $context + * + * @return CardEnvelopeType + */ + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + $data = []; + foreach ($object->data as $cardData) { + $data[] = $this->normalizer->normalize($cardData, $format, $context); + } + + return [ + 'card' => [ + 'type' => $object->type->value, + 'subType' => $object->subType->value, + 'data' => $data, + ], + ]; + } + + /** + * @param array $context + */ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof Card; + } + + /** @return array */ + public function getSupportedTypes(?string $format): array + { + return [Card::class => true]; + } +} diff --git a/src/Pass/Samsung/Normalizer/Coupon/CouponAttributesNormalizer.php b/src/Pass/Samsung/Normalizer/Coupon/CouponAttributesNormalizer.php new file mode 100644 index 0000000..9113f92 --- /dev/null +++ b/src/Pass/Samsung/Normalizer/Coupon/CouponAttributesNormalizer.php @@ -0,0 +1,108 @@ + $context + * + * @return CouponAttributesType + */ + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + $data = [ + 'title' => $object->title, + 'appLinkLogo' => $object->appLinkLogo, + 'appLinkName' => $object->appLinkName, + 'appLinkData' => $object->appLinkData, + 'issueDate' => $object->issueDate, + 'expiry' => $object->expiry, + ]; + + if (null !== $object->mainImg) { + $data['mainImg'] = $object->mainImg; + } + + if (null !== $object->logoImage) { + $data['logoImage'] = $this->normalizer->normalize($object->logoImage, $format, $context); + } + + if (null !== $object->brandName) { + $data['brandName'] = $object->brandName; + } + + if (null !== $object->noticeDesc) { + $data['noticeDesc'] = $object->noticeDesc; + } + + if (null !== $object->barcode) { + $data['barcode'] = $this->normalizer->normalize($object->barcode, $format, $context); + } + + if (null !== $object->bgColor) { + $data['bgColor'] = $object->bgColor; + } + + if (null !== $object->fontColor) { + $data['fontColor'] = $object->fontColor; + } + + if (null !== $object->balance) { + $data['balance'] = $object->balance; + } + + if (null !== $object->summaryUrl) { + $data['summaryUrl'] = $object->summaryUrl; + } + + if (null !== $object->editable) { + $data['editableYn'] = $object->editable ? 'Y' : 'N'; + } + + if (null !== $object->deletable) { + $data['deletableYn'] = $object->deletable ? 'Y' : 'N'; + } + + if (null !== $object->displayRedeemButton) { + $data['displayRedeemButtonYn'] = $object->displayRedeemButton ? 'Y' : 'N'; + } + + if (null !== $object->notification) { + $data['notificationYn'] = $object->notification ? 'Y' : 'N'; + } + + if (null !== $object->preventCapture) { + $data['preventCaptureYn'] = $object->preventCapture ? 'Y' : 'N'; + } + + return $data; + } + + /** + * @param array $context + */ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof CouponAttributes; + } + + /** @return array */ + public function getSupportedTypes(?string $format): array + { + return [CouponAttributes::class => true]; + } +} diff --git a/src/Pass/Samsung/Normalizer/DigitalId/DigitalIdAttributesNormalizer.php b/src/Pass/Samsung/Normalizer/DigitalId/DigitalIdAttributesNormalizer.php new file mode 100644 index 0000000..a0a92ed --- /dev/null +++ b/src/Pass/Samsung/Normalizer/DigitalId/DigitalIdAttributesNormalizer.php @@ -0,0 +1,148 @@ + $context + * + * @return DigitalIdAttributesType + */ + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + $data = [ + 'title' => $object->title, + 'holderName' => $object->holderName, + 'identifier' => $object->identifier, + 'issueDate' => $object->issueDate, + 'providerName' => $object->providerName, + 'csInfo' => $object->csInfo, + ]; + + if (null !== $object->logoImage) { + $data['logoImage'] = $this->normalizer->normalize($object->logoImage, $format, $context); + } + + if (null !== $object->secondHolderName) { + $data['secondHolderName'] = $object->secondHolderName; + } + + if (null !== $object->organization) { + $data['organization'] = $object->organization; + } + + if (null !== $object->position) { + $data['position'] = $object->position; + } + + if (null !== $object->idNumber) { + $data['idNumber'] = $object->idNumber; + } + + if (null !== $object->address) { + $data['address'] = $object->address; + } + + if (null !== $object->birthdate) { + $data['birthdate'] = $object->birthdate; + } + + if (null !== $object->gender) { + $data['gender'] = $object->gender; + } + + if (null !== $object->classification) { + $data['classification'] = $object->classification; + } + + if (null !== $object->expiry) { + $data['expiry'] = $object->expiry; + } + + if (null !== $object->issuerName) { + $data['issuerName'] = $object->issuerName; + } + + if (null !== $object->extraInfo) { + $data['extraInfo'] = $object->extraInfo; + } + + if (null !== $object->noticeDesc) { + $data['noticeDesc'] = $object->noticeDesc; + } + + if (null !== $object->barcode) { + $data['barcode'] = $this->normalizer->normalize($object->barcode, $format, $context); + } + + if (null !== $object->bgColor) { + $data['bgColor'] = $object->bgColor; + } + + if (null !== $object->fontColor) { + $data['fontColor'] = $object->fontColor; + } + + if (null !== $object->bgImage) { + $data['bgImage'] = $object->bgImage; + } + + if (null !== $object->coverImage) { + $data['coverImage'] = $object->coverImage; + } + + if (null !== $object->blinkColor) { + $data['blinkColor'] = $object->blinkColor; + } + + if (null !== $object->appLinkLogo) { + $data['appLinkLogo'] = $object->appLinkLogo; + } + + if (null !== $object->appLinkName) { + $data['appLinkName'] = $object->appLinkName; + } + + if (null !== $object->appLinkData) { + $data['appLinkData'] = $object->appLinkData; + } + + if (null !== $object->preventCapture) { + $data['preventCaptureYn'] = $object->preventCapture ? 'Y' : 'N'; + } + + if (null !== $object->privacyMode) { + $data['privacyModeYn'] = $object->privacyMode ? 'Y' : 'N'; + } + + return $data; + } + + /** + * @param array $context + */ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof DigitalIdAttributes; + } + + /** @return array */ + public function getSupportedTypes(?string $format): array + { + return [DigitalIdAttributes::class => true]; + } +} diff --git a/src/Pass/Samsung/Normalizer/EventTicket/EventTicketAttributesNormalizer.php b/src/Pass/Samsung/Normalizer/EventTicket/EventTicketAttributesNormalizer.php new file mode 100644 index 0000000..1d3eee3 --- /dev/null +++ b/src/Pass/Samsung/Normalizer/EventTicket/EventTicketAttributesNormalizer.php @@ -0,0 +1,115 @@ + $context + * + * @return EventTicketAttributesType + */ + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + $data = [ + 'title' => $object->title, + 'providerName' => $object->providerName, + 'issueDate' => $object->issueDate, + 'reservationNumber' => $object->reservationNumber, + 'startDate' => $object->startDate, + 'noticeDesc' => $object->noticeDesc, + 'appLinkLogo' => $object->appLinkLogo, + 'appLinkName' => $object->appLinkName, + 'appLinkData' => $object->appLinkData, + ]; + + if (null !== $object->mainImg) { + $data['mainImg'] = $object->mainImg; + } + + if (null !== $object->logoImage) { + $data['logoImage'] = $this->normalizer->normalize($object->logoImage, $format, $context); + } + + if (null !== $object->endDate) { + $data['endDate'] = $object->endDate; + } + + if (null !== $object->holderName) { + $data['holderName'] = $object->holderName; + } + + if (null !== $object->grade) { + $data['grade'] = $object->grade; + } + + if (null !== $object->seatNumber) { + $data['seatNumber'] = $object->seatNumber; + } + + if (null !== $object->entrance) { + $data['entrance'] = $object->entrance; + } + + if (null !== $object->barcode) { + $data['barcode'] = $this->normalizer->normalize($object->barcode, $format, $context); + } + + if (null !== $object->bgColor) { + $data['bgColor'] = $object->bgColor; + } + + if (null !== $object->fontColor) { + $data['fontColor'] = $object->fontColor; + } + + if (null !== $object->locations) { + $locations = []; + foreach ($object->locations as $location) { + $locations[] = $this->normalizer->normalize($location, $format, $context); + } + $data['locations'] = $locations; + } + + if (null !== $object->preventCapture) { + $data['preventCaptureYn'] = $object->preventCapture ? 'Y' : 'N'; + } + + if (null !== $object->noNetworkSupport) { + $data['noNetworkSupportYn'] = $object->noNetworkSupport ? 'Y' : 'N'; + } + + if (null !== $object->reactivatable) { + $data['reactivatableYn'] = $object->reactivatable ? 'Y' : 'N'; + } + + return $data; + } + + /** + * @param array $context + */ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof EventTicketAttributes; + } + + /** @return array */ + public function getSupportedTypes(?string $format): array + { + return [EventTicketAttributes::class => true]; + } +} diff --git a/src/Pass/Samsung/Normalizer/Generic/GenericAttributesNormalizer.php b/src/Pass/Samsung/Normalizer/Generic/GenericAttributesNormalizer.php new file mode 100644 index 0000000..91390a1 --- /dev/null +++ b/src/Pass/Samsung/Normalizer/Generic/GenericAttributesNormalizer.php @@ -0,0 +1,133 @@ + $context + * + * @return GenericAttributesType + */ + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + $data = [ + 'title' => $object->title, + 'providerName' => $object->providerName, + 'startDate' => $object->startDate, + 'noticeDesc' => $object->noticeDesc, + 'appLinkLogo' => $object->appLinkLogo, + 'appLinkName' => $object->appLinkName, + 'appLinkData' => $object->appLinkData, + ]; + + if (null !== $object->mainImg) { + $data['mainImg'] = $object->mainImg; + } + + if (null !== $object->subtitle) { + $data['subtitle'] = $object->subtitle; + } + + if (null !== $object->eventId) { + $data['eventId'] = $object->eventId; + } + + if (null !== $object->groupingId) { + $data['groupingId'] = $object->groupingId; + } + + if (null !== $object->endDate) { + $data['endDate'] = $object->endDate; + } + + if (null !== $object->logoImage) { + $data['logoImage'] = $this->normalizer->normalize($object->logoImage, $format, $context); + } + + if (null !== $object->coverImage) { + $data['coverImage'] = $object->coverImage; + } + + if (null !== $object->bgImage) { + $data['bgImage'] = $object->bgImage; + } + + if (null !== $object->bgColor) { + $data['bgColor'] = $object->bgColor; + } + + if (null !== $object->fontColor) { + $data['fontColor'] = $object->fontColor; + } + + if (null !== $object->blinkColor) { + $data['blinkColor'] = $object->blinkColor; + } + + if (null !== $object->serial1) { + $data['serial1'] = $this->normalizer->normalize($object->serial1, $format, $context); + } + + if (null !== $object->serial2) { + $data['serial2'] = $this->normalizer->normalize($object->serial2, $format, $context); + } + + if (null !== $object->csInfo) { + $data['csInfo'] = $object->csInfo; + } + + if (null !== $object->providerViewLink) { + $data['providerViewLink'] = $object->providerViewLink; + } + + if (null !== $object->locations) { + $locations = []; + foreach ($object->locations as $location) { + $locations[] = $this->normalizer->normalize($location, $format, $context); + } + $data['locations'] = $locations; + } + + if (null !== $object->preventCapture) { + $data['preventCaptureYn'] = $object->preventCapture ? 'Y' : 'N'; + } + + if (null !== $object->noNetworkSupport) { + $data['noNetworkSupportYn'] = $object->noNetworkSupport ? 'Y' : 'N'; + } + + if (null !== $object->privacyMode) { + $data['privacyModeYn'] = $object->privacyMode ? 'Y' : 'N'; + } + + return $data; + } + + /** + * @param array $context + */ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof GenericAttributes; + } + + /** @return array */ + public function getSupportedTypes(?string $format): array + { + return [GenericAttributes::class => true]; + } +} diff --git a/src/Pass/Samsung/Normalizer/GiftCard/GiftCardAttributesNormalizer.php b/src/Pass/Samsung/Normalizer/GiftCard/GiftCardAttributesNormalizer.php new file mode 100644 index 0000000..92f48f7 --- /dev/null +++ b/src/Pass/Samsung/Normalizer/GiftCard/GiftCardAttributesNormalizer.php @@ -0,0 +1,131 @@ + $context + * + * @return GiftCardAttributesType + */ + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + $data = [ + 'title' => $object->title, + 'providerName' => $object->providerName, + 'appLinkLogo' => $object->appLinkLogo, + 'appLinkName' => $object->appLinkName, + 'appLinkData' => $object->appLinkData, + ]; + + if (null !== $object->logoImage) { + $data['logoImage'] = $this->normalizer->normalize($object->logoImage, $format, $context); + } + + if (null !== $object->user) { + $data['user'] = $object->user; + } + + if (null !== $object->startDate) { + $data['startDate'] = $object->startDate; + } + + if (null !== $object->endDate) { + $data['endDate'] = $object->endDate; + } + + if (null !== $object->barcode) { + $data['barcode'] = $this->normalizer->normalize($object->barcode, $format, $context); + } + + if (null !== $object->bgColor) { + $data['bgColor'] = $object->bgColor; + } + + if (null !== $object->fontColor) { + $data['fontColor'] = $object->fontColor; + } + + if (null !== $object->bgImage) { + $data['bgImage'] = $object->bgImage; + } + + if (null !== $object->mainImg) { + $data['mainImg'] = $object->mainImg; + } + + if (null !== $object->blinkColor) { + $data['blinkColor'] = $object->blinkColor; + } + + if (null !== $object->noticeDesc) { + $data['noticeDesc'] = $object->noticeDesc; + } + + if (null !== $object->csInfo) { + $data['csInfo'] = $object->csInfo; + } + + if (null !== $object->merchantId) { + $data['merchantId'] = $object->merchantId; + } + + if (null !== $object->merchantName) { + $data['merchantName'] = $object->merchantName; + } + + if (null !== $object->amount) { + $data['amount'] = $object->amount; + } + + if (null !== $object->balance) { + $data['balance'] = $object->balance; + } + + if (null !== $object->summaryUrl) { + $data['summaryUrl'] = $object->summaryUrl; + } + + if (null !== $object->locations) { + $locations = []; + foreach ($object->locations as $location) { + $locations[] = $this->normalizer->normalize($location, $format, $context); + } + $data['locations'] = $locations; + } + + if (null !== $object->preventCapture) { + $data['preventCaptureYn'] = $object->preventCapture ? 'Y' : 'N'; + } + + return $data; + } + + /** + * @param array $context + */ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof GiftCardAttributes; + } + + /** @return array */ + public function getSupportedTypes(?string $format): array + { + return [GiftCardAttributes::class => true]; + } +} diff --git a/src/Pass/Samsung/Normalizer/LocalizationNormalizer.php b/src/Pass/Samsung/Normalizer/LocalizationNormalizer.php new file mode 100644 index 0000000..2677360 --- /dev/null +++ b/src/Pass/Samsung/Normalizer/LocalizationNormalizer.php @@ -0,0 +1,46 @@ + $context + * + * @return LocalizationType + */ + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + return [ + 'language' => $object->language, + 'attributes' => $this->normalizer->normalize($object->attributes, $format, $context), + ]; + } + + /** + * @param array $context + */ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof Localization; + } + + /** @return array */ + public function getSupportedTypes(?string $format): array + { + return [Localization::class => true]; + } +} diff --git a/src/Pass/Samsung/Normalizer/Loyalty/LoyaltyAttributesNormalizer.php b/src/Pass/Samsung/Normalizer/Loyalty/LoyaltyAttributesNormalizer.php new file mode 100644 index 0000000..8f6be1c --- /dev/null +++ b/src/Pass/Samsung/Normalizer/Loyalty/LoyaltyAttributesNormalizer.php @@ -0,0 +1,123 @@ + $context + * + * @return LoyaltyAttributesType + */ + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + $data = [ + 'title' => $object->title, + 'providerName' => $object->providerName, + 'appLinkLogo' => $object->appLinkLogo, + 'appLinkName' => $object->appLinkName, + 'appLinkData' => $object->appLinkData, + ]; + + if (null !== $object->logoImage) { + $data['logoImage'] = $this->normalizer->normalize($object->logoImage, $format, $context); + } + + if (null !== $object->startDate) { + $data['startDate'] = $object->startDate; + } + + if (null !== $object->endDate) { + $data['endDate'] = $object->endDate; + } + + if (null !== $object->barcode) { + $data['barcode'] = $this->normalizer->normalize($object->barcode, $format, $context); + } + + if (null !== $object->bgColor) { + $data['bgColor'] = $object->bgColor; + } + + if (null !== $object->fontColor) { + $data['fontColor'] = $object->fontColor; + } + + if (null !== $object->bgImage) { + $data['bgImage'] = $object->bgImage; + } + + if (null !== $object->blinkColor) { + $data['blinkColor'] = $object->blinkColor; + } + + if (null !== $object->noticeDesc) { + $data['noticeDesc'] = $object->noticeDesc; + } + + if (null !== $object->csInfo) { + $data['csInfo'] = $object->csInfo; + } + + if (null !== $object->merchantId) { + $data['merchantId'] = $object->merchantId; + } + + if (null !== $object->merchantName) { + $data['merchantName'] = $object->merchantName; + } + + if (null !== $object->amount) { + $data['amount'] = $object->amount; + } + + if (null !== $object->balance) { + $data['balance'] = $object->balance; + } + + if (null !== $object->summaryUrl) { + $data['summaryUrl'] = $object->summaryUrl; + } + + if (null !== $object->locations) { + $locations = []; + foreach ($object->locations as $location) { + $locations[] = $this->normalizer->normalize($location, $format, $context); + } + $data['locations'] = $locations; + } + + if (null !== $object->preventCapture) { + $data['preventCaptureYn'] = $object->preventCapture ? 'Y' : 'N'; + } + + return $data; + } + + /** + * @param array $context + */ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof LoyaltyAttributes; + } + + /** @return array */ + public function getSupportedTypes(?string $format): array + { + return [LoyaltyAttributes::class => true]; + } +} diff --git a/src/Pass/Samsung/Normalizer/PayAsYouGo/PayAsYouGoAttributesNormalizer.php b/src/Pass/Samsung/Normalizer/PayAsYouGo/PayAsYouGoAttributesNormalizer.php new file mode 100644 index 0000000..57710b0 --- /dev/null +++ b/src/Pass/Samsung/Normalizer/PayAsYouGo/PayAsYouGoAttributesNormalizer.php @@ -0,0 +1,120 @@ + $context + * + * @return PayAsYouGoAttributesType + */ + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + $data = [ + 'title' => $object->title, + 'noticeDesc' => $object->noticeDesc, + 'appLinkLogo' => $object->appLinkLogo, + 'appLinkName' => $object->appLinkName, + 'appLinkData' => $object->appLinkData, + 'barcode' => $this->normalizer->normalize($object->barcode, $format, $context), + ]; + + if (null !== $object->subtitle1) { + $data['subtitle1'] = $object->subtitle1; + } + + if (null !== $object->logoImage) { + $data['logoImage'] = $this->normalizer->normalize($object->logoImage, $format, $context); + } + + if (null !== $object->providerName) { + $data['providerName'] = $object->providerName; + } + + if (null !== $object->holderName) { + $data['holderName'] = $object->holderName; + } + + if (null !== $object->startDate) { + $data['startDate'] = $object->startDate; + } + + if (null !== $object->endDate) { + $data['endDate'] = $object->endDate; + } + + if (null !== $object->bgColor) { + $data['bgColor'] = $object->bgColor; + } + + if (null !== $object->fontColor) { + $data['fontColor'] = $object->fontColor; + } + + if (null !== $object->bgImage) { + $data['bgImage'] = $object->bgImage; + } + + if (null !== $object->blinkColor) { + $data['blinkColor'] = $object->blinkColor; + } + + if (null !== $object->csInfo) { + $data['csInfo'] = $object->csInfo; + } + + if (null !== $object->identifier) { + $data['identifier'] = $object->identifier; + } + + if (null !== $object->grade) { + $data['grade'] = $object->grade; + } + + if (null !== $object->summaryUrl) { + $data['summaryUrl'] = $object->summaryUrl; + } + + if (null !== $object->locations) { + $locations = []; + foreach ($object->locations as $location) { + $locations[] = $this->normalizer->normalize($location, $format, $context); + } + $data['locations'] = $locations; + } + + if (null !== $object->preventCapture) { + $data['preventCaptureYn'] = $object->preventCapture ? 'Y' : 'N'; + } + + return $data; + } + + /** + * @param array $context + */ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof PayAsYouGoAttributes; + } + + /** @return array */ + public function getSupportedTypes(?string $format): array + { + return [PayAsYouGoAttributes::class => true]; + } +} diff --git a/src/Pass/Samsung/Normalizer/Shared/LocationNormalizer.php b/src/Pass/Samsung/Normalizer/Shared/LocationNormalizer.php new file mode 100644 index 0000000..4dadb36 --- /dev/null +++ b/src/Pass/Samsung/Normalizer/Shared/LocationNormalizer.php @@ -0,0 +1,52 @@ + $context + * + * @return SamsungLocationType + */ + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + $data = [ + 'lat' => $object->lat, + 'lng' => $object->lng, + ]; + + if (null !== $object->address) { + $data['address'] = $object->address; + } + + if (null !== $object->name) { + $data['name'] = $object->name; + } + + return $data; + } + + /** + * @param array $context + */ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof Location; + } + + /** @return array */ + public function getSupportedTypes(?string $format): array + { + return [Location::class => true]; + } +} diff --git a/src/Pass/Samsung/Normalizer/Shared/SamsungBarcodeNormalizer.php b/src/Pass/Samsung/Normalizer/Shared/SamsungBarcodeNormalizer.php new file mode 100644 index 0000000..87a678d --- /dev/null +++ b/src/Pass/Samsung/Normalizer/Shared/SamsungBarcodeNormalizer.php @@ -0,0 +1,59 @@ + $context + * + * @return SamsungBarcodeType + */ + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + $data = [ + 'serialType' => $object->serialType->value, + ]; + + if (null !== $object->value) { + $data['value'] = $object->value; + } + + if (null !== $object->ptFormat) { + $data['ptFormat'] = $object->ptFormat; + } + + if (null !== $object->ptSubFormat) { + $data['ptSubFormat'] = $object->ptSubFormat; + } + + if (null !== $object->pin) { + $data['pin'] = $object->pin; + } + + return $data; + } + + /** + * @param array $context + */ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof SamsungBarcode; + } + + /** @return array */ + public function getSupportedTypes(?string $format): array + { + return [SamsungBarcode::class => true]; + } +} diff --git a/src/Pass/Samsung/Normalizer/Shared/SamsungImageNormalizer.php b/src/Pass/Samsung/Normalizer/Shared/SamsungImageNormalizer.php new file mode 100644 index 0000000..4547747 --- /dev/null +++ b/src/Pass/Samsung/Normalizer/Shared/SamsungImageNormalizer.php @@ -0,0 +1,49 @@ + $context + * + * @return SamsungImageType + */ + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + $data = []; + + if (null !== $object->darkUrl) { + $data['darkUrl'] = $object->darkUrl; + } + + if (null !== $object->lightUrl) { + $data['lightUrl'] = $object->lightUrl; + } + + return $data; + } + + /** + * @param array $context + */ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof SamsungImage; + } + + /** @return array */ + public function getSupportedTypes(?string $format): array + { + return [SamsungImage::class => true]; + } +} diff --git a/tests/Builder/BuilderTestSerializerFactory.php b/tests/Builder/BuilderTestSerializerFactory.php index 3e209c6..8b0da7b 100644 --- a/tests/Builder/BuilderTestSerializerFactory.php +++ b/tests/Builder/BuilderTestSerializerFactory.php @@ -74,6 +74,20 @@ use Jolicode\WalletKit\Pass\Apple\Normalizer\SemanticTagType\SeatNormalizer; use Jolicode\WalletKit\Pass\Apple\Normalizer\SemanticTagType\SemanticLocationNormalizer; use Jolicode\WalletKit\Pass\Apple\Normalizer\SemanticTagType\WifiNetworkNormalizer; +use Jolicode\WalletKit\Pass\Samsung\Normalizer\BoardingPass\BoardingPassAttributesNormalizer as SamsungBoardingPassAttributesNormalizer; +use Jolicode\WalletKit\Pass\Samsung\Normalizer\CardDataNormalizer as SamsungCardDataNormalizer; +use Jolicode\WalletKit\Pass\Samsung\Normalizer\CardNormalizer as SamsungCardNormalizer; +use Jolicode\WalletKit\Pass\Samsung\Normalizer\Coupon\CouponAttributesNormalizer as SamsungCouponAttributesNormalizer; +use Jolicode\WalletKit\Pass\Samsung\Normalizer\DigitalId\DigitalIdAttributesNormalizer as SamsungDigitalIdAttributesNormalizer; +use Jolicode\WalletKit\Pass\Samsung\Normalizer\EventTicket\EventTicketAttributesNormalizer as SamsungEventTicketAttributesNormalizer; +use Jolicode\WalletKit\Pass\Samsung\Normalizer\Generic\GenericAttributesNormalizer as SamsungGenericAttributesNormalizer; +use Jolicode\WalletKit\Pass\Samsung\Normalizer\GiftCard\GiftCardAttributesNormalizer as SamsungGiftCardAttributesNormalizer; +use Jolicode\WalletKit\Pass\Samsung\Normalizer\LocalizationNormalizer as SamsungLocalizationNormalizer; +use Jolicode\WalletKit\Pass\Samsung\Normalizer\Loyalty\LoyaltyAttributesNormalizer as SamsungLoyaltyAttributesNormalizer; +use Jolicode\WalletKit\Pass\Samsung\Normalizer\PayAsYouGo\PayAsYouGoAttributesNormalizer as SamsungPayAsYouGoAttributesNormalizer; +use Jolicode\WalletKit\Pass\Samsung\Normalizer\Shared\LocationNormalizer as SamsungLocationNormalizer; +use Jolicode\WalletKit\Pass\Samsung\Normalizer\Shared\SamsungBarcodeNormalizer; +use Jolicode\WalletKit\Pass\Samsung\Normalizer\Shared\SamsungImageNormalizer; use Symfony\Component\Serializer\Serializer; final class BuilderTestSerializerFactory @@ -151,6 +165,20 @@ public static function create(): Serializer new ActivationStatusNormalizer(), new TransitClassNormalizer(), new TransitObjectNormalizer(), + new SamsungImageNormalizer(), + new SamsungBarcodeNormalizer(), + new SamsungLocationNormalizer(), + new SamsungBoardingPassAttributesNormalizer(), + new SamsungEventTicketAttributesNormalizer(), + new SamsungCouponAttributesNormalizer(), + new SamsungGiftCardAttributesNormalizer(), + new SamsungLoyaltyAttributesNormalizer(), + new SamsungGenericAttributesNormalizer(), + new SamsungDigitalIdAttributesNormalizer(), + new SamsungPayAsYouGoAttributesNormalizer(), + new SamsungLocalizationNormalizer(), + new SamsungCardDataNormalizer(), + new SamsungCardNormalizer(), ]); } } diff --git a/tests/Builder/DualWalletBuilderTest.php b/tests/Builder/DualWalletBuilderTest.php index ba31257..c984404 100644 --- a/tests/Builder/DualWalletBuilderTest.php +++ b/tests/Builder/DualWalletBuilderTest.php @@ -5,12 +5,11 @@ namespace Jolicode\WalletKit\Tests\Builder; use Jolicode\WalletKit\Builder\GoogleVerticalEnum; -use Jolicode\WalletKit\Builder\GoogleWalletContext; use Jolicode\WalletKit\Builder\WalletPass; use Jolicode\WalletKit\Builder\WalletPlatformContext; use Jolicode\WalletKit\Exception\ApplePassNotAvailableException; use Jolicode\WalletKit\Exception\GoogleWalletPairNotAvailableException; -use Jolicode\WalletKit\Exception\InvalidWalletPlatformContextException; +use Jolicode\WalletKit\Exception\SamsungCardNotAvailableException; use Jolicode\WalletKit\Pass\Android\Model\Flight\AirportInfo; use Jolicode\WalletKit\Pass\Android\Model\Flight\FlightCarrier; use Jolicode\WalletKit\Pass\Android\Model\Flight\FlightHeader; @@ -40,17 +39,20 @@ protected function setUp(): void private function context(): WalletPlatformContext { - return WalletPlatformContext::both( - appleTeamIdentifier: 'TEAM1', - applePassTypeIdentifier: 'pass.com.example.test', - appleSerialNumber: 'SN-001', - appleOrganizationName: 'Example Org', - appleDescription: 'Test pass', - googleClassId: '3388000000012345.test_class', - googleObjectId: '3388000000012345.test_object', - defaultGoogleReviewStatus: ReviewStatusEnum::APPROVED, - defaultGoogleObjectState: StateEnum::ACTIVE, - ); + return (new WalletPlatformContext()) + ->withApple( + teamIdentifier: 'TEAM1', + passTypeIdentifier: 'pass.com.example.test', + serialNumber: 'SN-001', + organizationName: 'Example Org', + description: 'Test pass', + ) + ->withGoogle( + classId: '3388000000012345.test_class', + objectId: '3388000000012345.test_object', + defaultReviewStatus: ReviewStatusEnum::APPROVED, + defaultObjectState: StateEnum::ACTIVE, + ); } public function testGenericBuildNormalizes(): void @@ -206,30 +208,14 @@ public function testGoogleBarcodeUsesFirstAppleBarcodeWhenMultiple(): void self::assertSame('first', $objectJson['barcode']['value']); } - public function testEmptyPlatformContextThrows(): void - { - $this->expectException(InvalidWalletPlatformContextException::class); - new WalletPlatformContext(null, null); - } - - public function testGoogleOnlyWithoutIssuerThrows(): void - { - $this->expectException(InvalidWalletPlatformContextException::class); - new WalletPlatformContext(null, new GoogleWalletContext( - classId: 'c', - objectId: 'o', - issuerName: null, - )); - } - public function testGoogleOnlyBuildAppleAccessorThrows(): void { - $ctx = WalletPlatformContext::googleOnly( - googleClassId: '3388000000012345.g_only_class', - googleObjectId: '3388000000012345.g_only_object', + $ctx = (new WalletPlatformContext())->withGoogle( + classId: '3388000000012345.g_only_class', + objectId: '3388000000012345.g_only_object', issuerName: 'Issuer Inc.', - defaultGoogleReviewStatus: ReviewStatusEnum::APPROVED, - defaultGoogleObjectState: StateEnum::ACTIVE, + defaultReviewStatus: ReviewStatusEnum::APPROVED, + defaultObjectState: StateEnum::ACTIVE, ); $built = WalletPass::generic($ctx) @@ -247,9 +233,9 @@ public function testGoogleOnlyBuildAppleAccessorThrows(): void public function testGoogleOnlyBuildGoogleNormalizes(): void { - $ctx = WalletPlatformContext::googleOnly( - googleClassId: '3388000000012345.g_only_class', - googleObjectId: '3388000000012345.g_only_object', + $ctx = (new WalletPlatformContext())->withGoogle( + classId: '3388000000012345.g_only_class', + objectId: '3388000000012345.g_only_object', issuerName: 'Issuer Inc.', ); @@ -267,12 +253,12 @@ public function testGoogleOnlyBuildGoogleNormalizes(): void public function testAppleOnlyBuildGoogleAccessorThrows(): void { - $ctx = WalletPlatformContext::appleOnly( - appleTeamIdentifier: 'TEAM1', - applePassTypeIdentifier: 'pass.com.example.test', - appleSerialNumber: 'SN-A', - appleOrganizationName: 'Example Org', - appleDescription: 'Apple only', + $ctx = (new WalletPlatformContext())->withApple( + teamIdentifier: 'TEAM1', + passTypeIdentifier: 'pass.com.example.test', + serialNumber: 'SN-A', + organizationName: 'Example Org', + description: 'Apple only', ); $built = WalletPass::generic($ctx) @@ -287,4 +273,175 @@ public function testAppleOnlyBuildGoogleAccessorThrows(): void $this->expectException(GoogleWalletPairNotAvailableException::class); $built->google(); } + + public function testSamsungOnlyContext(): void + { + $ctx = (new WalletPlatformContext())->withSamsung( + refId: 'ref-001', + appLinkLogo: 'https://example.com/logo.png', + appLinkName: 'App', + appLinkData: 'https://example.com', + ); + + self::assertTrue($ctx->hasSamsung()); + self::assertFalse($ctx->hasApple()); + self::assertFalse($ctx->hasGoogle()); + } + + public function testSamsungOnlyBuildOfferNormalizes(): void + { + $ctx = (new WalletPlatformContext())->withSamsung( + refId: 'ref-samsung-offer', + appLinkLogo: 'https://example.com/logo.png', + appLinkName: 'Shop', + appLinkData: 'https://example.com', + ); + + $built = WalletPass::offer($ctx, 'Summer sale', 'Example Provider', RedemptionChannelEnum::BOTH)->build(); + + $card = $built->samsung(); + self::assertSame('coupon', $card->type->value); + self::assertSame('others', $card->subType->value); + self::assertCount(1, $card->data); + self::assertSame('ref-samsung-offer', $card->data[0]->refId); + + $cardJson = $this->serializer->normalize($card); + self::assertSame('coupon', $cardJson['card']['type']); + self::assertSame('Summer sale', $cardJson['card']['data'][0]['attributes']['title']); + } + + public function testSamsungOnlyBuildAppleAccessorThrows(): void + { + $ctx = (new WalletPlatformContext())->withSamsung(refId: 'ref-001'); + + $built = WalletPass::generic($ctx)->build(); + + $this->expectException(ApplePassNotAvailableException::class); + $built->apple(); + } + + public function testSamsungOnlyBuildGoogleAccessorThrows(): void + { + $ctx = (new WalletPlatformContext())->withSamsung(refId: 'ref-001'); + + $built = WalletPass::generic($ctx)->build(); + + $this->expectException(GoogleWalletPairNotAvailableException::class); + $built->google(); + } + + public function testDualWithoutSamsungThrowsOnSamsungAccessor(): void + { + $built = WalletPass::generic($this->context())->build(); + + $this->expectException(SamsungCardNotAvailableException::class); + $built->samsung(); + } + + public function testAllPlatformsContext(): void + { + $ctx = (new WalletPlatformContext()) + ->withApple( + teamIdentifier: 'TEAM1', + passTypeIdentifier: 'pass.com.example.test', + serialNumber: 'SN-ALL', + organizationName: 'Example Org', + description: 'All platforms', + ) + ->withGoogle( + classId: '3388000000012345.all_class', + objectId: '3388000000012345.all_object', + ) + ->withSamsung( + refId: 'ref-all', + appLinkLogo: 'https://example.com/logo.png', + appLinkName: 'App', + appLinkData: 'https://example.com', + ); + + $built = WalletPass::offer($ctx, 'Deal', 'Shop', RedemptionChannelEnum::INSTORE)->build(); + + self::assertSame(PassTypeEnum::COUPON, $built->apple()->passType); + self::assertSame(GoogleVerticalEnum::OFFER, $built->googleVertical()); + self::assertSame('coupon', $built->samsung()->type->value); + + $samsungJson = $this->serializer->normalize($built->samsung()); + self::assertSame('Deal', $samsungJson['card']['data'][0]['attributes']['title']); + } + + public function testMutateSamsung(): void + { + $ctx = (new WalletPlatformContext())->withSamsung(refId: 'ref-mut'); + + $built = WalletPass::generic($ctx) + ->mutateSamsung(static function ($card): void { + $card->data[0]->refId = 'ref-mutated'; + }) + ->build(); + + self::assertSame('ref-mutated', $built->samsung()->data[0]->refId); + } + + public function testSamsungFlightBoardingPass(): void + { + $ctx = (new WalletPlatformContext())->withSamsung( + refId: 'ref-flight', + appLinkLogo: 'https://example.com/logo.png', + appLinkName: 'Airlines', + appLinkData: 'https://example.com', + ); + + $built = WalletPass::flight( + $ctx, + 'Pat Lee', + new ReservationInfo(confirmationCode: 'ABC'), + new FlightHeader(carrier: new FlightCarrier(carrierIataCode: 'ZZ'), flightNumber: '101'), + new AirportInfo(airportIataCode: 'SFO'), + new AirportInfo(airportIataCode: 'LAX'), + )->build(); + + $card = $built->samsung(); + self::assertSame('boardingpass', $card->type->value); + self::assertSame('airlines', $card->subType->value); + + $cardJson = $this->serializer->normalize($card); + $attrs = $cardJson['card']['data'][0]['attributes']; + self::assertSame('Pat Lee', $attrs['user']); + self::assertSame('SFO', $attrs['departCode']); + self::assertSame('LAX', $attrs['arriveCode']); + } + + public function testSamsungTransitBus(): void + { + $ctx = (new WalletPlatformContext())->withSamsung( + refId: 'ref-bus', + appLinkLogo: 'https://example.com/logo.png', + appLinkName: 'Transit', + appLinkData: 'https://example.com', + ); + + $built = WalletPass::transit($ctx, TransitTypeEnum::BUS, TripTypeEnum::ONE_WAY) + ->withTicketNumber('BUS-7') + ->build(); + + $card = $built->samsung(); + self::assertSame('boardingpass', $card->type->value); + self::assertSame('buses', $card->subType->value); + } + + public function testWithMethodsAreImmutable(): void + { + $base = new WalletPlatformContext(); + $withApple = $base->withApple( + teamIdentifier: 'TEAM1', + passTypeIdentifier: 'pass.com.example.test', + serialNumber: 'SN-1', + organizationName: 'Org', + description: 'Desc', + ); + + self::assertFalse($base->hasApple()); + self::assertTrue($withApple->hasApple()); + self::assertNotSame($base, $withApple); + } } diff --git a/tests/Pass/Samsung/Normalizer/SamsungNormalizerTest.php b/tests/Pass/Samsung/Normalizer/SamsungNormalizerTest.php new file mode 100644 index 0000000..5ddc024 --- /dev/null +++ b/tests/Pass/Samsung/Normalizer/SamsungNormalizerTest.php @@ -0,0 +1,311 @@ +serializer = new Serializer([ + new SamsungImageNormalizer(), + new SamsungBarcodeNormalizer(), + new LocationNormalizer(), + new BoardingPassAttributesNormalizer(), + new EventTicketAttributesNormalizer(), + new CouponAttributesNormalizer(), + new GiftCardAttributesNormalizer(), + new LoyaltyAttributesNormalizer(), + new GenericAttributesNormalizer(), + new LocalizationNormalizer(), + new CardDataNormalizer(), + new CardNormalizer(), + ]); + } + + public function testBoardingPassCard(): void + { + $barcode = new SamsungBarcode(SerialTypeEnum::QRCODE, 'BOARDING123', 'qrcode'); + $attributes = new BoardingPassAttributes( + title: 'Flight ZZ412', + providerName: 'Example Airlines', + bgColor: '#1E3C5A', + appLinkLogo: 'https://example.com/logo.png', + appLinkName: 'Example Airlines', + appLinkData: 'https://example.com', + providerLogo: new SamsungImage('https://example.com/dark.png', 'https://example.com/light.png'), + user: 'Jordan Smith', + vehicleNumber: 'ZZ412', + seatNumber: '14A', + departCode: 'CDG', + arriveCode: 'JFK', + reservationNumber: 'XK4P2Q', + barcode: $barcode, + preventCapture: true, + ); + + $card = new Card( + CardTypeEnum::BOARDING_PASS, + CardSubTypeEnum::AIRLINES, + [new CardData('ref-001', 1612660039000, 1612660039000, 'en', $attributes)], + ); + + $data = $this->serializer->normalize($card); + + self::assertSame('boardingpass', $data['card']['type']); + self::assertSame('airlines', $data['card']['subType']); + self::assertCount(1, $data['card']['data']); + + $cardData = $data['card']['data'][0]; + self::assertSame('ref-001', $cardData['refId']); + self::assertSame(1612660039000, $cardData['createdAt']); + self::assertSame('en', $cardData['language']); + + $attrs = $cardData['attributes']; + self::assertSame('Flight ZZ412', $attrs['title']); + self::assertSame('Example Airlines', $attrs['providerName']); + self::assertSame('#1E3C5A', $attrs['bgColor']); + self::assertSame('Jordan Smith', $attrs['user']); + self::assertSame('ZZ412', $attrs['vehicleNumber']); + self::assertSame('14A', $attrs['seatNumber']); + self::assertSame('CDG', $attrs['departCode']); + self::assertSame('JFK', $attrs['arriveCode']); + self::assertSame('XK4P2Q', $attrs['reservationNumber']); + self::assertSame('Y', $attrs['preventCaptureYn']); + + self::assertSame('QRCODE', $attrs['barcode']['serialType']); + self::assertSame('BOARDING123', $attrs['barcode']['value']); + + self::assertSame('https://example.com/dark.png', $attrs['providerLogo']['darkUrl']); + self::assertSame('https://example.com/light.png', $attrs['providerLogo']['lightUrl']); + } + + public function testEventTicketCard(): void + { + $attributes = new EventTicketAttributes( + title: 'Indie Fest 2026', + providerName: 'Festival Inc.', + issueDate: 1612660039000, + reservationNumber: 'EVT-001', + startDate: 1612660039000, + noticeDesc: 'Doors open at 6pm', + appLinkLogo: 'https://example.com/logo.png', + appLinkName: 'Festival App', + appLinkData: 'https://example.com', + holderName: 'Sam Rivera', + bgColor: '#FF6B35', + locations: [new Location(48.8566, 2.3522, '123 Rue Example', 'Venue')], + preventCapture: false, + noNetworkSupport: true, + ); + + $card = new Card( + CardTypeEnum::TICKET, + CardSubTypeEnum::PERFORMANCES, + [new CardData('ref-evt', 1612660039000, 1612660039000, 'en', $attributes)], + ); + + $data = $this->serializer->normalize($card); + + self::assertSame('ticket', $data['card']['type']); + self::assertSame('performances', $data['card']['subType']); + + $attrs = $data['card']['data'][0]['attributes']; + self::assertSame('Indie Fest 2026', $attrs['title']); + self::assertSame('Sam Rivera', $attrs['holderName']); + self::assertSame('#FF6B35', $attrs['bgColor']); + self::assertSame('N', $attrs['preventCaptureYn']); + self::assertSame('Y', $attrs['noNetworkSupportYn']); + self::assertCount(1, $attrs['locations']); + self::assertSame(48.8566, $attrs['locations'][0]['lat']); + self::assertSame('Venue', $attrs['locations'][0]['name']); + } + + public function testCouponCard(): void + { + $attributes = new CouponAttributes( + title: '20% off', + appLinkLogo: 'https://example.com/logo.png', + appLinkName: 'Shop App', + appLinkData: 'https://example.com', + issueDate: 1612660039000, + expiry: 1712660039000, + brandName: 'Example Coffee', + barcode: new SamsungBarcode(SerialTypeEnum::BARCODE, 'PROMO-2026'), + editable: false, + deletable: true, + displayRedeemButton: true, + notification: true, + ); + + $data = $this->serializer->normalize( + new Card(CardTypeEnum::COUPON, CardSubTypeEnum::OTHERS, [new CardData('ref-coup', 1612660039000, 1612660039000, 'en', $attributes)]) + ); + + $attrs = $data['card']['data'][0]['attributes']; + self::assertSame('coupon', $data['card']['type']); + self::assertSame('20% off', $attrs['title']); + self::assertSame('Example Coffee', $attrs['brandName']); + self::assertSame('N', $attrs['editableYn']); + self::assertSame('Y', $attrs['deletableYn']); + self::assertSame('Y', $attrs['displayRedeemButtonYn']); + self::assertSame('Y', $attrs['notificationYn']); + self::assertSame('BARCODE', $attrs['barcode']['serialType']); + } + + public function testGiftCardCard(): void + { + $attributes = new GiftCardAttributes( + title: 'Gift Card', + providerName: 'Example Store', + appLinkLogo: 'https://example.com/logo.png', + appLinkName: 'Store App', + appLinkData: 'https://example.com', + barcode: new SamsungBarcode(SerialTypeEnum::QRCODE, '6034932523842700'), + amount: '$50.00', + balance: '$42.50', + merchantName: 'Example Store', + ); + + $data = $this->serializer->normalize( + new Card(CardTypeEnum::GIFT_CARD, CardSubTypeEnum::OTHERS, [new CardData('ref-gift', 1612660039000, 1612660039000, 'en', $attributes)]) + ); + + $attrs = $data['card']['data'][0]['attributes']; + self::assertSame('giftcard', $data['card']['type']); + self::assertSame('Gift Card', $attrs['title']); + self::assertSame('$50.00', $attrs['amount']); + self::assertSame('$42.50', $attrs['balance']); + self::assertSame('Example Store', $attrs['merchantName']); + } + + public function testLoyaltyCard(): void + { + $attributes = new LoyaltyAttributes( + title: 'Gold Rewards', + providerName: 'Rewards Inc.', + appLinkLogo: 'https://example.com/logo.png', + appLinkName: 'Rewards App', + appLinkData: 'https://example.com', + barcode: new SamsungBarcode(SerialTypeEnum::QRCODE, 'GLD-991122'), + merchantName: 'Rewards Inc.', + balance: '1,250 pts', + ); + + $data = $this->serializer->normalize( + new Card(CardTypeEnum::LOYALTY, CardSubTypeEnum::OTHERS, [new CardData('ref-loy', 1612660039000, 1612660039000, 'en', $attributes)]) + ); + + $attrs = $data['card']['data'][0]['attributes']; + self::assertSame('loyalty', $data['card']['type']); + self::assertSame('Gold Rewards', $attrs['title']); + self::assertSame('1,250 pts', $attrs['balance']); + } + + public function testGenericCard(): void + { + $attributes = new GenericAttributes( + title: 'Membership Card', + providerName: 'Gym Corp', + startDate: 1612660039000, + noticeDesc: 'Valid for 1 year', + appLinkLogo: 'https://example.com/logo.png', + appLinkName: 'Gym App', + appLinkData: 'https://example.com', + subtitle: 'Gold Member', + groupingId: 'membership-2026', + serial1: new SamsungBarcode(SerialTypeEnum::QRCODE, 'MEM8842'), + privacyMode: true, + ); + + $data = $this->serializer->normalize( + new Card(CardTypeEnum::GENERIC, CardSubTypeEnum::OTHERS, [new CardData('ref-gen', 1612660039000, 1612660039000, 'en', $attributes)]) + ); + + $attrs = $data['card']['data'][0]['attributes']; + self::assertSame('generic', $data['card']['type']); + self::assertSame('Membership Card', $attrs['title']); + self::assertSame('Gold Member', $attrs['subtitle']); + self::assertSame('membership-2026', $attrs['groupingId']); + self::assertSame('Y', $attrs['privacyModeYn']); + self::assertSame('QRCODE', $attrs['serial1']['serialType']); + } + + public function testLocalization(): void + { + $enAttributes = new GenericAttributes( + title: 'Membership', + providerName: 'Gym', + startDate: 1612660039000, + noticeDesc: 'Valid', + appLinkLogo: '', + appLinkName: '', + appLinkData: '', + ); + + $frAttributes = new GenericAttributes( + title: 'AdhĂ©sion', + providerName: 'Gym', + startDate: 1612660039000, + noticeDesc: 'Valide', + appLinkLogo: '', + appLinkName: '', + appLinkData: '', + ); + + $card = new Card( + CardTypeEnum::GENERIC, + CardSubTypeEnum::OTHERS, + [new CardData( + 'ref-loc', + 1612660039000, + 1612660039000, + 'en', + $enAttributes, + [new Localization('fr', $frAttributes)], + )], + ); + + $data = $this->serializer->normalize($card); + $cardData = $data['card']['data'][0]; + + self::assertSame('Membership', $cardData['attributes']['title']); + self::assertArrayHasKey('localization', $cardData); + self::assertCount(1, $cardData['localization']); + self::assertSame('fr', $cardData['localization'][0]['language']); + self::assertSame('AdhĂ©sion', $cardData['localization'][0]['attributes']['title']); + } +} diff --git a/tools/spec/samsung-wallet-keyset.json b/tools/spec/samsung-wallet-keyset.json new file mode 100644 index 0000000..796b4c9 --- /dev/null +++ b/tools/spec/samsung-wallet-keyset.json @@ -0,0 +1,250 @@ +{ + "entities": { + "boardingPassAttributes": [ + "appLinkData", + "appLinkLogo", + "appLinkName", + "arriveCode", + "arriveName", + "arriveTerminal", + "baggageAllowance", + "barcode", + "bgColor", + "boardingGroup", + "boardingSeqNo", + "boardingTime", + "departCode", + "departName", + "departTerminal", + "estimatedOrActualEndDate", + "estimatedOrActualStartDate", + "gate", + "gateClosingTime", + "noticeDesc", + "preventCaptureYn", + "providerLogo", + "providerName", + "reservationNumber", + "seatClass", + "seatNumber", + "title", + "user", + "vehicleNumber" + ], + "card": [ + "card", + "data", + "subType" + ], + "cardData": [ + "attributes", + "createdAt", + "language", + "localization", + "refId", + "updatedAt" + ], + "couponAttributes": [ + "appLinkData", + "appLinkLogo", + "appLinkName", + "balance", + "barcode", + "bgColor", + "brandName", + "deletableYn", + "displayRedeemButtonYn", + "editableYn", + "expiry", + "fontColor", + "issueDate", + "logoImage", + "mainImg", + "noticeDesc", + "notificationYn", + "preventCaptureYn", + "summaryUrl", + "title" + ], + "digitalIdAttributes": [ + "address", + "appLinkData", + "appLinkLogo", + "appLinkName", + "barcode", + "bgColor", + "bgImage", + "birthdate", + "blinkColor", + "classification", + "coverImage", + "csInfo", + "expiry", + "extraInfo", + "fontColor", + "gender", + "holderName", + "idNumber", + "identifier", + "issueDate", + "issuerName", + "logoImage", + "noticeDesc", + "organization", + "position", + "preventCaptureYn", + "privacyModeYn", + "providerName", + "secondHolderName", + "title" + ], + "eventTicketAttributes": [ + "appLinkData", + "appLinkLogo", + "appLinkName", + "barcode", + "bgColor", + "endDate", + "entrance", + "fontColor", + "grade", + "holderName", + "issueDate", + "locations", + "logoImage", + "mainImg", + "noNetworkSupportYn", + "noticeDesc", + "preventCaptureYn", + "providerName", + "reactivatableYn", + "reservationNumber", + "seatNumber", + "startDate", + "title" + ], + "genericAttributes": [ + "appLinkData", + "appLinkLogo", + "appLinkName", + "bgColor", + "bgImage", + "blinkColor", + "coverImage", + "csInfo", + "endDate", + "eventId", + "fontColor", + "groupingId", + "locations", + "logoImage", + "mainImg", + "noNetworkSupportYn", + "noticeDesc", + "preventCaptureYn", + "privacyModeYn", + "providerName", + "providerViewLink", + "serial1", + "serial2", + "startDate", + "subtitle", + "title" + ], + "giftCardAttributes": [ + "amount", + "appLinkData", + "appLinkLogo", + "appLinkName", + "balance", + "barcode", + "bgColor", + "bgImage", + "blinkColor", + "csInfo", + "endDate", + "fontColor", + "locations", + "logoImage", + "mainImg", + "merchantId", + "merchantName", + "noticeDesc", + "preventCaptureYn", + "providerName", + "startDate", + "summaryUrl", + "title", + "user" + ], + "localization": [ + "attributes", + "language" + ], + "location": [ + "address", + "lat", + "lng", + "name" + ], + "loyaltyAttributes": [ + "amount", + "appLinkData", + "appLinkLogo", + "appLinkName", + "balance", + "barcode", + "bgColor", + "bgImage", + "blinkColor", + "csInfo", + "endDate", + "fontColor", + "locations", + "logoImage", + "merchantId", + "merchantName", + "noticeDesc", + "preventCaptureYn", + "providerName", + "startDate", + "summaryUrl", + "title" + ], + "payAsYouGoAttributes": [ + "appLinkData", + "appLinkLogo", + "appLinkName", + "barcode", + "bgColor", + "bgImage", + "blinkColor", + "csInfo", + "endDate", + "fontColor", + "grade", + "holderName", + "identifier", + "locations", + "logoImage", + "noticeDesc", + "preventCaptureYn", + "providerName", + "startDate", + "subtitle1", + "summaryUrl", + "title" + ], + "samsungBarcode": [ + "pin", + "ptFormat", + "ptSubFormat", + "serialType", + "value" + ], + "samsungImage": [ + "darkUrl", + "lightUrl" + ] + } +} diff --git a/tools/spec/samsung-wallet-keyset.php b/tools/spec/samsung-wallet-keyset.php new file mode 100644 index 0000000..90ec198 --- /dev/null +++ b/tools/spec/samsung-wallet-keyset.php @@ -0,0 +1,237 @@ +#!/usr/bin/env php + */ +$entities = [ + 'card' => ['Card.php', 'CardEnvelopeType'], + 'cardData' => ['CardData.php', 'CardDataType'], + 'localization' => ['Localization.php', 'LocalizationType'], + 'samsungImage' => ['Shared/SamsungImage.php', 'SamsungImageType'], + 'samsungBarcode' => ['Shared/SamsungBarcode.php', 'SamsungBarcodeType'], + 'location' => ['Shared/Location.php', 'SamsungLocationType'], + 'boardingPassAttributes' => ['BoardingPass/BoardingPassAttributes.php', 'BoardingPassAttributesType'], + 'eventTicketAttributes' => ['EventTicket/EventTicketAttributes.php', 'EventTicketAttributesType'], + 'couponAttributes' => ['Coupon/CouponAttributes.php', 'CouponAttributesType'], + 'giftCardAttributes' => ['GiftCard/GiftCardAttributes.php', 'GiftCardAttributesType'], + 'loyaltyAttributes' => ['Loyalty/LoyaltyAttributes.php', 'LoyaltyAttributesType'], + 'genericAttributes' => ['Generic/GenericAttributes.php', 'GenericAttributesType'], + 'digitalIdAttributes' => ['DigitalId/DigitalIdAttributes.php', 'DigitalIdAttributesType'], + 'payAsYouGoAttributes' => ['PayAsYouGo/PayAsYouGoAttributes.php', 'PayAsYouGoAttributesType'], +]; + +$generated = buildKeyset($modelDir, $entities); +$encoded = encodeKeyset($generated); + +if ($cmd === 'generate') { + echo $encoded; + exit(0); +} + +if ($cmd === 'baseline') { + if (file_put_contents($keysetPath, $encoded) === false) { + fwrite(STDERR, "Could not write {$keysetPath}\n"); + exit(2); + } + echo "Wrote {$keysetPath}\n"; + exit(0); +} + +// check +if (!is_file($keysetPath)) { + fwrite(STDERR, "Missing keyset file: {$keysetPath}\nRun: castor spec:baseline:samsung\n"); + exit(2); +} + +$baselineRaw = file_get_contents($keysetPath); +if ($baselineRaw === false) { + fwrite(STDERR, "Could not read {$keysetPath}\n"); + exit(2); +} + +try { + $baselineDecoded = json_decode($baselineRaw, true, 512, JSON_THROW_ON_ERROR); +} catch (\JsonException) { + fwrite(STDERR, "Invalid JSON in baseline.\n"); + exit(2); +} + +$normBaseline = normalizeKeyset($baselineDecoded); +$normGenerated = normalizeKeyset($generated); + +if ($normBaseline === $normGenerated) { + echo "OK: Samsung wallet keyset matches baseline.\n"; + exit(0); +} + +echo "Samsung wallet keyset mismatch (entities => sorted JSON keys from phpstan array shapes).\n"; +reportDiff($normBaseline, $normGenerated); +exit(1); + +/** + * @param array $entities + * + * @return array{entities: array>} + */ +function buildKeyset(string $modelDir, array $entities): array +{ + $out = ['entities' => []]; + foreach ($entities as $name => [$file, $typeName]) { + $path = $modelDir . '/' . $file; + if (!is_file($path)) { + throw new RuntimeException("Missing model file: {$path}"); + } + $content = file_get_contents($path); + if ($content === false) { + throw new RuntimeException("Could not read: {$path}"); + } + $keys = extractKeysFromPhpStanArrayShape($content, $typeName); + $out['entities'][$name] = $keys; + } + + ksort($out['entities']); + + return $out; +} + +/** + * @return list + */ +function extractKeysFromPhpStanArrayShape(string $content, string $typeName): array +{ + $pattern = '/@phpstan-type\s+' . preg_quote($typeName, '/') . '\s+array\s*\{/'; + if (!preg_match($pattern, $content, $m, PREG_OFFSET_CAPTURE)) { + throw new RuntimeException("Could not find @phpstan-type {$typeName} array{ in file."); + } + + $openBracePos = $m[0][1] + strlen($m[0][0]) - 1; + $body = extractBalancedBraceBody($content, $openBracePos); + $keys = []; + + foreach (preg_split('/\R/', $body) as $rawLine) { + $line = trim($rawLine); + $line = preg_replace('/^\*\s*/', '', $line); + if ($line === '' || str_starts_with($line, '//')) { + continue; + } + if (preg_match_all('/(?:^|,\s*)([a-zA-Z_][a-zA-Z0-9_]*)\??\s*:/', $line, $km)) { + foreach ($km[1] as $key) { + $keys[] = $key; + } + } + } + + $keys = array_values(array_unique($keys)); + sort($keys); + + return $keys; +} + +function extractBalancedBraceBody(string $content, int $openBracePos): string +{ + $len = strlen($content); + if ($openBracePos >= $len || $content[$openBracePos] !== '{') { + throw new RuntimeException('Expected { at array shape start.'); + } + + $depth = 0; + $bodyStart = $openBracePos + 1; + + for ($i = $openBracePos; $i < $len; $i++) { + $c = $content[$i]; + if ($c === '{') { + $depth++; + } elseif ($c === '}') { + $depth--; + if ($depth === 0) { + return substr($content, $bodyStart, $i - $bodyStart); + } + } + } + + throw new RuntimeException('Unclosed array shape braces.'); +} + +/** + * @param array{entities?: array} $data + * + * @return array{entities: array>} + */ +function normalizeKeyset(array $data): array +{ + $entities = $data['entities'] ?? []; + if (!\is_array($entities)) { + return ['entities' => []]; + } + + $out = ['entities' => []]; + foreach ($entities as $name => $keys) { + if (!\is_array($keys)) { + continue; + } + $list = []; + foreach ($keys as $k) { + if (\is_string($k)) { + $list[] = $k; + } + } + sort($list); + $out['entities'][(string) $name] = $list; + } + ksort($out['entities']); + + return $out; +} + +/** + * @param array{entities: array>} $baseline + * @param array{entities: array>} $generated + */ +function reportDiff(array $baseline, array $generated): void +{ + $allEntities = array_unique([...array_keys($baseline['entities']), ...array_keys($generated['entities'])]); + sort($allEntities); + + foreach ($allEntities as $entity) { + $b = $baseline['entities'][$entity] ?? []; + $g = $generated['entities'][$entity] ?? []; + if ($b === $g) { + continue; + } + echo "\n[{$entity}]\n"; + $onlyB = array_values(array_diff($b, $g)); + $onlyG = array_values(array_diff($g, $b)); + if ($onlyB !== []) { + echo ' Only in baseline JSON: ' . implode(', ', $onlyB) . "\n"; + } + if ($onlyG !== []) { + echo ' Only in generated (code): ' . implode(', ', $onlyG) . "\n"; + } + } +} + +/** + * @param array{entities: array>} $keyset + */ +function encodeKeyset(array $keyset): string +{ + return json_encode($keyset, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n"; +}