@nimee/wallet-generator 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/apple/ApplePassGenerator.d.ts +71 -0
  2. package/dist/apple/ApplePassGenerator.js +262 -0
  3. package/dist/apple/ApplePassGenerator.js.map +1 -0
  4. package/dist/apple/signPassWorker.d.ts +1 -0
  5. package/dist/apple/signPassWorker.js +44 -0
  6. package/dist/apple/signPassWorker.js.map +1 -0
  7. package/dist/apple/template.pass/icon.png +0 -0
  8. package/dist/apple/template.pass/icon@2x.png +0 -0
  9. package/dist/apple/template.pass/logo.png +0 -0
  10. package/dist/apple/template.pass/logo@2x.png +0 -0
  11. package/dist/apple/template.pass/pass.json +16 -0
  12. package/dist/apple/template.pass/template.pass/icon.png +0 -0
  13. package/dist/apple/template.pass/template.pass/icon@2x.png +0 -0
  14. package/dist/apple/template.pass/template.pass/logo.png +0 -0
  15. package/dist/apple/template.pass/template.pass/logo@2x.png +0 -0
  16. package/dist/apple/template.pass/template.pass/pass.json +16 -0
  17. package/dist/google/GooglePassGenerator.d.ts +31 -0
  18. package/dist/google/GooglePassGenerator.js +103 -0
  19. package/dist/google/GooglePassGenerator.js.map +1 -0
  20. package/dist/google/googleAuth.d.ts +11 -0
  21. package/dist/google/googleAuth.js +99 -0
  22. package/dist/google/googleAuth.js.map +1 -0
  23. package/dist/helpers/colorHelpers.d.ts +9 -0
  24. package/dist/helpers/colorHelpers.js +32 -0
  25. package/dist/helpers/colorHelpers.js.map +1 -0
  26. package/dist/helpers/imageHelpers.d.ts +23 -0
  27. package/dist/helpers/imageHelpers.js +94 -0
  28. package/dist/helpers/imageHelpers.js.map +1 -0
  29. package/dist/index.d.ts +5 -0
  30. package/dist/index.js +25 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/types.d.ts +65 -0
  33. package/dist/types.js +3 -0
  34. package/dist/types.js.map +1 -0
  35. package/jest.config.js +18 -0
  36. package/package.json +38 -0
  37. package/src/apple/ApplePassGenerator.ts +291 -0
  38. package/src/apple/signPassWorker.ts +52 -0
  39. package/src/apple/template.pass/icon.png +0 -0
  40. package/src/apple/template.pass/icon@2x.png +0 -0
  41. package/src/apple/template.pass/logo.png +0 -0
  42. package/src/apple/template.pass/logo@2x.png +0 -0
  43. package/src/apple/template.pass/pass.json +16 -0
  44. package/src/google/GooglePassGenerator.ts +104 -0
  45. package/src/google/googleAuth.ts +134 -0
  46. package/src/helpers/colorHelpers.ts +34 -0
  47. package/src/helpers/imageHelpers.ts +87 -0
  48. package/src/index.ts +5 -0
  49. package/src/types.ts +66 -0
  50. package/tests/apple/ApplePassGenerator.test.ts +47 -0
  51. package/tests/google/GooglePassGenerator.test.ts +47 -0
  52. package/tests/helpers/colorHelpers.test.ts +30 -0
  53. package/tests/helpers/imageHelpers.test.ts +19 -0
  54. package/tsconfig.json +28 -0
