Skip to content

Commit e594252

Browse files
authored
Merge pull request #1 from jolicode/feature/add-samsung-wallet
2 parents 351526d + b940d57 commit e594252

56 files changed

Lines changed: 3723 additions & 172 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,49 +15,59 @@
1515

1616
## Overview
1717

18-
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.
18+
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.
1919

2020
- **PHP** 8.3+
2121
- **symfony/serializer** ^7.4 || ^8.0
2222

2323
## 🛠️ Builder
2424

25-
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.
25+
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.
2626

2727
**Cookbook** (single-store `appleOnly` / `googleOnly`, every vertical, shared options, exceptions): [docs/builder-examples.md](docs/builder-examples.md).
2828

29-
### Example — dual platform
29+
### Example — all platforms
3030

3131
```php
32-
$context = WalletPlatformContext::both(
33-
appleTeamIdentifier: 'ABCDE12345',
34-
applePassTypeIdentifier: 'pass.com.example.coupon',
35-
appleSerialNumber: 'COUPON-001',
36-
appleOrganizationName: 'Example Shop',
37-
appleDescription: 'Spring sale coupon',
38-
googleClassId: '3388000000012345.example_offer_class',
39-
googleObjectId: '3388000000012345.example_offer_object',
40-
defaultGoogleReviewStatus: ReviewStatusEnum::APPROVED,
41-
defaultGoogleObjectState: StateEnum::ACTIVE,
42-
);
32+
$context = (new WalletPlatformContext())
33+
->withApple(
34+
teamIdentifier: ‘ABCDE12345’,
35+
passTypeIdentifier: ‘pass.com.example.coupon’,
36+
serialNumber: ‘COUPON-001’,
37+
organizationName: ‘Example Shop’,
38+
description: ‘Spring sale coupon’,
39+
)
40+
->withGoogle(
41+
classId: ‘3388000000012345.example_offer_class’,
42+
objectId: ‘3388000000012345.example_offer_object’,
43+
defaultReviewStatus: ReviewStatusEnum::APPROVED,
44+
defaultGoogleObjectState: StateEnum::ACTIVE,
45+
)
46+
->withSamsung(
47+
refId: ‘coupon-samsung-001’,
48+
appLinkLogo: ‘https://example.com/logo.png’,
49+
appLinkName: ‘Example Shop’,
50+
appLinkData: ‘https://example.com’,
51+
);
4352

4453
$built = WalletPass::offer(
4554
$context,
46-
title: '15% off',
47-
provider: 'Example Shop',
55+
title: 15% off,
56+
provider: Example Shop,
4857
redemptionChannel: RedemptionChannelEnum::BOTH,
4958
)
50-
->withBackgroundColorRgb('rgb(30, 60, 90)')
59+
->withBackgroundColorRgb(rgb(30, 60, 90))
5160
->addAppleBarcode(new Barcode(
52-
altText: 'Coupon',
61+
altText: Coupon,
5362
format: BarcodeFormatEnum::QR,
54-
message: 'SAVE15-2026',
55-
messageEncoding: 'utf-8',
63+
message: SAVE15-2026,
64+
messageEncoding: utf-8,
5665
))
5766
->build();
5867

5968
// $built->apple() → Pass (coupon)
6069
// $built->google() → OfferClass + OfferObject
70+
// $built->samsung() → Card (coupon)
6171
// Then normalize with Symfony Serializer + this library’s normalizers.
6272
```
6373

@@ -69,6 +79,10 @@ Apple’s model maps to a **single** tree: either use the **builder** above or b
6979

7080
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()`.
7181

82+
### 📱 Samsung Wallet
83+
84+
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.
85+
7286
## Install
7387

7488
```bash
@@ -79,19 +93,22 @@ composer require jolicode/wallet-kit
7993

8094
- `Jolicode\WalletKit\Pass\Apple` — Apple Wallet `pass.json` payloads
8195
- `Jolicode\WalletKit\Pass\Android` — Google Wallet class and object payloads
82-
- `Jolicode\WalletKit\Builder` — Fluent builders (`WalletPass`, …) for Apple, Google, or both
96+
- `Jolicode\WalletKit\Pass\Samsung` — Samsung Wallet card payloads
97+
- `Jolicode\WalletKit\Builder` — Fluent builders (`WalletPass`, …) for Apple, Google, Samsung, or all
8398
- `Jolicode\WalletKit\Exception` — Builder context and `BuiltWalletPass` accessor exceptions
8499

