@misiki/gocommerce-connector 0.29.0 → 0.30.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.
@@ -1087,7 +1087,22 @@ function toCartOffer(o, currency) {
1087
1087
  applies: o.applies,
1088
1088
  amount: o.amount ? toMajor(o.amount) : null,
1089
1089
  remaining: o.remaining ? toMajor(o.remaining) : null,
1090
- reason: o.reason ?? ""
1090
+ reason: o.reason ?? "",
1091
+ autoApply: !!o.auto_apply
1092
+ };
1093
+ }
1094
+ function toCheckoutQuote(q) {
1095
+ const d = q.discount;
1096
+ return {
1097
+ subtotal: toMajor(q.subtotal),
1098
+ shippingCharges: toMajor(q.shipping),
1099
+ shippingMethod: q.shipping_method ?? null,
1100
+ discount: toMajor(d?.amount),
1101
+ coupon: d ? { code: d.code, title: d.title, autoApplied: d.auto_applied, freeShipping: !!d.free_shipping } : null,
1102
+ tax: toMajor(q.tax),
1103
+ taxIncluded: q.tax_inclusive,
1104
+ codCharges: toMajor(q.payment_fee),
1105
+ total: toMajor(q.total)
1091
1106
  };
1092
1107
  }
1093
1108
  function toFreeShipping(f) {
@@ -1112,6 +1127,14 @@ function remember(key, load, ttl = TTL_MS) {
1112
1127
  });
1113
1128
  return value;
1114
1129
  }
1130
+ var known = /* @__PURE__ */ new Map();
1131
+ function keep(key, value) {
1132
+ known.set(key, value);
1133
+ return value;
1134
+ }
1135
+ function recall(key) {
1136
+ return known.get(key);
1137
+ }
1115
1138
 
1116
1139
  // src/services/facts.ts
