Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/google-loyalty-balance.md
Original file line number Diff line number Diff line change
@@ -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`.
22 changes: 22 additions & 0 deletions packages/passmint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,28 @@ 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.
- 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

The same `Pass` produces both outputs — write your pass definition once:
Expand Down
38 changes: 32 additions & 6 deletions packages/passmint/src/google/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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<string, unknown> {
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<string, unknown> = {
...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',
Expand All @@ -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,
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/passmint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export type {
FieldValue,
GenericPassInput,
GoogleImageModule,
GoogleLoyalty,
GoogleLoyaltyPoints,
GoogleObjectImages,
ImageSource,
Images,
Expand Down
2 changes: 2 additions & 0 deletions packages/passmint/src/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export type {
CouponInput,
EventTicketInput,
GenericPassInput,
GoogleLoyalty,
GoogleLoyaltyPoints,
PassInput,
PassStyle,
StoreCardInput,
Expand Down
27 changes: 27 additions & 0 deletions packages/passmint/src/schema/pass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,10 +229,37 @@ export const EventTicketSchema = v.object({
})
export type EventTicketInput = v.InferOutput<typeof EventTicketSchema>

/**
* 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<typeof GoogleLoyaltyPointsSchema>

/**
* 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<typeof GoogleLoyaltySchema>

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<typeof StoreCardSchema>

Expand Down
103 changes: 103 additions & 0 deletions packages/passmint/test/google/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,3 +617,106 @@ describe('renderGooglePayload — logo and hero field names per type', () => {
}
})
})

describe('renderGooglePayload — loyalty balance and account name', () => {
const loyaltyObject = (input: Record<string, unknown>) => {
const payload = renderGooglePayload({ ...base, style: 'storeCard', ...input } as PassInput, {
issuerId,
})
return (payload.loyaltyObjects as Record<string, unknown>[])[0] as Record<string, unknown>
}

// 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', () => {
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('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(
{
...base,
style: 'storeCard',
google: { loyalty: { points: { label: 'Stamps', balance: '6 / 10' } } },
} as PassInput,
{ issuerId },
)
expect(withPoints.loyaltyClasses).toEqual(plain.loyaltyClasses)
})
})
35 changes: 35 additions & 0 deletions packages/passmint/test/schema/pass.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
Loading