85100
## API spec checks (with Castor)
86101

87-
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:
102+
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:
88103

89104
| Command | Purpose |
90105
| --- | --- |
91106
| `castor spec:check:google` | Fetches the live Wallet Objects discovery and compares its `revision` to [`tools/spec/google-wallet-baseline.json`](tools/spec/google-wallet-baseline.json). |
92107
| `castor spec:baseline:google` | After you update Android models for a new discovery revision, refreshes that JSON baseline. |
93108
| `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). |
94109
| `castor spec:baseline:apple` | Rewrites `apple-pass-keyset.json` from the current phpstan definitions (run after intentional model changes). |
110+
| `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). |
111+
| `castor spec:baseline:samsung` | Rewrites `samsung-wallet-keyset.json` from the current phpstan definitions (run after intentional model changes). |
95112

96113
Scripts live under [`tools/spec/`](tools/spec/) and are also invoked by CI (`spec-check` job).
97114

castor.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,15 @@ function spec_baseline_google(): void
7979
{
8080
run([spec_tools_php(), __DIR__ . '/tools/spec/google-wallet-spec.php', 'baseline']);
8181
}
82+
83+
#[AsTask('check:samsung', namespace: 'spec', description: 'Compare Samsung Wallet phpstan keyset to tools/spec/samsung-wallet-keyset.json')]
84+
function spec_check_samsung(): void
85+
{
86+
run([spec_tools_php(), __DIR__ . '/tools/spec/samsung-wallet-keyset.php', 'check']);
87+
}
88+
89+
#[AsTask('baseline:samsung', namespace: 'spec', description: 'Regenerate tools/spec/samsung-wallet-keyset.json from Samsung Model phpstan types')]
90+
function spec_baseline_samsung(): void
91+
{
92+
run([spec_tools_php(), __DIR__ . '/tools/spec/samsung-wallet-keyset.php', 'baseline']);
93+
}

docs/builder-examples.md

