fresh-squeezy 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,599 @@
1
+ // src/core/errors.ts
2
+ var FreshSqueezyError = class _FreshSqueezyError extends Error {
3
+ code;
4
+ status;
5
+ detail;
6
+ constructor(opts) {
7
+ super(opts.message);
8
+ this.name = "FreshSqueezyError";
9
+ this.code = opts.code;
10
+ this.status = opts.status;
11
+ this.detail = opts.detail;
12
+ Object.setPrototypeOf(this, _FreshSqueezyError.prototype);
13
+ }
14
+ };
15
+
16
+ // src/core/config.ts
17
+ var DEFAULT_BASE_URL = "https://api.lemonsqueezy.com";
18
+ var ENV_KEYS = {
19
+ apiKey: "LEMON_SQUEEZY_API_KEY",
20
+ storeId: "LEMON_SQUEEZY_STORE_ID",
21
+ mode: "LEMON_SQUEEZY_MODE"
22
+ };
23
+ function resolveConfig(input = {}) {
24
+ const apiKey = input.apiKey ?? process.env[ENV_KEYS.apiKey];
25
+ if (!apiKey) {
26
+ throw new FreshSqueezyError({
27
+ code: "MISSING_API_KEY",
28
+ message: `No API key provided. Pass \`apiKey\` or set ${ENV_KEYS.apiKey}.`
29
+ });
30
+ }
31
+ const mode = normalizeMode(input.mode ?? process.env[ENV_KEYS.mode] ?? "test");
32
+ const storeIdRaw = input.storeId ?? process.env[ENV_KEYS.storeId];
33
+ return {
34
+ apiKey,
35
+ storeId: storeIdRaw == null ? void 0 : String(storeIdRaw),
36
+ mode,
37
+ baseUrl: input.baseUrl ?? DEFAULT_BASE_URL,
38
+ fetch: input.fetch ?? globalThis.fetch
39
+ };
40
+ }
41
+ function normalizeMode(value) {
42
+ if (value === "test" || value === "live") return value;
43
+ throw new FreshSqueezyError({
44
+ code: "INVALID_MODE",
45
+ message: `Mode must be "test" or "live", got "${value}".`
46
+ });
47
+ }
48
+
49
+ // src/core/http.ts
50
+ var HttpClient = class {
51
+ constructor(config) {
52
+ this.config = config;
53
+ }
54
+ config;
55
+ async request(options) {
56
+ const url = this.buildUrl(options.path, options.query);
57
+ const headers = {
58
+ Authorization: `Bearer ${this.config.apiKey}`,
59
+ Accept: "application/vnd.api+json"
60
+ };
61
+ if (options.body !== void 0) {
62
+ headers["Content-Type"] = "application/vnd.api+json";
63
+ }
64
+ let response;
65
+ try {
66
+ response = await this.config.fetch(url, {
67
+ method: options.method ?? "GET",
68
+ headers,
69
+ body: options.body === void 0 ? void 0 : JSON.stringify(options.body),
70
+ signal: options.signal
71
+ });
72
+ } catch (cause) {
73
+ throw new FreshSqueezyError({
74
+ code: "NETWORK_ERROR",
75
+ message: cause instanceof Error ? cause.message : "Network request failed",
76
+ detail: cause
77
+ });
78
+ }
79
+ const text = await response.text();
80
+ const parsed = text.length > 0 ? safeJsonParse(text) : void 0;
81
+ if (!response.ok) {
82
+ throw toApiError(response.status, parsed);
83
+ }
84
+ return parsed;
85
+ }
86
+ /**
87
+ * Fetch a single JSON:API resource and return its `data` object.
88
+ */
89
+ async getResource(path) {
90
+ const doc = await this.request({ path });
91
+ return doc.data;
92
+ }
93
+ /**
94
+ * Fetch a JSON:API collection and return its `data` array.
95
+ * Pagination is the caller's responsibility — use `meta.page` on the raw
96
+ * request for multi-page traversal.
97
+ */
98
+ async getCollection(path, query) {
99
+ const doc = await this.request({ path, query });
100
+ return doc.data;
101
+ }
102
+ buildUrl(path, query) {
103
+ const url = new URL(path, this.config.baseUrl);
104
+ if (query) {
105
+ for (const [key, value] of Object.entries(query)) {
106
+ if (value === void 0) continue;
107
+ url.searchParams.append(key, String(value));
108
+ }
109
+ }
110
+ return url.toString();
111
+ }
112
+ };
113
+ function safeJsonParse(text) {
114
+ try {
115
+ return JSON.parse(text);
116
+ } catch {
117
+ return text;
118
+ }
119
+ }
120
+ function toApiError(status, body) {
121
+ const errors = extractJsonApiErrors(body);
122
+ const first = errors[0];
123
+ const code = status === 401 ? "UNAUTHORIZED" : status === 404 ? "NOT_FOUND" : status === 429 ? "RATE_LIMITED" : first?.code ?? `HTTP_${status}`;
124
+ const message = first?.detail ?? first?.title ?? `Lemon Squeezy request failed with status ${status}`;
125
+ return new FreshSqueezyError({ code, status, message, detail: body });
126
+ }
127
+ function extractJsonApiErrors(body) {
128
+ if (!body || typeof body !== "object") return [];
129
+ const errors = body.errors;
130
+ if (!Array.isArray(errors)) return [];
131
+ return errors.filter(
132
+ (entry) => typeof entry === "object" && entry !== null
133
+ );
134
+ }
135
+
136
+ // src/resources/users.ts
137
+ async function getAuthenticatedUser(http) {
138
+ return http.request({ path: "/v1/users/me" });
139
+ }
140
+ function userResource(doc) {
141
+ return doc.data;
142
+ }
143
+
144
+ // src/resources/stores.ts
145
+ async function getStore(http, storeId) {
146
+ return http.getResource(`/v1/stores/${storeId}`);
147
+ }
148
+ async function listStores(http) {
149
+ return http.getCollection("/v1/stores");
150
+ }
151
+
152
+ // src/validate/rules.ts
153
+ var ISSUE_CODES = {
154
+ AUTH_FAILED: "AUTH_FAILED",
155
+ MODE_MISMATCH: "MODE_MISMATCH",
156
+ STORE_NOT_FOUND: "STORE_NOT_FOUND",
157
+ STORE_NOT_OWNED: "STORE_NOT_OWNED",
158
+ PRODUCT_NOT_FOUND: "PRODUCT_NOT_FOUND",
159
+ PRODUCT_WRONG_STORE: "PRODUCT_WRONG_STORE",
160
+ PRODUCT_UNPUBLISHED: "PRODUCT_UNPUBLISHED",
161
+ PRODUCT_NO_BUY_URL: "PRODUCT_NO_BUY_URL",
162
+ VARIANT_UNPUBLISHED: "VARIANT_UNPUBLISHED",
163
+ VARIANT_MISSING: "VARIANT_MISSING",
164
+ WEBHOOK_NOT_FOUND: "WEBHOOK_NOT_FOUND",
165
+ WEBHOOK_EVENTS_MISSING: "WEBHOOK_EVENTS_MISSING",
166
+ WEBHOOK_OPTIONAL_EVENTS: "WEBHOOK_OPTIONAL_EVENTS",
167
+ NETWORK_ERROR: "NETWORK_ERROR",
168
+ UNKNOWN: "UNKNOWN"
169
+ };
170
+ function issue(code, severity, message, extras = {}) {
171
+ const base = { code, severity, message };
172
+ if (extras.suggestedFix !== void 0) base.suggestedFix = extras.suggestedFix;
173
+ if (extras.context !== void 0) base.context = extras.context;
174
+ return base;
175
+ }
176
+ function isOk(issues) {
177
+ return !issues.some((entry) => entry.severity === "error");
178
+ }
179
+ function buildResult(name, mode, issues, resource) {
180
+ const result = {
181
+ name,
182
+ ok: isOk(issues),
183
+ mode,
184
+ issues
185
+ };
186
+ if (resource !== void 0) result.resource = resource;
187
+ return result;
188
+ }
189
+
190
+ // src/validate/connection.ts
191
+ async function validateConnection(http, mode) {
192
+ const issues = [];
193
+ try {
194
+ const userDoc = await getAuthenticatedUser(http);
195
+ const stores = await listStores(http);
196
+ const actualMode = resolveActualMode(userDoc.meta?.test_mode);
197
+ const summary = {
198
+ user: userDoc.data.attributes,
199
+ storeCount: stores.length,
200
+ storeIds: stores.map((store) => store.id),
201
+ declaredMode: mode,
202
+ ...actualMode ? { actualMode } : {}
203
+ };
204
+ if (actualMode && actualMode !== mode) {
205
+ issues.push(
206
+ issue(
207
+ ISSUE_CODES.MODE_MISMATCH,
208
+ "error",
209
+ `API key is a ${actualMode}-mode key but was run with --mode ${mode}.`,
210
+ {
211
+ suggestedFix: `Either pass --mode ${actualMode} or use a ${mode}-mode key from https://app.lemonsqueezy.com/settings/api.`,
212
+ context: { declared: mode, actual: actualMode }
213
+ }
214
+ )
215
+ );
216
+ }
217
+ if (stores.length === 0) {
218
+ issues.push(
219
+ issue(
220
+ ISSUE_CODES.STORE_NOT_FOUND,
221
+ "warning",
222
+ "API key authenticated but no stores are reachable.",
223
+ { suggestedFix: "Confirm the API key belongs to an account that owns at least one store." }
224
+ )
225
+ );
226
+ }
227
+ return buildResult("connection", mode, issues, summary);
228
+ } catch (err) {
229
+ issues.push(toConnectionIssue(err));
230
+ return buildResult("connection", mode, issues);
231
+ }
232
+ }
233
+ function resolveActualMode(testMode) {
234
+ if (testMode === true) return "test";
235
+ if (testMode === false) return "live";
236
+ return void 0;
237
+ }
238
+ function toConnectionIssue(err) {
239
+ if (err instanceof FreshSqueezyError) {
240
+ if (err.code === "UNAUTHORIZED") {
241
+ return issue(ISSUE_CODES.AUTH_FAILED, "error", "API key rejected by Lemon Squeezy.", {
242
+ suggestedFix: "Regenerate the key at https://app.lemonsqueezy.com/settings/api.",
243
+ context: { status: err.status ?? null }
244
+ });
245
+ }
246
+ if (err.code === "NETWORK_ERROR") {
247
+ return issue(ISSUE_CODES.NETWORK_ERROR, "error", `Could not reach Lemon Squeezy: ${err.message}`);
248
+ }
249
+ return issue(ISSUE_CODES.UNKNOWN, "error", err.message, {
250
+ context: { status: err.status ?? null, code: err.code }
251
+ });
252
+ }
253
+ const message = err instanceof Error ? err.message : "Unknown error";
254
+ return issue(ISSUE_CODES.UNKNOWN, "error", message);
255
+ }
256
+
257
+ // src/validate/store.ts
258
+ async function validateStore(http, mode, storeId) {
259
+ const issues = [];
260
+ try {
261
+ const store = await getStore(http, storeId);
262
+ return buildResult("store", mode, issues, store.attributes);
263
+ } catch (err) {
264
+ if (err instanceof FreshSqueezyError && err.status === 404) {
265
+ issues.push(
266
+ issue(
267
+ ISSUE_CODES.STORE_NOT_FOUND,
268
+ "error",
269
+ `Store ${storeId} not found with the current API key.`,
270
+ {
271
+ suggestedFix: "Check the store ID and confirm the API key belongs to the account that owns it.",
272
+ context: { storeId: String(storeId) }
273
+ }
274
+ )
275
+ );
276
+ return buildResult("store", mode, issues);
277
+ }
278
+ if (err instanceof FreshSqueezyError) {
279
+ issues.push(
280
+ issue(ISSUE_CODES.UNKNOWN, "error", err.message, {
281
+ context: { status: err.status ?? null, code: err.code }
282
+ })
283
+ );
284
+ return buildResult("store", mode, issues);
285
+ }
286
+ const message = err instanceof Error ? err.message : "Unknown error";
287
+ issues.push(issue(ISSUE_CODES.UNKNOWN, "error", message));
288
+ return buildResult("store", mode, issues);
289
+ }
290
+ }
291
+
292
+ // src/resources/products.ts
293
+ async function getProduct(http, productId) {
294
+ return http.getResource(`/v1/products/${productId}`);
295
+ }
296
+ async function listProducts(http, storeId) {
297
+ return http.getCollection("/v1/products", {
298
+ "filter[store_id]": String(storeId)
299
+ });
300
+ }
301
+
302
+ // src/resources/variants.ts
303
+ async function listVariantsForProduct(http, productId) {
304
+ return http.getCollection("/v1/variants", {
305
+ "filter[product_id]": String(productId)
306
+ });
307
+ }
308
+
309
+ // src/validate/product.ts
310
+ async function validateProduct(http, mode, options) {
311
+ const issues = [];
312
+ let product;
313
+ try {
314
+ product = await getProduct(http, options.productId);
315
+ } catch (err) {
316
+ if (err instanceof FreshSqueezyError && err.status === 404) {
317
+ issues.push(
318
+ issue(
319
+ ISSUE_CODES.PRODUCT_NOT_FOUND,
320
+ "error",
321
+ `Product ${options.productId} not found.`,
322
+ {
323
+ suggestedFix: "Verify the product ID in the Lemon Squeezy dashboard.",
324
+ context: { productId: String(options.productId) }
325
+ }
326
+ )
327
+ );
328
+ return buildResult("product", mode, issues);
329
+ }
330
+ const message = err instanceof Error ? err.message : "Unknown error";
331
+ issues.push(issue(ISSUE_CODES.UNKNOWN, "error", message));
332
+ return buildResult("product", mode, issues);
333
+ }
334
+ const attrs = product.attributes;
335
+ if (options.expectedStoreId !== void 0) {
336
+ const expected = String(options.expectedStoreId);
337
+ const actual = String(attrs.store_id);
338
+ if (expected !== actual) {
339
+ issues.push(
340
+ issue(
341
+ ISSUE_CODES.PRODUCT_WRONG_STORE,
342
+ "error",
343
+ `Product belongs to store ${actual}, expected ${expected}.`,
344
+ {
345
+ suggestedFix: "Either use the correct store ID or the correct product ID \u2014 IDs should not cross stores.",
346
+ context: { expectedStoreId: expected, actualStoreId: actual }
347
+ }
348
+ )
349
+ );
350
+ }
351
+ }
352
+ if (attrs.status !== "published") {
353
+ issues.push(
354
+ issue(
355
+ ISSUE_CODES.PRODUCT_UNPUBLISHED,
356
+ "error",
357
+ `Product is in "${attrs.status}" state, not "published".`,
358
+ {
359
+ suggestedFix: "Publish the product in the Lemon Squeezy dashboard before selling.",
360
+ context: { status: attrs.status }
361
+ }
362
+ )
363
+ );
364
+ }
365
+ if (!attrs.buy_now_url) {
366
+ issues.push(
367
+ issue(
368
+ ISSUE_CODES.PRODUCT_NO_BUY_URL,
369
+ "warning",
370
+ "Product has no buy-now URL. Hosted checkout may be disabled.",
371
+ { suggestedFix: "Enable buy-now in product settings, or use a custom checkout flow." }
372
+ )
373
+ );
374
+ }
375
+ try {
376
+ const variants = await listVariantsForProduct(http, options.productId);
377
+ if (variants.length === 0) {
378
+ issues.push(
379
+ issue(
380
+ ISSUE_CODES.VARIANT_MISSING,
381
+ "error",
382
+ "Product has no variants. Customers cannot purchase it.",
383
+ { suggestedFix: "Add at least one variant in the product configuration." }
384
+ )
385
+ );
386
+ } else if (!variants.some((variant) => variant.attributes.status === "published")) {
387
+ issues.push(
388
+ issue(
389
+ ISSUE_CODES.VARIANT_UNPUBLISHED,
390
+ "error",
391
+ "Product has variants but none are published.",
392
+ { suggestedFix: "Publish at least one variant." }
393
+ )
394
+ );
395
+ }
396
+ } catch (err) {
397
+ const message = err instanceof Error ? err.message : "Unknown error fetching variants";
398
+ issues.push(issue(ISSUE_CODES.UNKNOWN, "warning", message));
399
+ }
400
+ return buildResult("product", mode, issues, attrs);
401
+ }
402
+
403
+ // src/resources/webhooks.ts
404
+ async function listWebhooksForStore(http, storeId) {
405
+ return http.getCollection("/v1/webhooks", {
406
+ "filter[store_id]": String(storeId)
407
+ });
408
+ }
409
+
410
+ // src/support/manifest.ts
411
+ var SUPPORTED_RESOURCES = [
412
+ "users",
413
+ "stores",
414
+ "products",
415
+ "variants",
416
+ "webhooks"
417
+ ];
418
+ var RECOMMENDED_WEBHOOK_EVENTS = [
419
+ "order_created",
420
+ "order_refunded",
421
+ "subscription_created",
422
+ "subscription_updated",
423
+ "subscription_cancelled",
424
+ "subscription_resumed",
425
+ "subscription_expired",
426
+ "subscription_payment_success",
427
+ "subscription_payment_failed"
428
+ ];
429
+ var OPTIONAL_WEBHOOK_EVENTS = [
430
+ "customer_updated",
431
+ "affiliate_activated",
432
+ "license_key_created",
433
+ "license_key_updated"
434
+ ];
435
+ var ACKNOWLEDGED_CHANGELOG_ENTRIES = [
436
+ {
437
+ date: "2026-02-25",
438
+ summary: "Added customer_updated webhook event.",
439
+ handledBy: "OPTIONAL_WEBHOOK_EVENTS"
440
+ },
441
+ {
442
+ date: "2025-06-11",
443
+ summary: "Added payment_processor attribute to Subscription objects.",
444
+ handledBy: "Not wrapped \u2014 reachable via client.request('/v1/subscriptions/:id'). Add a validator only if a real integration needs it."
445
+ },
446
+ {
447
+ date: "2025-01-21",
448
+ summary: "Added Affiliates endpoints and affiliate_activated webhook.",
449
+ handledBy: "OPTIONAL_WEBHOOK_EVENTS (event only; resource stays v2 scope)"
450
+ },
451
+ {
452
+ date: "2024-01-05",
453
+ summary: "Added test_mode flag to /v1/users/me meta.",
454
+ handledBy: "Read in validateConnection to emit MODE_MISMATCH when the key's true mode differs from the caller's declared mode."
455
+ }
456
+ ];
457
+
458
+ // src/validate/webhook.ts
459
+ async function validateWebhook(http, mode, options) {
460
+ const issues = [];
461
+ let webhooks;
462
+ try {
463
+ webhooks = await listWebhooksForStore(http, options.storeId);
464
+ } catch (err) {
465
+ if (err instanceof FreshSqueezyError) {
466
+ issues.push(
467
+ issue(ISSUE_CODES.UNKNOWN, "error", err.message, {
468
+ context: { status: err.status ?? null, code: err.code }
469
+ })
470
+ );
471
+ return buildResult("webhook", mode, issues);
472
+ }
473
+ const message = err instanceof Error ? err.message : "Unknown error";
474
+ issues.push(issue(ISSUE_CODES.UNKNOWN, "error", message));
475
+ return buildResult("webhook", mode, issues);
476
+ }
477
+ const match = webhooks.find((webhook) => normalizeUrl(webhook.attributes.url) === normalizeUrl(options.url));
478
+ if (!match) {
479
+ issues.push(
480
+ issue(
481
+ ISSUE_CODES.WEBHOOK_NOT_FOUND,
482
+ "error",
483
+ `No webhook registered for URL ${options.url} on store ${options.storeId}.`,
484
+ {
485
+ suggestedFix: "Register the webhook in Lemon Squeezy (Settings \u2192 Webhooks) and subscribe to the recommended events.",
486
+ context: { storeId: String(options.storeId), url: options.url }
487
+ }
488
+ )
489
+ );
490
+ return buildResult("webhook", mode, issues);
491
+ }
492
+ const subscribed = new Set(match.attributes.events);
493
+ const missingRecommended = RECOMMENDED_WEBHOOK_EVENTS.filter((event) => !subscribed.has(event));
494
+ const missingOptional = OPTIONAL_WEBHOOK_EVENTS.filter((event) => !subscribed.has(event));
495
+ if (missingRecommended.length > 0) {
496
+ issues.push(
497
+ issue(
498
+ ISSUE_CODES.WEBHOOK_EVENTS_MISSING,
499
+ "error",
500
+ `Webhook is missing recommended events: ${missingRecommended.join(", ")}.`,
501
+ {
502
+ suggestedFix: "Subscribe to all recommended events so the integration survives plan changes and refunds.",
503
+ context: { missing: missingRecommended.join(",") }
504
+ }
505
+ )
506
+ );
507
+ }
508
+ if (missingOptional.length > 0) {
509
+ issues.push(
510
+ issue(
511
+ ISSUE_CODES.WEBHOOK_OPTIONAL_EVENTS,
512
+ "info",
513
+ `Optional events not subscribed: ${missingOptional.join(", ")}.`,
514
+ { context: { missing: missingOptional.join(",") } }
515
+ )
516
+ );
517
+ }
518
+ return buildResult("webhook", mode, issues, match.attributes);
519
+ }
520
+ function normalizeUrl(raw) {
521
+ return raw.replace(/\/+$/, "").toLowerCase();
522
+ }
523
+
524
+ // src/validate/doctor.ts
525
+ async function doctor(http, mode, options = {}) {
526
+ const results = [];
527
+ const connection = await validateConnection(http, mode);
528
+ results.push(connection);
529
+ if (!connection.ok) {
530
+ return { ok: false, mode, results };
531
+ }
532
+ if (options.storeId !== void 0) {
533
+ results.push(await validateStore(http, mode, options.storeId));
534
+ }
535
+ if (options.productId !== void 0) {
536
+ results.push(
537
+ await validateProduct(http, mode, {
538
+ productId: options.productId,
539
+ expectedStoreId: options.storeId
540
+ })
541
+ );
542
+ }
543
+ if (options.storeId !== void 0 && options.webhookUrl !== void 0) {
544
+ results.push(
545
+ await validateWebhook(http, mode, {
546
+ storeId: options.storeId,
547
+ url: options.webhookUrl
548
+ })
549
+ );
550
+ }
551
+ const ok = results.every((result) => result.ok);
552
+ return { ok, mode, results };
553
+ }
554
+
555
+ // src/createFreshSqueezy.ts
556
+ function createFreshSqueezy(config = {}) {
557
+ const resolved = resolveConfig(config);
558
+ const http = new HttpClient(resolved);
559
+ return {
560
+ mode: resolved.mode,
561
+ request: (options) => http.request(options),
562
+ validateConnection: () => validateConnection(http, resolved.mode),
563
+ validateStore: (storeId) => validateStore(http, resolved.mode, storeId),
564
+ validateProduct: (options) => validateProduct(http, resolved.mode, options),
565
+ validateWebhook: (options) => validateWebhook(http, resolved.mode, options),
566
+ doctor: (options) => doctor(http, resolved.mode, {
567
+ storeId: options?.storeId ?? resolved.storeId,
568
+ productId: options?.productId,
569
+ webhookUrl: options?.webhookUrl
570
+ })
571
+ };
572
+ }
573
+ export {
574
+ ACKNOWLEDGED_CHANGELOG_ENTRIES,
575
+ ENV_KEYS,
576
+ FreshSqueezyError,
577
+ ISSUE_CODES,
578
+ OPTIONAL_WEBHOOK_EVENTS,
579
+ RECOMMENDED_WEBHOOK_EVENTS,
580
+ SUPPORTED_RESOURCES,
581
+ buildResult,
582
+ createFreshSqueezy,
583
+ doctor,
584
+ getAuthenticatedUser,
585
+ getProduct,
586
+ getStore,
587
+ isOk,
588
+ issue,
589
+ listProducts,
590
+ listStores,
591
+ listVariantsForProduct,
592
+ listWebhooksForStore,
593
+ resolveConfig,
594
+ userResource,
595
+ validateConnection,
596
+ validateProduct,
597
+ validateWebhook
598
+ };
599
+ //# sourceMappingURL=index.js.map