From 100d7e99deff75b4fd258554e84953eda6fb4314 Mon Sep 17 00:00:00 2001 From: Alex Pate Date: Thu, 8 Oct 2026 17:50:38 +0200 Subject: [PATCH 1/2] feat(google): loyalty balance and account name on store cards --- .changeset/google-loyalty-balance.md | 5 ++ packages/passmint/README.md | 21 ++++++ packages/passmint/src/google/render.ts | 38 +++++++++-- packages/passmint/src/index.ts | 2 + packages/passmint/src/schema/index.ts | 2 + packages/passmint/src/schema/pass.ts | 27 ++++++++ packages/passmint/test/google/render.test.ts | 72 ++++++++++++++++++++ packages/passmint/test/schema/pass.test.ts | 35 ++++++++++ 8 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 .changeset/google-loyalty-balance.md diff --git a/.changeset/google-loyalty-balance.md b/.changeset/google-loyalty-balance.md new file mode 100644 index 0000000..a68eadd --- /dev/null +++ b/.changeset/google-loyalty-balance.md @@ -0,0 +1,5 @@ +--- +'passmint': minor +--- + +Add `google.loyalty` on store cards for Google Wallet loyalty objects: `points` and `secondaryPoints` (`{ label, balance }`, rendered as `loyaltyPoints` / `secondaryLoyaltyPoints`; a string balance becomes `balance.string`, an int32 `balance.int`, any other number `balance.double`) and `accountName` (the holder's name; `null` omits it). Without `google.loyalty` the output is unchanged: `accountName` is still the pass description. New exported types: `GoogleLoyalty`, `GoogleLoyaltyPoints`. diff --git a/packages/passmint/README.md b/packages/passmint/README.md index 8485184..d6bbab5 100644 --- a/packages/passmint/README.md +++ b/packages/passmint/README.md @@ -192,6 +192,27 @@ images: { Leave a field out to keep the default. Set it to `null` to clear an override: the object gets the default image if passmint renders one there, otherwise the field is emitted as `null` so a Google REST PATCH removes the image an earlier render set. The class is never affected. +### Google loyalty balance + +Store cards render as Google `loyaltyObject`s. `google.loyalty` adds Google's native balance and account-holder name. It's Google-only; Apple output ignores it. + +```ts +const pass = Pass.storeCard({ + // ...base fields + google: { + loyalty: { + points: { label: 'Stamps', balance: '6 / 10' }, // → loyaltyPoints, balance.string + secondaryPoints: { label: 'Reward', balance: 'Latte' }, // → secondaryLoyaltyPoints + accountName: 'Ana García', // the holder's name + }, + }, +}).build() +``` + +- `balance` takes a string (`balance.string`), an int32 (`balance.int`) or any other finite number (`balance.double`). +- `accountName` defaults to the pass `description`, which is what earlier versions always sent. Set it to `null` to leave it off for anonymous cards. +- Both live on the object, so changing a balance never re-submits the class for review. + ## One schema, two wallets The same `Pass` produces both outputs — write your pass definition once: diff --git a/packages/passmint/src/google/render.ts b/packages/passmint/src/google/render.ts index d29f3a2..7b09b73 100644 --- a/packages/passmint/src/google/render.ts +++ b/packages/passmint/src/google/render.ts @@ -5,7 +5,7 @@ import type { GoogleObjectImages, ImageSource } from '../schema/images' import type { LocalizedString } from '../schema/localization' import { defaultValue, translations } from '../schema/localization' import type { Location } from '../schema/locations' -import type { PassInput } from '../schema/pass' +import type { GoogleLoyaltyPoints, PassInput } from '../schema/pass' import type { GoogleSavePayload } from './jwt' /** @@ -414,7 +414,37 @@ function renderTransit(input: PassInput, classId: string, objectId: string): Sty } } +const INT32_MIN = -2_147_483_648 +const INT32_MAX = 2_147_483_647 + +/** passmint's loyalty balance as Google's LoyaltyPoints. */ +function toLoyaltyPoints(points: GoogleLoyaltyPoints): Record { + const { balance } = points + return { + label: points.label, + balance: + typeof balance === 'string' + ? { string: balance } + : Number.isInteger(balance) && balance >= INT32_MIN && balance <= INT32_MAX + ? { int: balance } + : { double: balance }, + } +} + function renderLoyalty(input: PassInput, classId: string, objectId: string): StyleResult { + const loyalty = input.style === 'storeCard' ? input.google?.loyalty : undefined + const objectDef: Record = { + ...commonObjectFields(input, classId, objectId), + accountId: input.serialNumber, + } + // Undefined keeps the long-standing default (the description); null omits the field. + const accountName = + loyalty?.accountName === undefined ? defaultValue(resolveHeader(input)) : loyalty.accountName + if (accountName !== null) objectDef.accountName = accountName + if (loyalty?.points) objectDef.loyaltyPoints = toLoyaltyPoints(loyalty.points) + if (loyalty?.secondaryPoints) { + objectDef.secondaryLoyaltyPoints = toLoyaltyPoints(loyalty.secondaryPoints) + } return { classKey: 'loyaltyClasses', objectKey: 'loyaltyObjects', @@ -423,11 +453,7 @@ function renderLoyalty(input: PassInput, classId: string, objectId: string): Sty ...classImageFields(input, 'programLogo'), programName: defaultValue(resolveTitle(input)), }, - objectDef: { - ...commonObjectFields(input, classId, objectId), - accountId: input.serialNumber, - accountName: defaultValue(resolveHeader(input)), - }, + objectDef, } } diff --git a/packages/passmint/src/index.ts b/packages/passmint/src/index.ts index 8457dd7..5777627 100644 --- a/packages/passmint/src/index.ts +++ b/packages/passmint/src/index.ts @@ -66,6 +66,8 @@ export type { FieldValue, GenericPassInput, GoogleImageModule, + GoogleLoyalty, + GoogleLoyaltyPoints, GoogleObjectImages, ImageSource, Images, diff --git a/packages/passmint/src/schema/index.ts b/packages/passmint/src/schema/index.ts index 44deaa1..83d81ea 100644 --- a/packages/passmint/src/schema/index.ts +++ b/packages/passmint/src/schema/index.ts @@ -27,6 +27,8 @@ export type { CouponInput, EventTicketInput, GenericPassInput, + GoogleLoyalty, + GoogleLoyaltyPoints, PassInput, PassStyle, StoreCardInput, diff --git a/packages/passmint/src/schema/pass.ts b/packages/passmint/src/schema/pass.ts index 6ea19b2..e2b0f79 100644 --- a/packages/passmint/src/schema/pass.ts +++ b/packages/passmint/src/schema/pass.ts @@ -229,10 +229,37 @@ export const EventTicketSchema = v.object({ }) export type EventTicketInput = v.InferOutput +/** + * A Google Wallet loyalty balance (LoyaltyObject `loyaltyPoints`). A string renders as + * `balance.string`, so a stamp count like "6 / 10" needs no parsing; an int32 as + * `balance.int`; any other finite number as `balance.double`. Google-only; Apple ignores it. + */ +const GoogleLoyaltyPointsSchema = v.object({ + label: v.pipe(v.string(), v.minLength(1)), + balance: v.union([v.pipe(v.string(), v.minLength(1)), v.pipe(v.number(), v.finite())]), +}) +export type GoogleLoyaltyPoints = v.InferOutput + +/** + * Google-only loyalty extras for a store card. + * + * `accountName` is the card holder's name ("Ana García"). Omitted (undefined) keeps the + * existing behaviour — the pass description — so current cards do not change; `null` leaves + * `accountName` off the object entirely (an anonymous card shows only its account id). + */ +const GoogleLoyaltySchema = v.object({ + points: v.optional(GoogleLoyaltyPointsSchema), + secondaryPoints: v.optional(GoogleLoyaltyPointsSchema), + accountName: v.optional(v.nullable(v.pipe(v.string(), v.minLength(1)))), +}) +export type GoogleLoyalty = v.InferOutput + export const StoreCardSchema = v.object({ ...PassBaseSchema.entries, style: v.literal('storeCard'), ...StandardFieldLayout, + /** Google Wallet only. See {@link GoogleLoyalty}. */ + google: v.optional(v.object({ loyalty: v.optional(GoogleLoyaltySchema) })), }) export type StoreCardInput = v.InferOutput diff --git a/packages/passmint/test/google/render.test.ts b/packages/passmint/test/google/render.test.ts index 4f0ecf4..af19263 100644 --- a/packages/passmint/test/google/render.test.ts +++ b/packages/passmint/test/google/render.test.ts @@ -617,3 +617,75 @@ describe('renderGooglePayload — logo and hero field names per type', () => { } }) }) + +describe('renderGooglePayload — loyalty balance and account name', () => { + const loyaltyObject = (input: Record) => { + const payload = renderGooglePayload({ ...base, style: 'storeCard', ...input } as PassInput, { + issuerId, + }) + return (payload.loyaltyObjects as Record[])[0] as Record + } + + it('no google input renders exactly as 0.6.0 (accountName is the description, no points)', () => { + const obj = loyaltyObject({}) + expect(obj.accountName).toBe('Sample pass') + expect(obj).not.toHaveProperty('loyaltyPoints') + expect(obj).not.toHaveProperty('secondaryLoyaltyPoints') + }) + + it('renders a string balance as balance.string', () => { + const obj = loyaltyObject({ + google: { loyalty: { points: { label: 'Stamps', balance: '6 / 10' } } }, + }) + expect(obj.loyaltyPoints).toEqual({ label: 'Stamps', balance: { string: '6 / 10' } }) + }) + + it('renders an int32 as balance.int and anything else numeric as balance.double', () => { + expect( + loyaltyObject({ google: { loyalty: { points: { label: 'Points', balance: 120 } } } }) + .loyaltyPoints, + ).toEqual({ label: 'Points', balance: { int: 120 } }) + expect( + loyaltyObject({ google: { loyalty: { points: { label: 'Miles', balance: 2.5 } } } }) + .loyaltyPoints, + ).toEqual({ label: 'Miles', balance: { double: 2.5 } }) + expect( + loyaltyObject({ + google: { loyalty: { points: { label: 'Big', balance: 3_000_000_000 } } }, + }).loyaltyPoints, + ).toEqual({ label: 'Big', balance: { double: 3_000_000_000 } }) + }) + + it('renders secondaryPoints as secondaryLoyaltyPoints', () => { + const obj = loyaltyObject({ + google: { loyalty: { secondaryPoints: { label: 'Reward', balance: 'Free latte' } } }, + }) + expect(obj.secondaryLoyaltyPoints).toEqual({ + label: 'Reward', + balance: { string: 'Free latte' }, + }) + expect(obj).not.toHaveProperty('loyaltyPoints') + }) + + it('uses an explicit accountName, and omits it for null', () => { + expect(loyaltyObject({ google: { loyalty: { accountName: 'Ana García' } } }).accountName).toBe( + 'Ana García', + ) + expect(loyaltyObject({ google: { loyalty: { accountName: null } } })).not.toHaveProperty( + 'accountName', + ) + }) + + it('leaves the class untouched (balance is object-level, so a balance change never re-submits the class)', () => { + const plain = renderGooglePayload({ ...base, style: 'storeCard' }, { issuerId }) + const withPoints = renderGooglePayload( + { + ...base, + style: 'storeCard', + google: { loyalty: { points: { label: 'Stamps', balance: '6 / 10' } } }, + } as PassInput, + { issuerId }, + ) + expect(withPoints.loyaltyClasses).toEqual(plain.loyaltyClasses) + }) +}) diff --git a/packages/passmint/test/schema/pass.test.ts b/packages/passmint/test/schema/pass.test.ts index 974b9d0..c7b34fd 100644 --- a/packages/passmint/test/schema/pass.test.ts +++ b/packages/passmint/test/schema/pass.test.ts @@ -428,3 +428,38 @@ describe('GenericPassSchema — poster', () => { expect(r.success).toBe(true) }) }) + +describe('parsePassInput — google.loyalty (storeCard)', () => { + const storeCard = (google: unknown) => ({ style: 'storeCard', ...validBase, google }) + + it('accepts points, secondaryPoints and accountName', () => { + const pass = parsePassInput( + storeCard({ + loyalty: { + points: { label: 'Stamps', balance: '6 / 10' }, + secondaryPoints: { label: 'Reward', balance: 1 }, + accountName: null, + }, + }), + ) + expect(pass.style === 'storeCard' && pass.google?.loyalty?.points?.balance).toBe('6 / 10') + }) + + it.each([ + ['an empty label', { points: { label: '', balance: '1' } }], + ['an empty string balance', { points: { label: 'Stamps', balance: '' } }], + ['a non-finite balance', { points: { label: 'Stamps', balance: Number.NaN } }], + ['an empty accountName', { accountName: '' }], + ])('rejects %s', (_, loyalty) => { + expect(() => parsePassInput(storeCard({ loyalty }))).toThrow(PassmintSchemaError) + }) + + it('ignores google on other styles (unknown keys are stripped, as before)', () => { + const pass = parsePassInput({ + style: 'eventTicket', + ...validBase, + google: { loyalty: { points: { label: 'x', balance: 1 } } }, + }) + expect(pass).not.toHaveProperty('google') + }) +}) From c50a9829e833ac4b447bcd654011e0f5ca9b29f6 Mon Sep 17 00:00:00 2001 From: Alex Pate Date: Thu, 8 Oct 2026 17:53:46 +0200 Subject: [PATCH 2/2] test(google): pin 0.6.0 loyalty output, cover combined points and applyRaw --- packages/passmint/README.md | 3 +- packages/passmint/test/google/render.test.ts | 41 +++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/passmint/README.md b/packages/passmint/README.md index d6bbab5..2fd8c24 100644 --- a/packages/passmint/README.md +++ b/packages/passmint/README.md @@ -211,7 +211,8 @@ const pass = Pass.storeCard({ - `balance` takes a string (`balance.string`), an int32 (`balance.int`) or any other finite number (`balance.double`). - `accountName` defaults to the pass `description`, which is what earlier versions always sent. Set it to `null` to leave it off for anonymous cards. -- Both live on the object, so changing a balance never re-submits the class for review. +- Fields you leave out (an absent `points`, or `accountName: null`) are left out of the object, so a Google PATCH won't clear a value already on the card. Use an update or replace to remove it. +- These all live on the object, so changing a balance never re-submits the class for review. ## One schema, two wallets diff --git a/packages/passmint/test/google/render.test.ts b/packages/passmint/test/google/render.test.ts index af19263..0d68af9 100644 --- a/packages/passmint/test/google/render.test.ts +++ b/packages/passmint/test/google/render.test.ts @@ -626,11 +626,19 @@ describe('renderGooglePayload — loyalty balance and account name', () => { return (payload.loyaltyObjects as Record[])[0] as Record } - it('no google input renders exactly as 0.6.0 (accountName is the description, no points)', () => { - const obj = loyaltyObject({}) - expect(obj.accountName).toBe('Sample pass') - expect(obj).not.toHaveProperty('loyaltyPoints') - expect(obj).not.toHaveProperty('secondaryLoyaltyPoints') + // Captured from the 0.6.0 release (c3e175b), not from this code: key order included. + const RENDERED_0_6_0 = + '{"loyaltyClasses":[{"id":"3388000000000000.gr-1-class","issuerName":"Example Corp","reviewStatus":"UNDER_REVIEW","programName":"Example Corp"}],"loyaltyObjects":[{"id":"3388000000000000.gr-1","classId":"3388000000000000.gr-1-class","state":"ACTIVE","accountId":"gr-1","accountName":"Sample pass"}]}' + + it.each([ + ['no google key', {}], + ['google: {}', { google: {} }], + ['google: { loyalty: {} }', { google: { loyalty: {} } }], + ])('renders byte-identically to 0.6.0 with %s', (_, extra) => { + const payload = renderGooglePayload({ ...base, style: 'storeCard', ...extra } as PassInput, { + issuerId, + }) + expect(JSON.stringify(payload)).toBe(RENDERED_0_6_0) }) it('renders a string balance as balance.string', () => { @@ -676,6 +684,29 @@ describe('renderGooglePayload — loyalty balance and account name', () => { ) }) + it('renders points, secondaryPoints and an explicit accountName together', () => { + const obj = loyaltyObject({ + google: { + loyalty: { + points: { label: 'Stamps', balance: '6 / 10' }, + secondaryPoints: { label: 'Reward', balance: 'Latte' }, + accountName: 'Ana García', + }, + }, + }) + expect(obj.loyaltyPoints).toEqual({ label: 'Stamps', balance: { string: '6 / 10' } }) + expect(obj.secondaryLoyaltyPoints).toEqual({ label: 'Reward', balance: { string: 'Latte' } }) + expect(obj.accountName).toBe('Ana García') + }) + + it('lets applyRaw.google override loyaltyPoints', () => { + const obj = loyaltyObject({ + google: { loyalty: { points: { label: 'Stamps', balance: '6 / 10' } } }, + applyRaw: { google: { loyaltyPoints: { label: 'Raw', balance: { string: '9 / 10' } } } }, + }) + expect(obj.loyaltyPoints).toEqual({ label: 'Raw', balance: { string: '9 / 10' } }) + }) + it('leaves the class untouched (balance is object-level, so a balance change never re-submits the class)', () => { const plain = renderGooglePayload({ ...base, style: 'storeCard' }, { issuerId }) const withPoints = renderGooglePayload(