1117
1140
  var StoreFacts = class extends BaseService {
@@ -2109,6 +2132,7 @@ var customizerService = new CustomizerService();
2109
2132
 
2110
2133
  // src/services/checkout-service.ts
2111
2134
  var CheckoutService = class extends BaseService {
2135
+ expected = /* @__PURE__ */ new Map();
2112
2136
  /** Delivery options for the address on the cart: `{ data: [{ id, name, base_rate }] }`. */
2113
2137
  async getShippingRates({ cartId } = {}) {
2114
2138
  const id2 = cartId || storedCartId();
@@ -2280,6 +2304,64 @@ var CheckoutService = class extends BaseService {
2280
2304
  );
2281
2305
  return { methods: options.methods ?? [], currency: options.currency };
2282
2306
  }
2307
+ /**
2308
+ * What checkout would charge for the cart now, by the storefront's method
2309
+ * code (`COD`, `RAZORPAY`, `PAYPAL`) or none yet (D185). Null until the
2310
+ * address step has stored an address, and null from an engine before D185.
2311
+ *
2312
+ * A refusal is checkout's own: the same GoCommerceError, with the same
2313
+ * message, that placing the order would raise now.
2314
+ */
2315
+ async getQuote({ cartId, method = "" } = {}) {
2316
+ const id2 = cartId || storedCartId();
2317
+ if (!id2 || !cartExtras(id2).shippingAddress) return null;
2318
+ const { extras, email, address } = await this.storedCheckout(id2);
2319
+ const code = method.toLowerCase() === "paypal" ? await this.redirectGateway() : method.toLowerCase();
2320
+ try {
2321
+ const quote2 = await this.data("/api/checkout/quote", {
2322
+ query: {
2323
+ cart: id2,
2324
+ country: address.country,
2325
+ state: address.state,
2326
+ shipping_rate_id: extras.shippingRateId || void 0,
2327
+ method: code || void 0,
2328
+ email: email || void 0
2329
+ }
2330
+ });
2331
+ return toCheckoutQuote(quote2);
2332
+ } catch (err) {
2333
+ if (err instanceof GoCommerceError && (err.status === 405 || err.status === 404 && err.message.startsWith("no route for "))) {
2334
+ return null;
2335
+ }
2336
+ throw err;
2337
+ }
2338
+ }
2339
+ /**
2340
+ * The total on screen as the shopper confirms, from getQuote, or null when nothing was quoted
2341
+ * (D185). Every order placed for the cart then carries it, and checkout refuses one whose own
2342
+ * total differs instead of charging it: `onChange` gets checkout's figures now and the sentence
2343
+ * the placing call fails with.
2344
+ */
2345
+ expectTotal({
2346
+ cartId,
2347
+ total,
2348
+ onChange
2349
+ }) {
2350
+ const id2 = cartId || storedCartId();
2351
+ if (!id2) return;
2352
+ if (typeof total === "number") this.expected.set(id2, { total, onChange });
2353
+ else this.expected.delete(id2);
2354
+ }
2355
+ async storedCheckout(cartId) {
2356
+ const id2 = cartId || storedCartId();
2357
+ if (!id2) throw new GoCommerceError("Your bag is empty.", 400);
2358
+ const extras = cartExtras(id2);
2359
+ const ship = extras.shippingAddress;
2360
+ if (!ship) throw new GoCommerceError("Add a delivery address first.", 400);
2361
+ const cart = await this.data(`/api/carts/${encodeURIComponent(id2)}`);
2362
+ const email = cart.email || (ship.email ? String(ship.email) : "");
2363
+ return { id: id2, extras, email, address: toEngineAddress(ship), currency: cart.currency };
2364
+ }
2283
2365
  async redirectGateway() {
2284
2366
  const { methods } = await this.listMethods();
2285
2367
  return methods.find((m) => REDIRECT_GATEWAYS.has(m.code))?.code;
@@ -2295,15 +2377,8 @@ var CheckoutService = class extends BaseService {
2295
2377
  * the engine refuses a key replayed with a different body.
2296
2378
  */
2297
2379
  async place(code, cartId, extra = {}) {
2298
- const id2 = cartId || storedCartId();
2299
- if (!id2) throw new GoCommerceError("Your bag is empty.", 400);
2300
- const extras = cartExtras(id2);
2301
- const ship = extras.shippingAddress;
2302
- if (!ship) throw new GoCommerceError("Add a delivery address first.", 400);
2303
- const cart = await this.data(`/api/carts/${encodeURIComponent(id2)}`);
2304
- const email = cart.email || (ship.email ? String(ship.email) : "");
2380
+ const { id: id2, extras, email, address, currency } = await this.storedCheckout(cartId);
2305
2381
  if (!email) throw new GoCommerceError("Add an email address so we can send your receipt.", 400);
2306
- const address = toEngineAddress(ship);
2307
2382
  const body = {
2308
2383
  cart_id: id2,
2309
2384
  email,
@@ -2326,10 +2401,23 @@ var CheckoutService = class extends BaseService {
2326
2401
  ...meta ? { meta_capi: meta } : {},
2327
2402
  ...attribution ? { attribution } : {}
2328
2403
  };
2404
+ const expected = this.expected.get(id2);
2329
2405
  const result = await this.data(`/api/checkout/${encodeURIComponent(code)}`, {
2330
2406
  method: "POST",
2331
- body: Object.keys(metadata).length ? { ...body, metadata } : body,
2407
+ body: {
2408
+ ...Object.keys(metadata).length ? { ...body, metadata } : body,
2409
+ ...expected ? { quoted_total_minor: toMinor(expected.total, currency) } : {}
2410
+ },
2332
2411
  headers: { "Idempotency-Key": key }
2412
+ }).catch((err) => {
2413
+ if (keyAlreadyUsed(err)) {
2414
+ throw new GoCommerceError("This order was already placed in another tab. Check that tab for its confirmation.", 400, err.code);
2415
+ }
2416
+ const now = movedTotal(err);
2417
+ if (!expected || !now) throw err;
2418
+ const message = `Your total changed to ${shopperAmount(now.total, currency)}. Check the new total and confirm again.`;
2419
+ expected.onChange?.(now, message);
2420
+ throw new GoCommerceError(message, 409, "total_changed", now);
2333
2421
  });
2334
2422
  if (result.order.access_token) {
2335
2423
  rememberOrder({
@@ -2352,6 +2440,21 @@ var CheckoutService = class extends BaseService {
2352
2440
  };
2353
2441
  var REDIRECT_GATEWAYS = /* @__PURE__ */ new Set(["adyen", "paddle", "lemonsqueezy", "creem", "hyperswitch", "revenuecat"]);
2354
2442
  var unsupported = (method, why) => new NotSupportedError("CheckoutService", method, why);
2443
+ function keyAlreadyUsed(err) {
2444
+ return err instanceof GoCommerceError && err.status === 400 && err.message.includes("Idempotency-Key was already used");
2445
+ }
2446
+ function movedTotal(err) {
2447
+ if (!(err instanceof GoCommerceError) || err.code !== "total_changed") return null;
2448
+ const now = err.details;
2449
+ return now?.total ? toCheckoutQuote(now) : null;
2450
+ }
2451
+ function shopperAmount(amount, currency) {
2452
+ return new Intl.NumberFormat(currency === "INR" ? "en-IN" : "en", {
2453
+ style: "currency",
2454
+ currency,
2455
+ ...Number.isInteger(amount) ? { minimumFractionDigits: 0 } : {}
2456
+ }).format(amount);
2457
+ }
2355
2458
  function newKey() {
2356
2459
  const c = globalThis.crypto;
2357
2460
  if (c?.randomUUID) return c.randomUUID();
@@ -2413,7 +2516,8 @@ var CouponService = class extends BaseService {
2413
2516
  minAmount: o.minAmount,
2414
2517
  description: o.applies ? o.title : o.remaining !== null ? `${o.title}. Add ${o.remaining} more to use this.` : o.reason || o.title,
2415
2518
  applies: o.applies,
2416
- remaining: o.remaining
2519
+ remaining: o.remaining,
2520
+ autoApply: o.autoApply
2417
2521
  }));
2418
2522
  return { data, count: data.length, pageSize: data.length, noOfPage: 1, page: 1 };
2419
2523
  }
@@ -3169,15 +3273,18 @@ var StoreService = class extends BaseService {
3169
3273
  async getStoreByIdOrDomain(_args = {}) {
3170
3274
  const base = await readStaticStore();
3171
3275
  const store = this.store;
3172
- if (store) {
3173
- await this.facts.plugins().catch((err) => {
3174
- if (isStoreMissing(err)) {
3276
+ const [plugins, live] = await Promise.all([
3277
+ this.facts.plugins().catch((err) => {
3278
+ if (store && isStoreMissing(err)) {
3175
3279
  throw new GoCommerceError(`store not found: no store "${store}" on this engine`, 404, "store_not_found");
3176
3280
  }
3177
- });
3178
- }
3179
- const live = await this.live().catch(() => ({}));
3180
- return { ...base, ...live, id: base.id || "gocommerce" };
3281
+ return void 0;
3282
+ }),
3283
+ this.live().catch(() => ({}))
3284
+ ]);
3285
+ const known2 = `${this.creds.apiUrl ?? ""}|${store ?? ""}|closed`;
3286
+ const closed = plugins ? keep(known2, closedOf(plugins)) : recall(known2);
3287
+ return { ...base, ...live, closed, id: base.id || "gocommerce" };
3181
3288
  }
3182
3289
  async getStore(storeId) {
3183
3290
  return this.getStoreByIdOrDomain({ storeId });
@@ -3212,6 +3319,46 @@ var StoreService = class extends BaseService {
3212
3319
  }
3213
3320
  return super.post(path, data);
3214
3321
  }
3322
+ /**
3323
+ * A closed store's password (D170), traded for a pass. Never throws. The
3324
+ * engine allows 20 tries an hour per client, and sees this server as the
3325
+ * client unless `clientAddress`, the shopper's, travels as `X-Forwarded-For`.
3326
+ */
3327
+ async unlockStore(password, { clientAddress } = {}) {
3328
+ const typed = password.trim();
3329
+ if (!typed) return { outcome: "wrong" };
3330
+ try {
3331
+ return { outcome: "unlocked", pass: await this.storePass({ password: typed }, clientAddress) };
3332
+ } catch (err) {
3333
+ return { outcome: err instanceof GoCommerceError && UNLOCK_REFUSED[err.status] || "unavailable" };
3334
+ }
3335
+ }
3336
+ /**
3337
+ * A pass checked without spending a try: the pass, or null when it is wrong,
3338
+ * expired, ended by a new password, or the store takes none now. Throws when
3339
+ * the engine cannot say.
3340
+ */
3341
+ async checkStorePass(token) {
3342
+ if (!token) return null;
3343
+ try {
3344
+ return await this.storePass({ token });
3345
+ } catch (err) {
3346
+ if (err instanceof GoCommerceError && (err.status === 422 || err.status === 404)) return null;
3347
+ throw err;
3348
+ }
3349
+ }
3350
+ async storePass(body, clientAddress) {
3351
+ const answer = await this.data("/api/store/password", {
3352
+ method: "POST",
3353
+ body,
3354
+ headers: clientAddress && ONE_IP_ADDRESS.test(clientAddress) ? { "X-Forwarded-For": clientAddress } : void 0
3355
+ });
3356
+ const expiresAt = new Date(answer?.expires_at ?? NaN);
3357
+ if (!answer?.token || Number.isNaN(expiresAt.getTime())) {
3358
+ throw new GoCommerceError("The commerce engine answered a store pass without its token or expiry.", 502);
3359
+ }
3360
+ return { token: answer.token, expiresAt };
3361
+ }
3215
3362
  async live() {
3216
3363
  const [ready, plugins, menus, base, profile, ships] = await Promise.all([
3217
3364
  this.facts.ready(),
@@ -3329,6 +3476,17 @@ var StoreService = class extends BaseService {
3329
3476
  }
3330
3477
  };
3331
3478
  var storeService = new StoreService();
3479
+ var ONE_IP_ADDRESS = /^[\da-f.:]{2,45}$/i;
3480
+ var UNLOCK_REFUSED = {
3481
+ 422: "wrong",
3482
+ 429: "throttled",
3483
+ 404: "open"
3484
+ };
3485
+ function closedOf(plugins) {
3486
+ const plugin = plugins?.get("store-close");
3487
+ if (!plugin) return void 0;
3488
+ return { message: String(plugin.settings?.message ?? ""), password: plugin.settings?.password === true };
3489
+ }
3332
3490
  var AD_TAGS = [
3333
3491
  ["meta-pixel", "pixel_id", "facebookPixel"],
3334
3492
  ["microsoft-clarity", "project_id", "microsoftClarity"],
@@ -3357,10 +3515,10 @@ function googleTags(plugins) {
3357
3515
  }
3358
3516
  function deliverable(base, ships) {
3359
3517
  if (ships.everywhere || !ships.countries.length) return {};
3360
- const known = new Map((base.countries ?? []).map((c) => [String(c.code).toUpperCase(), c]));
3361
- const countries = ships.countries.map((code) => known.get(code) ?? { code, name: regionName(code) });
3362
- const keep = base.country && ships.countries.includes(String(base.country.code).toUpperCase());
3363
- return { countries, ...keep ? {} : { country: countries[0] } };
3518
+ const known2 = new Map((base.countries ?? []).map((c) => [String(c.code).toUpperCase(), c]));
3519
+ const countries = ships.countries.map((code) => known2.get(code) ?? { code, name: regionName(code) });
3520
+ const keep2 = base.country && ships.countries.includes(String(base.country.code).toUpperCase());
3521
+ return { countries, ...keep2 ? {} : { country: countries[0] } };
3364
3522
  }
3365
3523
  function regionName(code) {
3366
3524
  try {
@@ -3515,7 +3673,8 @@ function toSection(s) {
3515
3673
  title: c.title,
3516
3674
  slug: c.slug,
3517
3675
  link: `/collections/${c.slug}`,
3518
- image: mediaUrl(c.image) ?? null
3676
+ image: mediaUrl(c.image) ?? null,
3677
+ description: c.description || null
3519
3678
  }));
3520
3679
  }
3521
3680
  return out;