@@ -0,0 +1,291 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { Worker } from 'worker_threads';
4
+ import * as imageHelpers from '../helpers/imageHelpers';
5
+ import { IWalletPassData, IAppleWalletConfig, IWalletLayoutField } from '../types';
6
+
7
+ // ─── Apple pass image dimensions ─────────────────────────────────────────────
8
+
9
+ const LOGO_1X = { w: 160, h: 50 };
10
+ const LOGO_2X = { w: 320, h: 100 };
11
+ const ICON_1X = { w: 29, h: 29 };
12
+ const ICON_2X = { w: 58, h: 58 };
13
+ const STRIP_1X = { w: 375, h: 123 };
14
+ const STRIP_2X = { w: 750, h: 246 };
15
+
16
+ // ─── Types ────────────────────────────────────────────────────────────────────
17
+
18
+ export interface ApplePassGeneratorOptions {
19
+ /** Allowed image host list passed to downloadImageBuffer. Empty = all https hosts. */
20
+ allowedHosts?: string[];
21
+ /** Worker thread timeout in milliseconds. Defaults to 15 000. */
22
+ workerTimeoutMs?: number;
23
+ }
24
+
25
+ interface PassImage {
26
+ name: string;
27
+ buffer: Buffer;
28
+ }
29
+
30
+ interface WorkerPayload {
31
+ modelPath: string;
32
+ certificates: { signerCert: string; signerKey: string; wwdr: string };
33
+ passOverrides: Record<string, unknown>;
34
+ images: PassImage[];
35
+ barcodes: { message: string; format: string; messageEncoding: string };
36
+ layoutFields: Array<{ key: string; label: string; value: string; row: string }>;
37
+ relevantDate: string;
38
+ }
39
+
40
+ // ─── Field resolver ───────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Maps a layout field key to a label/value pair derived from pass data.
44
+ * Keys are the same ones used in the user-event WalletService.
45
+ */
46
+ function resolveField(
47
+ key: string,
48
+ data: IWalletPassData
49
+ ): { label: string; value: string } | undefined {
50
+ switch (key) {
51
+ case 'event':
52
+ case 'passTitle':
53
+ return data.passTitle ? { label: 'Event', value: data.passTitle } : undefined;
54
+ case 'holder':
55
+ case 'holderName':
56
+ case 'attendee':
57
+ return data.holderName ? { label: 'Name', value: data.holderName } : undefined;
58
+ case 'date':
59
+ case 'dateDisplay':
60
+ return data.dateDisplay ? { label: 'Date', value: data.dateDisplay } : undefined;
61
+ case 'seat':
62
+ return data.seat ? { label: 'Seat', value: data.seat } : undefined;
63
+ case 'order':
64
+ case 'orderNumber':
65
+ return data.orderNumber ? { label: 'Order', value: data.orderNumber } : undefined;
66
+ case 'subtitle':
67
+ case 'passSubtitle':
68
+ return data.passSubtitle ? { label: 'Ticket', value: data.passSubtitle } : undefined;
69
+ case 'plan_name':
70
+ return data.passTitle ? { label: 'Plan', value: data.passTitle } : undefined;
71
+ case 'member_name':
72
+ return data.holderName ? { label: 'Member', value: data.holderName } : undefined;
73
+ case 'valid_from': {
74
+ const value = data.dateDisplay || (data.startDate ? data.startDate.toLocaleDateString('he-IL') : '') || '';
75
+ return value ? { label: 'Valid From', value } : undefined;
76
+ }
77
+ case 'valid_to': {
78
+ const value = data.endDate ? data.endDate.toLocaleDateString('he-IL') : '';
79
+ return value ? { label: 'Valid To', value } : undefined;
80
+ }
81
+ default:
82
+ return undefined;
83
+ }
84
+ }
85
+
86
+ // ─── ApplePassGenerator ───────────────────────────────────────────────────────
87
+
88
+ export class ApplePassGenerator {
89
+ private readonly allowedHosts: string[];
90
+ private readonly workerTimeoutMs: number;
91
+
92
+ constructor(options: ApplePassGeneratorOptions = {}) {
93
+ this.allowedHosts = options.allowedHosts ?? [];
94
+ this.workerTimeoutMs = options.workerTimeoutMs ?? 15_000;
95
+ }
96
+
97
+ // ── Public API ─────────────────────────────────────────────────────────────
98
+
99
+ /**
100
+ * Generates a signed Apple Wallet pass buffer.
101
+ *
102
+ * @param data - Caller-assembled pass data (all DB resolution done upstream).
103
+ * @param config - Apple certificates and identifiers.
104
+ * @param defaultLayout - Layout fields to use when data.layout is absent.
105
+ * @returns Signed `.pkpass` buffer ready to stream to the client.
106
+ */
107
+ async generate(
108
+ data: IWalletPassData,
109
+ config: IAppleWalletConfig,
110
+ defaultLayout: IWalletLayoutField[]
111
+ ): Promise<Buffer> {
112
+ const images = await this.prepareImages(data);
113
+ const layoutFields = this.resolveLayout(data, defaultLayout);
114
+ const modelPath = this.resolveTemplatePath(config);
115
+
116
+ const passOverrides: Record<string, unknown> = {
117
+ passTypeIdentifier: config.passTypeIdentifier,
118
+ teamIdentifier: config.teamIdentifier,
119
+ serialNumber: data.objectId,
120
+ description: data.passTitle,
121
+ organizationName: data.marketplaceName ?? 'Nimi',
122
+ logoText: data.marketplaceName ?? 'Nimi',
123
+ ...(data.backgroundColor && { backgroundColor: data.backgroundColor }),
124
+ ...(data.foregroundColor && { foregroundColor: data.foregroundColor }),
125
+ ...(data.labelColor && { labelColor: data.labelColor }),
126
+ ...(data.isCheckedIn && { voided: true }),
127
+ };
128
+
129
+ const workerPayload: WorkerPayload = {
130
+ modelPath,
131
+ certificates: {
132
+ signerCert: config.cert,
133
+ signerKey: config.key,
134
+ wwdr: config.wwdr,
135
+ },
136
+ passOverrides,
137
+ images,
138
+ barcodes: {
139
+ message: data.qrContent,
140
+ format: 'PKBarcodeFormatQR',
141
+ messageEncoding: 'iso-8859-1',
142
+ },
143
+ layoutFields,
144
+ relevantDate: (data.startDate ?? new Date()).toISOString(),
145
+ };
146
+
147
+ return this.spawnWorker(workerPayload);
148
+ }
149
+
150
+ // ── Template path resolution ───────────────────────────────────────────────
151
+
152
+ /**
153
+ * Returns the absolute path to the `.pass` template directory.
154
+ * Uses `config.templatePath` when provided; otherwise falls back to the
155
+ * bundled template shipped with the library.
156
+ */
157
+ resolveTemplatePath(config: IAppleWalletConfig): string {
158
+ return config.templatePath ?? path.join(__dirname, "template.pass");
159
+ }
160
+
161
+ // ── Worker spawn ───────────────────────────────────────────────────────────
162
+
163
+ /**
164
+ * Spawns the `signPassWorker` in a worker thread and returns the signed buffer.
165
+ * Automatically selects the compiled `.js` file in production and falls back to
166
+ * the `.ts` source in development (when ts-node is available).
167
+ */
168
+ spawnWorker(workerData: WorkerPayload): Promise<Buffer> {
169
+ const jsPath = path.resolve(__dirname, 'signPassWorker.js');
170
+ const tsPath = path.resolve(__dirname, 'signPassWorker.ts');
171
+ const workerPath = require('fs').existsSync(jsPath) ? jsPath : tsPath;
172
+
173
+ const { workerTimeoutMs } = this;
174
+
175
+ return new Promise<Buffer>((resolve, reject) => {
176
+ const isTs = workerPath.endsWith('.ts');
177
+ const worker = new Worker(workerPath, {
178
+ ...(isTs && { execArgv: ['--require', 'ts-node/register'] }),
179
+ workerData,
180
+ });
181
+
182
+ const cleanup = () => {
183
+ clearTimeout(timer);
184
+ worker.removeAllListeners();
185
+ };
186
+
187
+ const timer = setTimeout(() => {
188
+ cleanup();
189
+ worker.terminate();
190
+ reject(new Error(`[ApplePassGenerator] worker timed out after ${workerTimeoutMs}ms`));
191
+ }, workerTimeoutMs);
192
+
193
+ worker.on('message', (msg) => {
194
+ cleanup();
195
+ if (msg?.error) {
196
+ reject(new Error(msg.error));
197
+ } else {
198
+ resolve(Buffer.from(msg));
199
+ }
200
+ });
201
+
202
+ worker.on('error', (err) => {
203
+ cleanup();
204
+ reject(err);
205
+ });
206
+
207
+ worker.on('exit', (code) => {
208
+ cleanup();
209
+ if (code !== 0) {
210
+ reject(new Error(`[ApplePassGenerator] worker exited with code ${code}`));
211
+ }
212
+ });
213
+ });
214
+ }
215
+
216
+ // ── Private helpers ────────────────────────────────────────────────────────
217
+
218
+ /**
219
+ * Downloads logo and strip images in parallel, resizes them to Apple specs,
220
+ * and returns a flat list of named buffers ready to pass to the worker.
221
+ */
222
+ private async prepareImages(data: IWalletPassData): Promise<PassImage[]> {
223
+ const imageTimeoutMs = 5_000;
224
+ const [rawLogo, rawStrip] = await Promise.all([
225
+ data.logoUrl
226
+ ? imageHelpers.downloadImageBuffer(data.logoUrl, this.allowedHosts, imageTimeoutMs)
227
+ : Promise.resolve(undefined),
228
+ data.stripImageUrl
229
+ ? imageHelpers.downloadImageBuffer(data.stripImageUrl, this.allowedHosts, imageTimeoutMs)
230
+ : Promise.resolve(undefined),
231
+ ]);
232
+
233
+ // Fall back to bundled template icon when logo download fails or URL absent
234
+ let logoBuffer: Buffer | undefined = rawLogo;
235
+ if (!logoBuffer) {
236
+ const templateIcon = path.join(__dirname, 'template.pass', 'icon.png');
237
+ try {
238
+ logoBuffer = await fs.promises.readFile(templateIcon);
239
+ } catch {
240
+ // No fallback available — pass will use whatever is baked into the template
241
+ }
242
+ }
243
+
244
+ const images: PassImage[] = [];
245
+
246
+ if (logoBuffer) {
247
+ const [logo1x, logo2x, icon1x, icon2x] = await Promise.all([
248
+ imageHelpers.resizeImageBuffer(logoBuffer, LOGO_1X.w, LOGO_1X.h),
249
+ imageHelpers.resizeImageBuffer(logoBuffer, LOGO_2X.w, LOGO_2X.h),
250
+ imageHelpers.resizeImageBuffer(logoBuffer, ICON_1X.w, ICON_1X.h),
251
+ imageHelpers.resizeImageBuffer(logoBuffer, ICON_2X.w, ICON_2X.h),
252
+ ]);
253
+ if (logo1x) images.push({ name: 'logo.png', buffer: logo1x });
254
+ if (logo2x) images.push({ name: 'logo@2x.png', buffer: logo2x });
255
+ if (icon1x) images.push({ name: 'icon.png', buffer: icon1x });
256
+ if (icon2x) images.push({ name: 'icon@2x.png', buffer: icon2x });
257
+ }
258
+
259
+ if (rawStrip) {
260
+ const [strip1x, strip2x] = await Promise.all([
261
+ imageHelpers.resizeImageBuffer(rawStrip, STRIP_1X.w, STRIP_1X.h),
262
+ imageHelpers.resizeImageBuffer(rawStrip, STRIP_2X.w, STRIP_2X.h),
263
+ ]);
264
+ if (strip1x) images.push({ name: 'strip.png', buffer: strip1x });
265
+ if (strip2x) images.push({ name: 'strip@2x.png', buffer: strip2x });
266
+ }
267
+
268
+ return images;
269
+ }
270
+
271
+ /**
272
+ * Resolves layout fields from pass data (or falls back to `defaultLayout`),
273
+ * filters out hidden rows, and maps each field key to a label/value pair.
274
+ */
275
+ private resolveLayout(
276
+ data: IWalletPassData,
277
+ defaultLayout: IWalletLayoutField[]
278
+ ): Array<{ key: string; label: string; value: string; row: string }> {
279
+ const fields = (data.layout?.fields ?? defaultLayout)
280
+ .filter((f) => f.row !== 'hidden')
281
+ .sort((a, b) => a.position - b.position);
282
+
283
+ const result: Array<{ key: string; label: string; value: string; row: string }> = [];
284
+ for (const f of fields) {
285
+ const resolved = resolveField(f.key, data);
286
+ if (!resolved) continue;
287
+ result.push({ key: f.key, label: resolved.label, value: resolved.value, row: f.row });
288
+ }
289
+ return result;
290
+ }
291
+ }
@@ -0,0 +1,52 @@
1
+ import { parentPort, workerData } from "worker_threads";
2
+ import { PKPass } from "passkit-generator";
3
+
4
+ interface PassImage {
5
+ name: string;
6
+ buffer: Buffer;
7
+ }
8
+
9
+ interface WorkerData {
10
+ modelPath: string;
11
+ certificates: { signerCert: string; signerKey: string; wwdr: string };
12
+ passOverrides: Record<string, any>;
13
+ images: PassImage[];
14
+ barcodes: { message: string; format: string; messageEncoding: string };
15
+ layoutFields: Array<{ key: string; label: string; value: string; row: string }>;
16
+ relevantDate: string;
17
+ }
18
+
19
+ async function run() {
20
+ const data = workerData as WorkerData;
21
+
22
+ const pass = await PKPass.from(
23
+ {
24
+ model: data.modelPath,
25
+ certificates: data.certificates,
26
+ },
27
+ data.passOverrides
28
+ );
29
+
30
+ pass.setRelevantDate(new Date(data.relevantDate));
31
+
32
+ for (const img of data.images) {
33
+ pass.addBuffer(img.name, Buffer.from(img.buffer));
34
+ }
35
+
36
+ pass.setBarcodes(data.barcodes as any);
37
+
38
+ for (const f of data.layoutFields) {
39
+ const entry = { key: f.key, label: f.label, value: f.value };
40
+ if (f.row === "header") pass.headerFields.push(entry);
41
+ if (f.row === "secondary") pass.secondaryFields.push(entry);
42
+ if (f.row === "auxiliary") pass.auxiliaryFields.push(entry);
43
+ if (f.row === "back") pass.backFields.push(entry);
44
+ }
45
+
46
+ const buffer = pass.getAsBuffer();
47
+ parentPort!.postMessage(buffer);
48
+ }
49
+
50
+ run().catch((err) => {
51
+ parentPort!.postMessage({ error: err.message || String(err) });
52
+ });
Binary file
Binary file
@@ -0,0 +1,16 @@
1
+ {
2
+ "formatVersion": 1,
3
+ "passTypeIdentifier": "pass.il.co.nimi.event-ticket",
4
+ "teamIdentifier": "PLACEHOLDER",
5
+ "organizationName": "PLACEHOLDER",
6
+ "description": "Event Ticket",
7
+ "foregroundColor": "rgb(255, 255, 255)",
8
+ "backgroundColor": "rgb(60, 65, 76)",
9
+ "labelColor": "rgb(200, 200, 200)",
10
+ "eventTicket": {
11
+ "primaryFields": [],
12
+ "secondaryFields": [],
13
+ "auxiliaryFields": [],
14
+ "backFields": []
15
+ }
16
+ }
@@ -0,0 +1,104 @@
1
+ import axios from 'axios';
2
+ import jwt from 'jsonwebtoken';
3
+ import logger from '@nimee/logger';
4
+ import { IGoogleWalletConfig, IWalletPassData } from '../types';
5
+ import { ensureGoogleTicketClass, getGoogleAccessToken, buildGoogleTicketObject } from './googleAuth';
6
+
7
+ export class GooglePassGenerator {
8
+ /**
9
+ * Builds the full Google Wallet class ID.
10
+ * Uses config.classId if provided; otherwise derives from issuerId + data.classIdSuffix.
11
+ */
12
+ private buildClassId(data: IWalletPassData, config: IGoogleWalletConfig): string {
13
+ if (config.classId) {
14
+ return config.classId;
15
+ }
16
+ const suffix = data.classIdSuffix || data.objectId;
17
+ return `${config.issuerId}.${suffix}`;
18
+ }
19
+
20
+ /**
21
+ * Ensures the Google Wallet EventTicketClass exists (creates or patches).
22
+ * Delegates to the shared googleAuth helper.
23
+ */
24
+ private async ensureClass(classId: string, data: IWalletPassData, config: IGoogleWalletConfig): Promise<void> {
25
+ // Pass a config override with the resolved classId so ensureGoogleTicketClass
26
+ // uses exactly the classId we computed instead of recomputing it.
27
+ const configWithClass: IGoogleWalletConfig = { ...config, classId };
28
+ await ensureGoogleTicketClass(configWithClass, data);
29
+ }
30
+
31
+ /**
32
+ * Builds the Google Wallet ticket object payload.
33
+ * Delegates to the shared builder in googleAuth.
34
+ */
35
+ private buildObject(objectId: string, classId: string, data: IWalletPassData): object {
36
+ return buildGoogleTicketObject(objectId, classId, data);
37
+ }
38
+
39
+ /**
40
+ * Signs the JWT payload with the service account private key using RS256.
41
+ */
42
+ private signJwt(ticketObject: object, config: IGoogleWalletConfig): string {
43
+ const claims = {
44
+ iss: config.serviceAccountEmail,
45
+ aud: 'google',
46
+ origins: [] as string[],
47
+ typ: 'savetowallet',
48
+ payload: {
49
+ eventTicketObjects: [ticketObject],
50
+ },
51
+ };
52
+
53
+ return jwt.sign(claims, config.privateKey, { algorithm: 'RS256' });
54
+ }
55
+
56
+ /**
57
+ * Generates a Google Wallet "Add to Wallet" save URL for the given pass data.
58
+ */
59
+ async generateSaveUrl(data: IWalletPassData, config: IGoogleWalletConfig): Promise<string> {
60
+ const classId = this.buildClassId(data, config);
61
+
62
+ await this.ensureClass(classId, data, config);
63
+
64
+ const objectId = `${config.issuerId}.${data.objectId}`.replace(/[^a-zA-Z0-9_.\-]/g, '-');
65
+ const ticketObject = this.buildObject(objectId, classId, data);
66
+ const token = this.signJwt(ticketObject, config);
67
+
68
+ logger.info(`[GooglePassGenerator] save URL generated for objectId=${data.objectId}`);
69
+ return `https://pay.google.com/gp/v/save/${token}`;
70
+ }
71
+
72
+ /**
73
+ * PATCHes the Google Wallet object state (ACTIVE | EXPIRED | INACTIVE).
74
+ * Never throws — swallows all errors and logs a warning instead.
75
+ */
76
+ async updateObjectState(
77
+ rawObjectId: string,
78
+ config: IGoogleWalletConfig,
79
+ state: 'ACTIVE' | 'EXPIRED' | 'INACTIVE'
80
+ ): Promise<void> {
81
+ try {
82
+ const accessToken = await getGoogleAccessToken(config);
83
+ const encodedId = encodeURIComponent(rawObjectId);
84
+ const url = `https://walletobjects.googleapis.com/walletobjects/v1/eventTicketObject/${encodedId}`;
85
+
86
+ await axios.patch(
87
+ url,
88
+ { state },
89
+ {
90
+ headers: { Authorization: `Bearer ${accessToken}` },
91
+ timeout: 5000,
92
+ }
93
+ );
94
+
95
+ logger.info(`[GooglePassGenerator] object ${rawObjectId} state updated to ${state}`);
96
+ } catch (err) {
97
+ logger.warn(
98
+ `[GooglePassGenerator] failed to update object state for ${rawObjectId} → ${state}: ${
99
+ err instanceof Error ? err.message : String(err)
100
+ }`
101
+ );
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,134 @@
1
+ import axios from "axios";
2
+ import jwt from "jsonwebtoken";
3
+ import logger from "@nimee/logger";
4
+ import { IGoogleWalletConfig, IWalletPassData } from "../types";
5
+
6
+ // ─── Token cache ──────────────────────────────────────────────────────────────
7
+
8
+ interface IGoogleTokenCache {
9
+ accessToken: string;
10
+ expiresAt: number; // epoch ms
11
+ }
12
+
13
+ // Intentional module-level singletons — shared across all Wallet instances
14
+ // (each pod maintains its own in-memory cache).
15
+ const googleTokenCache = new Map<string, IGoogleTokenCache>();
16
+ const googleClassSyncedAt = new Map<string, number>(); // classId → epoch ms
17
+ const CLASS_SYNC_TTL_MS = 60 * 60 * 1000; // re-sync class at most once per hour
18
+
19
+ // ─── OAuth ────────────────────────────────────────────────────────────────────
20
+
21
+ /** Exchanges service account credentials for a Google OAuth2 access token (cached for ~1 hour). */
22
+ export async function getGoogleAccessToken(config: IGoogleWalletConfig): Promise<string> {
23
+ const cached = googleTokenCache.get(config.serviceAccountEmail);
24
+ if (cached && cached.expiresAt - Date.now() > 5 * 60 * 1000) {
25
+ return cached.accessToken;
26
+ }
27
+
28
+ const now = Math.floor(Date.now() / 1000);
29
+ const assertion = jwt.sign(
30
+ {
31
+ iss: config.serviceAccountEmail,
32
+ sub: config.serviceAccountEmail,
33
+ aud: "https://oauth2.googleapis.com/token",
34
+ scope: "https://www.googleapis.com/auth/wallet_object.issuer",
35
+ iat: now,
36
+ exp: now + 3600,
37
+ },
38
+ config.privateKey,
39
+ { algorithm: "RS256" }
40
+ );
41
+
42
+ const response = await axios.post(
43
+ "https://oauth2.googleapis.com/token",
44
+ new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }),
45
+ { headers: { "Content-Type": "application/x-www-form-urlencoded" }, timeout: 5000 }
46
+ );
47
+
48
+ const accessToken: string = response.data.access_token;
49
+ googleTokenCache.set(config.serviceAccountEmail, { accessToken, expiresAt: Date.now() + 3600 * 1000 });
50
+ return accessToken;
51
+ }
52
+
53
+ // ─── Class sync ───────────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Ensures the Google Wallet EventTicketClass exists and is up to date.
57
+ * Skipped if successfully synced within the last hour.
58
+ * Attempts to create; if it already exists (409), patches instead.
59
+ */
60
+ export async function ensureGoogleTicketClass(config: IGoogleWalletConfig, data: IWalletPassData): Promise<void> {
61
+ const classId = config.classId || `${config.issuerId}.event-${data.classIdSuffix || data.objectId}`;
62
+
63
+ if (Date.now() - (googleClassSyncedAt.get(classId) ?? 0) < CLASS_SYNC_TTL_MS) {
64
+ return;
65
+ }
66
+
67
+ const accessToken = await getGoogleAccessToken(config);
68
+ const classBody = buildGoogleTicketClass(classId, data);
69
+ const baseUrl = "https://walletobjects.googleapis.com/walletobjects/v1/eventTicketClass";
70
+ const headers = { Authorization: `Bearer ${accessToken}` };
71
+
72
+ try {
73
+ await axios.post(baseUrl, classBody, { headers, timeout: 5000 });
74
+ googleClassSyncedAt.set(classId, Date.now());
75
+ } catch (err: any) {
76
+ if (err.response?.status === 409) {
77
+ await axios.patch(`${baseUrl}/${encodeURIComponent(classId)}`, classBody, { headers, timeout: 5000 });
78
+ googleClassSyncedAt.set(classId, Date.now());
79
+ } else {
80
+ throw new Error(`failed to sync Google Wallet class: ${err.response?.data?.error?.message ?? err.message}`);
81
+ }
82
+ }
83
+ }
84
+
85
+ // ─── Object / class builders ──────────────────────────────────────────────────
86
+
87
+ export function buildGoogleTicketObject(objectId: string, classId: string, data: IWalletPassData): object {
88
+ const dateDisplay = data.dateDisplay;
89
+
90
+ return {
91
+ id: objectId,
92
+ classId,
93
+ state: data.isCheckedIn ? "COMPLETED" : "ACTIVE",
94
+ ticketHolderName: data.holderName,
95
+ issueName: data.marketplaceName || "",
96
+ ticketNumber: data.orderNumber || "",
97
+ ...(data.logoUrl && { logo: { sourceUri: { uri: data.logoUrl } } }),
98
+ barcode: {
99
+ type: "QR_CODE",
100
+ value: data.qrContent,
101
+ ...(data.orderNumber && { alternateText: data.orderNumber }),
102
+ },
103
+ eventName: {
104
+ defaultValue: { language: "en-US", value: data.passTitle || "" },
105
+ },
106
+ ...(data.backgroundColorHex && { hexBackgroundColor: data.backgroundColorHex }),
107
+ ...((data.seat) && {
108
+ seatInfo: {
109
+ ...(data.seat && { seat: { defaultValue: { language: "en-US", value: data.seat } } }),
110
+ },
111
+ }),
112
+ textModulesData: [
113
+ { header: "TICKET", body: data.passSubtitle || data.passTitle || "" },
114
+ ...(dateDisplay ? [{ header: "DATE", body: dateDisplay }] : []),
115
+ ],
116
+ validTimeInterval: {
117
+ start: { date: (data.startDate || new Date()).toISOString() },
118
+ ...(data.endDate && { end: { date: data.endDate.toISOString() } }),
119
+ },
120
+ };
121
+ }
122
+
123
+ export function buildGoogleTicketClass(classId: string, data: IWalletPassData): object {
124
+ return {
125
+ id: classId,
126
+ issuerName: data.marketplaceName || "",
127
+ reviewStatus: "UNDER_REVIEW",
128
+ eventName: {
129
+ defaultValue: { language: "en-US", value: data.passTitle || "" },
130
+ },
131
+ ...(data.logoUrl && { logo: { sourceUri: { uri: data.logoUrl } } }),
132
+ ...(data.stripImageUrl && { heroImage: { sourceUri: { uri: data.stripImageUrl } } }),
133
+ };
134
+ }
@@ -0,0 +1,34 @@
1
+ // ─── Color normalization ──────────────────────────────────────────────────────
2
+
3
+ export interface INormalizedColor {
4
+ rgb: string;
5
+ hex: string;
6
+ }
7
+
8
+ /**
9
+ * Normalizes "#3c4150", "3c4150", or "rgb(60, 65, 80)" to both Apple rgb() and hex formats.
10
+ * Returns undefined if the input is missing or invalid.
11
+ */
12
+ export function normalizeColor(raw: string): INormalizedColor | undefined {
13
+ if (!raw || !raw.trim()) return undefined;
14
+
15
+ const rgbMatch = raw.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
16
+ if (rgbMatch) {
17
+ const r = parseInt(rgbMatch[1]);
18
+ const g = parseInt(rgbMatch[2]);
19
+ const b = parseInt(rgbMatch[3]);
20
+ if (r > 255 || g > 255 || b > 255) return undefined;
21
+ const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
22
+ return { rgb: `rgb(${r},${g},${b})`, hex };
23
+ }
24
+
25
+ const cleaned = raw.replace('#', '');
26
+ if (/^[0-9a-fA-F]{6}$/.test(cleaned)) {
27
+ const r = parseInt(cleaned.slice(0, 2), 16);
28
+ const g = parseInt(cleaned.slice(2, 4), 16);
29
+ const b = parseInt(cleaned.slice(4, 6), 16);
30
+ return { rgb: `rgb(${r},${g},${b})`, hex: `#${cleaned.toLowerCase()}` };
31
+ }
32
+
33
+ return undefined;
34
+ }