Lines changed: 100 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@
1414
- [Portable options (all builders)](#portable-options-all-builders)
1515
- [Limitations](#limitations)
1616

17-
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.
17+
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).
1818

1919
After `build()`, you get a [`BuiltWalletPass`](../src/Builder/BuiltWalletPass.php):
2020

2121
- `$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)
2222
- `$built->google()->issuerClass` / `$built->google()->passObject` → Google class and object (throws [`GoogleWalletPairNotAvailableException`](../src/Exception/GoogleWalletPairNotAvailableException.php) if there was no Google slice)
23+
- `$built->samsung()` → Samsung [`Card`](../src/Pass/Samsung/Model/Card.php) envelope (throws [`SamsungCardNotAvailableException`](../src/Exception/SamsungCardNotAvailableException.php) if there was no Samsung slice)
2324

2425
Serialize with **Symfony Serializer** and the normalizers from this package (see [`tests/Builder/BuilderTestSerializerFactory.php`](../tests/Builder/BuilderTestSerializerFactory.php) for a full list).
2526

@@ -36,24 +37,27 @@ use Jolicode\WalletKit\Builder\WalletPlatformContext;
3637
use Jolicode\WalletKit\Pass\Android\Model\Shared\ReviewStatusEnum;
3738
use Jolicode\WalletKit\Pass\Android\Model\Shared\StateEnum;
3839

39-
$context = WalletPlatformContext::both(
40-
appleTeamIdentifier: 'YOUR_TEAM_ID',
41-
applePassTypeIdentifier: 'pass.com.example.app',
42-
appleSerialNumber: 'UNIQUE-SERIAL-001',
43-
appleOrganizationName: 'Example Airlines',
44-
appleDescription: 'Boarding pass SFO → LHR',
45-
googleClassId: '3388000000012345.example_flight_class',
46-
googleObjectId: '3388000000012345.example_flight_object',
47-
defaultGoogleReviewStatus: ReviewStatusEnum::APPROVED,
48-
defaultGoogleObjectState: StateEnum::ACTIVE,
49-
);
40+
$context = (new WalletPlatformContext())
41+
->withApple(
42+
teamIdentifier: 'YOUR_TEAM_ID',
43+
passTypeIdentifier: 'pass.com.example.app',
44+
serialNumber: 'UNIQUE-SERIAL-001',
45+
organizationName: 'Example Airlines',
46+
description: 'Boarding pass SFO → LHR',
47+
)
48+
->withGoogle(
49+
classId: '3388000000012345.example_flight_class',
50+
objectId: '3388000000012345.example_flight_object',
51+
defaultReviewStatus: ReviewStatusEnum::APPROVED,
52+
defaultObjectState: StateEnum::ACTIVE,
53+
);
5054
```
5155

5256
---
5357

5458
## Apple-only and Google-only snippets
5559

56-
**Apple-only** (no Google IDs required). After `build()`, use only `$built->apple()`; `$built->google()` throws.
60+
**Apple-only** (no Google or Samsung needed). After `build()`, use only `$built->apple()`; `$built->google()` and `$built->samsung()` throw.
5761

5862
```php
5963
use Jolicode\WalletKit\Builder\WalletPlatformContext;
@@ -62,12 +66,12 @@ use Jolicode\WalletKit\Pass\Apple\Model\Field;
6266
use Jolicode\WalletKit\Pass\Apple\Model\PassStructure;
6367
use Jolicode\WalletKit\Pass\Android\Model\Generic\GenericTypeEnum;
6468

65-
$appleContext = WalletPlatformContext::appleOnly(
66-
appleTeamIdentifier: 'YOUR_TEAM_ID',
67-
applePassTypeIdentifier: 'pass.com.example.app',
68-
appleSerialNumber: 'SN-APPLE-ONLY',
69-
appleOrganizationName: 'Example Org',
70-
appleDescription: 'Membership',
69+
$appleContext = (new WalletPlatformContext())->withApple(
70+
teamIdentifier: 'YOUR_TEAM_ID',
71+
passTypeIdentifier: 'pass.com.example.app',
72+
serialNumber: 'SN-APPLE-ONLY',
73+
organizationName: 'Example Org',
74+
description: 'Membership',
7175
);
7276

7377
$built = WalletPass::generic($appleContext)
@@ -80,7 +84,7 @@ $built = WalletPass::generic($appleContext)
8084
$pass = $built->apple();
8185
```
8286

83-
**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.
87+
**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.
8488

8589
```php
8690
use Jolicode\WalletKit\Builder\WalletPlatformContext;
@@ -89,9 +93,9 @@ use Jolicode\WalletKit\Pass\Android\Model\Offer\RedemptionChannelEnum;
8993
use Jolicode\WalletKit\Pass\Apple\Model\Barcode;
9094
use Jolicode\WalletKit\Pass\Apple\Model\BarcodeFormatEnum;
9195

92-
$googleContext = WalletPlatformContext::googleOnly(
93-
googleClassId: '3388000000012345.example_offer_class',
94-
googleObjectId: '3388000000012345.example_offer_object',
96+
$googleContext = (new WalletPlatformContext())->withGoogle(
97+
classId: '3388000000012345.example_offer_class',
98+
objectId: '3388000000012345.example_offer_object',
9599
issuerName: 'Example Shop',
96100
);
97101

@@ -110,6 +114,74 @@ $built = WalletPass::offer(
110114
$pair = $built->google();
111115
```
112116

117+
**Samsung-only** (Samsung requires `appLinkLogo`, `appLinkName`, `appLinkData` on all card types). After `build()`, use `$built->samsung()`; `$built->apple()` and `$built->google()` throw.
118+
119+
```php
120+
use Jolicode\WalletKit\Builder\WalletPlatformContext;
121+
use Jolicode\WalletKit\Builder\WalletPass;
122+
use Jolicode\WalletKit\Pass\Android\Model\Offer\RedemptionChannelEnum;
123+
use Jolicode\WalletKit\Pass\Apple\Model\Barcode;
124+
use Jolicode\WalletKit\Pass\Apple\Model\BarcodeFormatEnum;
125+
126+
$samsungContext = (new WalletPlatformContext())->withSamsung(
127+
refId: 'coupon-samsung-001',
128+
appLinkLogo: 'https://example.com/logo.png',
129+
appLinkName: 'Example Shop',
130+
appLinkData: 'https://example.com',
131+
);
132+
133+
$built = WalletPass::offer(
134+
$samsungContext,
135+
title: '10% off',
136+
provider: 'Example Shop',
137+
redemptionChannel: RedemptionChannelEnum::INSTORE,
138+
)->addAppleBarcode(new Barcode(
139+
altText: 'Promo',
140+
format: BarcodeFormatEnum::QR,
141+
message: 'SAVE10',
142+
messageEncoding: 'utf-8',
143+
))->build();
144+
145+
$card = $built->samsung();
146+
```
147+
148+
**All three platforms** — Apple + Google + Samsung in a single build.
149+
150+
```php
151+
use Jolicode\WalletKit\Builder\WalletPlatformContext;
152+
use Jolicode\WalletKit\Builder\WalletPass;
153+
use Jolicode\WalletKit\Pass\Android\Model\Offer\RedemptionChannelEnum;
154+
use Jolicode\WalletKit\Pass\Android\Model\Shared\ReviewStatusEnum;
155+
use Jolicode\WalletKit\Pass\Android\Model\Shared\StateEnum;
156+
157+
$allContext = (new WalletPlatformContext())
158+
->withApple(
159+
teamIdentifier: 'YOUR_TEAM_ID',
160+
passTypeIdentifier: 'pass.com.example.app',
161+
serialNumber: 'SN-ALL-001',
162+
organizationName: 'Example Shop',
163+
description: 'Promotional offer',
164+
)
165+
->withGoogle(
166+
classId: '3388000000012345.example_offer_class',
167+
objectId: '3388000000012345.example_offer_object',
168+
defaultReviewStatus: ReviewStatusEnum::APPROVED,
169+
defaultObjectState: StateEnum::ACTIVE,
170+
)
171+
->withSamsung(
172+
refId: 'offer-samsung-001',
173+
appLinkLogo: 'https://example.com/logo.png',
174+
appLinkName: 'Example Shop',
175+
appLinkData: 'https://example.com',
176+
);
177+
178+
$built = WalletPass::offer($allContext, '20% off', 'Example Shop', RedemptionChannelEnum::BOTH)->build();
179+
180+
$applePass = $built->apple();
181+
$googlePair = $built->google();
182+
$samsungCard = $built->samsung();
183+
```
184+
113185
---
114186

115187
## 1. Generic pass
@@ -341,11 +413,13 @@ These methods come from [`CommonWalletBuilderTrait`](../src/Builder/CommonWallet
341413
| `withGoogleReviewStatus` / `withGoogleObjectState` | Overrides context defaults for Google class/object lifecycle. |
342414
| `withAppLinkData` / `withGoogleLinksModuleData` | Links and app deep links (Google; Apple where mapped). |
343415
| `mutateApple(callable)` | Escape hatch to tweak the Apple `Pass` before `build()` returns. |
416+
| `mutateSamsung(callable)` | Escape hatch to tweak the Samsung `Card` before `build()` returns. |
344417

345418
---
346419

347420
## Limitations
348421

349-
- The library does **not** sign `.pkpass` bundles or call Google Wallet REST APIs.
350-
- 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.
351-
- 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).
422+
- The library does **not** sign `.pkpass` bundles, call Google Wallet REST APIs, or tokenize Samsung Wallet payloads.
423+
- 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.
424+
- 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`).
425+
- 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/`.

src/Builder/BuiltWalletPass.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66

77
use Jolicode\WalletKit\Exception\ApplePassNotAvailableException;
88
use Jolicode\WalletKit\Exception\GoogleWalletPairNotAvailableException;
9+
use Jolicode\WalletKit\Exception\SamsungCardNotAvailableException;
910
use Jolicode\WalletKit\Pass\Apple\Model\Pass;
11+
use Jolicode\WalletKit\Pass\Samsung\Model\Card;
1012

1113
final class BuiltWalletPass
1214
{
1315
public function __construct(
1416
private readonly ?Pass $apple,
1517
private readonly ?GoogleWalletPair $google,
18+
private readonly ?Card $samsung = null,
1619
) {
1720
}
1821

@@ -38,4 +41,13 @@ public function googleVertical(): GoogleVerticalEnum
3841
{
3942
return $this->google()->vertical;
4043
}
44+
45+
public function samsung(): Card
46+
{
47+
if (null === $this->samsung) {
48+
throw new SamsungCardNotAvailableException();
49+
}
50+
51+
return $this->samsung;
52+
}
4153
}

0 commit comments

Comments
 (0)