@rebuy/rebuy 3.16.3 → 3.17.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.
@@ -14,5 +14,5 @@ export { fetchUserConfig } from '../server/userConfig';
14
14
  export { validateGifts } from '../server/giftValidation';
15
15
  export { fetchMonetizeOffers, type MonetizeOffers } from '../server/monetizeOffers';
16
16
  export { postAnalyticsEvents, translateAnalyticsEvent, type CabAnalyticsEventName } from '../server/analytics';
17
- export { AnalyticsEventInputSchema, AnalyticsInputSchema, CartInputSchema, CartLineInputSchema, DataSourceInputSchema, GiftInputSchema, GiftValidationInputSchema, MonetizeInputSchema, MonetizeTransactionItemSchema, REQUEST_BOUNDS, UserConfigInputSchema, WidgetSettingsInputSchema, } from '../server/requestSchemas';
18
- export { NotFoundError, REBUY_ENGINE_HOST, UpstreamError, type AnalyticsEventInput, type AnalyticsInput, type CartInput, type CartLineInput, type DataSourceInput, type GiftInput, type GiftValidationInput, type MonetizeInput, type MonetizeTransactionItem, type ServerContext, type ServerDrift, type UpstreamFetch, type UserConfigInput, type WidgetSettingsInput, } from '../server/shared';
17
+ export { AnalyticsEventInputSchema, AnalyticsInputSchema, CartInputSchema, CartLineInputSchema, DataSourceInputSchema, GiftAdjustmentSchema, GiftInputSchema, GiftValidationInputSchema, GiftValidationResponseSchema, MonetizeInputSchema, MonetizeTransactionItemSchema, REQUEST_BOUNDS, UserConfigInputSchema, WidgetSettingsInputSchema, } from '../server/requestSchemas';
18
+ export { NotFoundError, REBUY_ENGINE_HOST, UpstreamError, type AnalyticsEventInput, type AnalyticsInput, type CartInput, type CartLineInput, type DataSourceInput, type GiftAdjustment, type GiftInput, type GiftValidationInput, type GiftValidationResponse, type MonetizeInput, type MonetizeTransactionItem, type ServerContext, type ServerDrift, type UpstreamFetch, type UserConfigInput, type WidgetSettingsInput, } from '../server/shared';
@@ -23,6 +23,9 @@ var isDefinitiveRuleAnswer = (metadata) => metadata != null && (metadata.matched
23
23
  var hasLostDiscount = (gift) => !gift.discounted && gift.cost > 0;
24
24
  var shouldRemoveGift = (gift, validIds) => !validIds.has(gift.productId) || hasLostDiscount(gift);
25
25
 
26
+ // src/gwp/types.ts
27
+ var GIFT_KINDS = ["gwp", "sgwp"];
28
+
26
29
  // src/schema/metadata.ts
27
30
  import { z as z2 } from "zod";
28
31
 
@@ -382,6 +385,14 @@ var NotFoundError = class extends Error {
382
385
  }
383
386
  };
384
387
 
388
+ // src/server/shopSubtotal.ts
389
+ var shopCurrencyRate = (cart) => {
390
+ const rate = cart.currencyRate ?? 1;
391
+ return Number.isFinite(rate) && rate > 0 ? rate : 1;
392
+ };
393
+ var shopSubtotalCents = (cart) => Math.round((cart.subtotal - (cart.discountTotal ?? 0)) / shopCurrencyRate(cart));
394
+ var toShopCents = (majorPresentment, rate) => Math.round(majorPresentment * 100 / rate);
395
+
385
396
  // src/schema/cabShopConfig.ts
386
397
  import { z as z6 } from "zod";
387
398
 
@@ -3875,9 +3886,7 @@ var buildEngineParams = (input, key) => {
3875
3886
  variant_id: variantId
3876
3887
  }));
3877
3888
  const itemCount = cart.items.reduce((total, { quantity }) => total + quantity, 0);
3878
- const currencyRate = cart.currencyRate ?? 1;
3879
- const rate = Number.isFinite(currencyRate) && currencyRate > 0 ? currencyRate : 1;
3880
- const subtotal = Math.round((cart.subtotal - (cart.discountTotal ?? 0)) / rate);
3889
+ const subtotal = shopSubtotalCents(cart);
3881
3890
  return {
3882
3891
  cart: {
3883
3892
  attributes: encodeAttributes(cart.attributes),
@@ -4350,26 +4359,92 @@ var convertWidgetSettings = async (input, ctx) => {
4350
4359
  return experiment && isCABTree(result) ? { ...result, experiment } : result;
4351
4360
  };
4352
4361
 
4353
- // src/server/giftValidation.ts
4354
- var resolveDataSourcePath = async (widgetId, shop, ctx) => {
4355
- const raw = await ctx.fetchUpstream("/api/v1/widgets/settings", { id: String(widgetId), shop }, { host: ctx.host });
4356
- const data = raw && typeof raw === "object" && "data" in raw ? raw.data : raw;
4357
- const endpoint = deepCamelCase(data)?.endpoint;
4362
+ // src/server/giftWidgets.ts
4363
+ import { z as z29 } from "zod";
4364
+ var SettingsNumber = z29.union([
4365
+ z29.number(),
4366
+ z29.string().trim().regex(/^-?\d+(\.\d+)?$/)
4367
+ ]).pipe(z29.coerce.number());
4368
+ var SgwpMilestoneSchema = z29.object({
4369
+ selectable_product_quantity: SettingsNumber.pipe(z29.number().int().nonnegative()),
4370
+ unlock_price: SettingsNumber
4371
+ });
4372
+ var fetchGiftWidgetSettings = async (widgetId, shop, ctx) => unwrapData(await ctx.fetchUpstream("/api/v1/widgets/settings", { id: String(widgetId), shop }, { host: ctx.host }));
4373
+ var readDataSourcePath = (settings) => {
4374
+ const endpoint = deepCamelCase(settings)?.endpoint;
4358
4375
  return typeof endpoint === "string" && endpoint ? endpoint : void 0;
4359
4376
  };
4360
- var groupByWidget = (gifts) => {
4377
+ var readMilestones = (settings, report) => {
4378
+ const raw = settings && typeof settings === "object" ? settings.milestones : void 0;
4379
+ if (raw == null) return [];
4380
+ const parsed = z29.array(SgwpMilestoneSchema).safeParse(raw);
4381
+ if (parsed.success) return parsed.data;
4382
+ report?.({
4383
+ detail: [
4384
+ "selectable-gift milestones unreadable",
4385
+ ...parsed.error.issues.slice(0, 1).map((issue) => [issue.path.join("."), issue.message].filter(Boolean).join(": "))
4386
+ ].join(": "),
4387
+ kind: "giftMilestones"
4388
+ });
4389
+ return [];
4390
+ };
4391
+ var groupGiftsByWidget = (gifts, kind) => {
4361
4392
  const byWidget = /* @__PURE__ */ new Map();
4362
4393
  for (const gift of gifts) {
4394
+ if (gift.kind !== kind) continue;
4363
4395
  byWidget.set(gift.widgetId, [...byWidget.get(gift.widgetId) ?? [], gift]);
4364
4396
  }
4365
4397
  return byWidget;
4366
4398
  };
4367
- var validateGifts = async (input, ctx) => {
4399
+
4400
+ // src/server/sgwpCapacity.ts
4401
+ var sgwpQualifyingSubtotalCents = (cart, gifts) => {
4402
+ const giftMajor = gifts.filter((gift) => gift.kind === "sgwp").reduce((total, gift) => total + gift.cost, 0);
4403
+ return shopSubtotalCents(cart) - toShopCents(giftMajor, shopCurrencyRate(cart));
4404
+ };
4405
+ var unlockedCapacity = (milestones, subtotalCents) => milestones.reduce(
4406
+ (capacity, milestone) => Math.round(milestone.unlock_price * 100) <= subtotalCents ? capacity + milestone.selectable_product_quantity : capacity,
4407
+ 0
4408
+ );
4409
+
4410
+ // src/server/sgwpValidation.ts
4411
+ var computeSgwpAdjustments = (gifts, capacity) => {
4412
+ let surplus = gifts.reduce((units, gift) => units + gift.quantity, 0) - capacity;
4413
+ const adjust = [];
4414
+ for (const gift of gifts) {
4415
+ if (surplus <= 0) break;
4416
+ const trimmed = Math.min(gift.quantity, surplus);
4417
+ adjust.push({ lineId: gift.lineId, quantity: gift.quantity - trimmed });
4418
+ surplus -= trimmed;
4419
+ }
4420
+ return adjust;
4421
+ };
4422
+ var validateSelectableGifts = async (input, ctx) => {
4423
+ const byWidget = groupGiftsByWidget(input.gifts, "sgwp");
4424
+ if (byWidget.size === 0) return [];
4425
+ const subtotalCents = sgwpQualifyingSubtotalCents(input.cart, input.gifts);
4368
4426
  const perWidget = await Promise.all(
4369
- [...groupByWidget(input.gifts)].map(async ([widgetId, gifts]) => {
4427
+ [...byWidget].map(async ([widgetId, gifts]) => {
4428
+ try {
4429
+ const settings = await fetchGiftWidgetSettings(widgetId, input.shop, ctx);
4430
+ const milestones = readMilestones(settings, ctx.reportDrift);
4431
+ if (milestones.length === 0) return [];
4432
+ return computeSgwpAdjustments(gifts, unlockedCapacity(milestones, subtotalCents));
4433
+ } catch {
4434
+ return [];
4435
+ }
4436
+ })
4437
+ );
4438
+ return perWidget.flat();
4439
+ };
4440
+
4441
+ // src/server/giftValidation.ts
4442
+ var validateGwpGifts = async (input, ctx) => {
4443
+ const perWidget = await Promise.all(
4444
+ [...groupGiftsByWidget(input.gifts, "gwp")].map(async ([widgetId, gifts]) => {
4370
4445
  const discountLost = gifts.filter(hasLostDiscount).map((gift) => gift.lineId);
4371
4446
  try {
4372
- const dataSourcePath = await resolveDataSourcePath(widgetId, input.shop, ctx);
4447
+ const dataSourcePath = readDataSourcePath(await fetchGiftWidgetSettings(widgetId, input.shop, ctx));
4373
4448
  if (!dataSourcePath) return discountLost;
4374
4449
  const { metadata } = await fetchDataSourceResults(
4375
4450
  {
@@ -4402,7 +4477,17 @@ var validateGifts = async (input, ctx) => {
4402
4477
  }
4403
4478
  })
4404
4479
  );
4405
- return { remove: perWidget.flat() };
4480
+ return perWidget.flat();
4481
+ };
4482
+ var validateGifts = async (input, ctx) => {
4483
+ const [remove, adjust] = await Promise.all([validateGwpGifts(input, ctx), validateSelectableGifts(input, ctx)]);
4484
+ const removed = new Set(remove);
4485
+ const targets = /* @__PURE__ */ new Map();
4486
+ for (const { lineId, quantity } of adjust) {
4487
+ if (removed.has(lineId)) continue;
4488
+ targets.set(lineId, Math.min(quantity, targets.get(lineId) ?? quantity));
4489
+ }
4490
+ return { adjust: [...targets].map(([lineId, quantity]) => ({ lineId, quantity })), remove: [...removed] };
4406
4491
  };
4407
4492
 
4408
4493
  // src/server/analytics.ts
@@ -4524,7 +4609,7 @@ var postAnalyticsEvents = async (input, ctx) => {
4524
4609
  };
4525
4610
 
4526
4611
  // src/server/requestSchemas.ts
4527
- import { z as z29 } from "zod";
4612
+ import { z as z30 } from "zod";
4528
4613
  var REQUEST_BOUNDS = {
4529
4614
  /** Cart-level attributes per cart. */
4530
4615
  CART_ATTRIBUTES_MAX: 100,
@@ -4555,42 +4640,42 @@ var REQUEST_BOUNDS = {
4555
4640
  /** Rich ads transaction lines — the engine's ads contract. */
4556
4641
  TRANSACTION_ITEMS_MAX: 50
4557
4642
  };
4558
- var KeyValue = z29.object({
4559
- key: z29.string().max(REQUEST_BOUNDS.STRING_KEY_MAX),
4560
- value: z29.string().max(REQUEST_BOUNDS.STRING_VALUE_MAX).optional()
4643
+ var KeyValue = z30.object({
4644
+ key: z30.string().max(REQUEST_BOUNDS.STRING_KEY_MAX),
4645
+ value: z30.string().max(REQUEST_BOUNDS.STRING_VALUE_MAX).optional()
4561
4646
  });
4562
- var CartLineInputSchema = z29.object({
4563
- productId: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
4564
- properties: z29.array(KeyValue).max(REQUEST_BOUNDS.LINE_PROPERTIES_MAX).optional(),
4565
- quantity: z29.number(),
4566
- subscriptionId: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4567
- variantId: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX)
4647
+ var CartLineInputSchema = z30.object({
4648
+ productId: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
4649
+ properties: z30.array(KeyValue).max(REQUEST_BOUNDS.LINE_PROPERTIES_MAX).optional(),
4650
+ quantity: z30.number(),
4651
+ subscriptionId: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4652
+ variantId: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX)
4568
4653
  });
4569
- var CartInputSchema = z29.object({
4570
- attributes: z29.array(KeyValue).max(REQUEST_BOUNDS.CART_ATTRIBUTES_MAX).optional(),
4654
+ var CartInputSchema = z30.object({
4655
+ attributes: z30.array(KeyValue).max(REQUEST_BOUNDS.CART_ATTRIBUTES_MAX).optional(),
4571
4656
  /**
4572
4657
  * Presentment-per-shop-currency FX rate (buyer price ÷ shop price, e.g. `1.35` for a USD shop
4573
4658
  * selling in CAD). Money rule thresholds are authored in SHOP currency, so the engine subtotal is
4574
4659
  * divided by this before evaluation. Omit (or send 1) when the cart is already in the shop's currency.
4575
4660
  */
4576
- currencyRate: z29.number().optional(),
4661
+ currencyRate: z30.number().optional(),
4577
4662
  /** Applied discount codes; the engine evaluates `discount_code` rules against them (sent as `[{code}]`). */
4578
- discountCodes: z29.array(z29.string().max(REQUEST_BOUNDS.STRING_KEY_MAX)).max(REQUEST_BOUNDS.DISCOUNT_CODES_MAX).optional(),
4663
+ discountCodes: z30.array(z30.string().max(REQUEST_BOUNDS.STRING_KEY_MAX)).max(REQUEST_BOUNDS.DISCOUNT_CODES_MAX).optional(),
4579
4664
  /** Presentment cents of cart-level discount allocations, subtracted from `subtotal` for the engine. */
4580
- discountTotal: z29.number().optional(),
4581
- items: z29.array(CartLineInputSchema).max(REQUEST_BOUNDS.CART_ITEMS_MAX),
4665
+ discountTotal: z30.number().optional(),
4666
+ items: z30.array(CartLineInputSchema).max(REQUEST_BOUNDS.CART_ITEMS_MAX),
4582
4667
  /** Presentment subtotal in cents. */
4583
- subtotal: z29.number()
4668
+ subtotal: z30.number()
4584
4669
  });
4585
- var UserConfigInputSchema = z29.object({ shop: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX) });
4586
- var DataSourceInputSchema = z29.object({
4670
+ var UserConfigInputSchema = z30.object({ shop: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX) });
4671
+ var DataSourceInputSchema = z30.object({
4587
4672
  cart: CartInputSchema,
4588
4673
  /**
4589
4674
  * Numeric Shopify company-location id when the buyer is a B2B session (`buyerIdentity.purchasingCompany`).
4590
4675
  * Present ⇒ the orchestrator filters results by the location's catalog via admin-api (REB-23713); the
4591
4676
  * client's country-context market check is bypassed for B2B (it false-negatives B2B-catalog products).
4592
4677
  */
4593
- companyLocationId: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4678
+ companyLocationId: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4594
4679
  /**
4595
4680
  * Buyer's market country (ISO 3166-1 alpha-2) — forwarded to the engine as the `country_code` PARAM
4596
4681
  * when the shop's markets toggle is on (REB-21688; the sink was the `X-Country-Code` header under
@@ -4599,16 +4684,16 @@ var DataSourceInputSchema = z29.object({
4599
4684
  * uppercase (the wire value is forwarded verbatim as the param) so a case-sensitive engine lookup
4600
4685
  * can't silently miss a lowercase code — `adsEngine.ts` uppercases for the same reason.
4601
4686
  */
4602
- country: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).regex(/^[A-Za-z]{2}$/, "must be an ISO 3166-1 alpha-2 country code (e.g. CA)").transform((value) => value.toUpperCase()).optional(),
4603
- customerId: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4604
- dataSourcePath: z29.string().max(REQUEST_BOUNDS.STRING_KEY_MAX),
4687
+ country: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).regex(/^[A-Za-z]{2}$/, "must be an ISO 3166-1 alpha-2 country code (e.g. CA)").transform((value) => value.toUpperCase()).optional(),
4688
+ customerId: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4689
+ dataSourcePath: z30.string().max(REQUEST_BOUNDS.STRING_KEY_MAX),
4605
4690
  /**
4606
4691
  * Engine `filter_inputs` override: `'no'` keeps in-cart (input) products in the results/rule
4607
4692
  * outputs — gift validation sends it so a gift's own product can appear in its answer. Omitted = the
4608
4693
  * engine's default filtering.
4609
4694
  */
4610
- filterInputs: z29.enum(["no", "yes"]).optional(),
4611
- integrations: z29.record(z29.string().max(REQUEST_BOUNDS.STRING_KEY_MAX), z29.boolean()).refine((record) => Object.keys(record).length <= REQUEST_BOUNDS.INTEGRATION_KEYS_MAX, {
4695
+ filterInputs: z30.enum(["no", "yes"]).optional(),
4696
+ integrations: z30.record(z30.string().max(REQUEST_BOUNDS.STRING_KEY_MAX), z30.boolean()).refine((record) => Object.keys(record).length <= REQUEST_BOUNDS.INTEGRATION_KEYS_MAX, {
4612
4697
  message: `Too many integration keys (max ${REQUEST_BOUNDS.INTEGRATION_KEYS_MAX})`
4613
4698
  }).optional(),
4614
4699
  /**
@@ -4621,20 +4706,31 @@ var DataSourceInputSchema = z29.object({
4621
4706
  * request header) fails the regex. Callers should send the buyer's resolved locale (e.g. Shopify
4622
4707
  * checkout's `localization.language.isoCode`), not the request header verbatim.
4623
4708
  */
4624
- language: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).regex(/^[A-Za-z0-9-]+$/, "must be a single BCP-47 tag (e.g. fr-CA), not a q-value list").optional(),
4625
- limit: z29.number().max(REQUEST_BOUNDS.LIMIT_MAX),
4626
- productType: z29.enum(["both", "one-time", "subscription"]).optional(),
4627
- shop: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
4628
- visitorId: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional()
4709
+ language: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).regex(/^[A-Za-z0-9-]+$/, "must be a single BCP-47 tag (e.g. fr-CA), not a q-value list").optional(),
4710
+ limit: z30.number().max(REQUEST_BOUNDS.LIMIT_MAX),
4711
+ productType: z30.enum(["both", "one-time", "subscription"]).optional(),
4712
+ shop: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
4713
+ visitorId: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional()
4629
4714
  });
4630
- var GiftInputSchema = z29.object({
4631
- cost: z29.number(),
4632
- discounted: z29.boolean(),
4633
- lineId: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
4634
- productId: z29.number(),
4635
- widgetId: z29.number()
4715
+ var GiftInputSchema = z30.object({
4716
+ cost: z30.number(),
4717
+ discounted: z30.boolean(),
4718
+ /**
4719
+ * Which gift attribution the line carries (REB-24363). Defaults to `gwp` because a client that predates
4720
+ * the field only ever sent GWP lines — without the default an older extension's request would 422.
4721
+ */
4722
+ kind: z30.enum(GIFT_KINDS).default("gwp"),
4723
+ lineId: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
4724
+ productId: z30.number(),
4725
+ /**
4726
+ * The line's unit count (REB-24363) — the selectable-GWP verdict trims surplus units against milestone
4727
+ * capacity. The second deliberate default: a client predating the field sent only GWP lines, whose
4728
+ * verdict is whole-line, so a missing quantity reads as one unit rather than 422ing the older request.
4729
+ */
4730
+ quantity: z30.number().int().min(1).default(1),
4731
+ widgetId: z30.number()
4636
4732
  });
4637
- var GiftValidationInputSchema = z29.object({
4733
+ var GiftValidationInputSchema = z30.object({
4638
4734
  cart: CartInputSchema,
4639
4735
  /**
4640
4736
  * Buyer's market country — threaded into the per-widget data-source evaluations, which forward it to
@@ -4643,59 +4739,76 @@ var GiftValidationInputSchema = z29.object({
4643
4739
  * turns markets off); regex-bound and uppercase-normalized for the same reasons as
4644
4740
  * `DataSourceInputSchema`'s `country`.
4645
4741
  */
4646
- country: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).regex(/^[A-Za-z]{2}$/, "must be an ISO 3166-1 alpha-2 country code (e.g. CA)").transform((value) => value.toUpperCase()).optional(),
4647
- customerId: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4648
- gifts: z29.array(GiftInputSchema).max(REQUEST_BOUNDS.GIFTS_MAX),
4742
+ country: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).regex(/^[A-Za-z]{2}$/, "must be an ISO 3166-1 alpha-2 country code (e.g. CA)").transform((value) => value.toUpperCase()).optional(),
4743
+ customerId: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4744
+ gifts: z30.array(GiftInputSchema).max(REQUEST_BOUNDS.GIFTS_MAX),
4649
4745
  /**
4650
4746
  * Buyer's checkout language — threaded into the per-widget data-source evaluations, which normalize
4651
4747
  * and forward it as the engine `locale` param under the same markets-toggle gate (REB-21688). MUST be
4652
4748
  * a single BCP-47 tag, for the same reason as `DataSourceInputSchema`'s `language` above.
4653
4749
  */
4654
- language: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).regex(/^[A-Za-z0-9-]+$/, "must be a single BCP-47 tag (e.g. fr-CA), not a q-value list").optional(),
4655
- shop: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
4656
- visitorId: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional()
4750
+ language: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).regex(/^[A-Za-z0-9-]+$/, "must be a single BCP-47 tag (e.g. fr-CA), not a q-value list").optional(),
4751
+ shop: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
4752
+ visitorId: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional()
4753
+ });
4754
+ var GiftAdjustmentSchema = z30.object({
4755
+ lineId: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
4756
+ quantity: z30.number().int().nonnegative()
4657
4757
  });
4658
- var MonetizeTransactionItemSchema = z29.object({
4659
- id: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
4660
- priceCents: z29.number(),
4661
- quantity: z29.number(),
4662
- title: z29.string().max(REQUEST_BOUNDS.STRING_TEXT_MAX),
4663
- type: z29.string().max(REQUEST_BOUNDS.STRING_TEXT_MAX)
4758
+ var GiftValidationResponseSchema = z30.object({
4759
+ adjust: z30.array(GiftAdjustmentSchema).max(REQUEST_BOUNDS.GIFTS_MAX).default([]),
4760
+ remove: z30.array(z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX)).max(REQUEST_BOUNDS.GIFTS_MAX).refine(
4761
+ (remove) => new Set(remove).size === remove.length,
4762
+ "a line id may appear at most once in `remove`"
4763
+ )
4764
+ }).refine(
4765
+ ({ adjust, remove }) => !adjust.some(({ lineId }) => remove.includes(lineId)),
4766
+ "a line id may not appear in both `remove` and `adjust`"
4767
+ ).refine(
4768
+ ({ adjust }) => new Set(adjust.map(({ lineId }) => lineId)).size === adjust.length,
4769
+ "a line id may appear at most once in `adjust`"
4770
+ );
4771
+ var MonetizeTransactionItemSchema = z30.object({
4772
+ id: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
4773
+ priceCents: z30.number(),
4774
+ quantity: z30.number(),
4775
+ title: z30.string().max(REQUEST_BOUNDS.STRING_TEXT_MAX),
4776
+ type: z30.string().max(REQUEST_BOUNDS.STRING_TEXT_MAX)
4664
4777
  });
4665
- var MonetizeInputSchema = z29.object({
4778
+ var MonetizeInputSchema = z30.object({
4666
4779
  cart: CartInputSchema,
4667
4780
  /** Client-minted render-beacon correlation id; forwarded to the ads engine as `client_request_id`. */
4668
- clientRequestId: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4669
- country: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4670
- currency: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4781
+ clientRequestId: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4782
+ country: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4783
+ currency: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4671
4784
  /** Ads-targeting identity/geo/locale fields (React parity); folded into the engine query verbatim. */
4672
- customerEmail: z29.string().max(REQUEST_BOUNDS.STRING_TEXT_MAX).optional(),
4673
- customerFirstName: z29.string().max(REQUEST_BOUNDS.STRING_TEXT_MAX).optional(),
4674
- customerId: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4675
- customerLastName: z29.string().max(REQUEST_BOUNDS.STRING_TEXT_MAX).optional(),
4676
- language: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4785
+ customerEmail: z30.string().max(REQUEST_BOUNDS.STRING_TEXT_MAX).optional(),
4786
+ customerFirstName: z30.string().max(REQUEST_BOUNDS.STRING_TEXT_MAX).optional(),
4787
+ customerId: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4788
+ customerLastName: z30.string().max(REQUEST_BOUNDS.STRING_TEXT_MAX).optional(),
4789
+ language: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4677
4790
  /** Buyer-facing order name (e.g. `#1001`) for the server-composed "Order … Confirmed" chrome line. */
4678
- orderName: z29.string().max(REQUEST_BOUNDS.STRING_TEXT_MAX).optional(),
4791
+ orderName: z30.string().max(REQUEST_BOUNDS.STRING_TEXT_MAX).optional(),
4679
4792
  /** Ads surface label, forwarded verbatim; `customer_account` is measured/floored as its own surface (the account-area order list). */
4680
- placement: z29.enum(["customer_account", "order_status", "thank_you_page"]),
4793
+ placement: z30.enum(["customer_account", "order_status", "thank_you_page"]),
4681
4794
  /** Privacy Policy footer href — the Monetize widget's `privacy_policy_url` setting (React parity). */
4682
- privacyPolicyUrl: z29.string().max(REQUEST_BOUNDS.STRING_URL_MAX).optional(),
4795
+ privacyPolicyUrl: z30.string().max(REQUEST_BOUNDS.STRING_URL_MAX).optional(),
4683
4796
  /** 30-min rolling session id (React parity); used for the ads `session_id`, else the visitor id. */
4684
- sessionId: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4685
- shop: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
4797
+ sessionId: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4798
+ shop: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
4686
4799
  /** Editor/QA indicator; OR-ed into the ads-engine `testing` flag so it stays out of billing/reporting. */
4687
- testing: z29.boolean().optional(),
4688
- timezone: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4800
+ testing: z30.boolean().optional(),
4801
+ timezone: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4689
4802
  /** Decorated (numeric) order id → the ads `transaction_id`. */
4690
- transactionId: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4803
+ transactionId: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4691
4804
  /** Rich per-line ads items; capped at the engine's ads contract. Preferred over `cart.items`. */
4692
- transactionItems: z29.array(MonetizeTransactionItemSchema).max(REQUEST_BOUNDS.TRANSACTION_ITEMS_MAX).optional(),
4805
+ transactionItems: z30.array(MonetizeTransactionItemSchema).max(REQUEST_BOUNDS.TRANSACTION_ITEMS_MAX).optional(),
4693
4806
  /** Order total in presentment cents (React prefers total over subtotal); else `cart.subtotal`. */
4694
- transactionTotal: z29.number().optional(),
4695
- visitorId: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4696
- zipCode: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional()
4807
+ transactionTotal: z30.number().optional(),
4808
+ visitorId: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4809
+ zipCode: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional()
4697
4810
  });
4698
- var WidgetSettingsInputSchema = z29.object({
4811
+ var WidgetSettingsInputSchema = z30.object({
4699
4812
  /**
4700
4813
  * Same domain as `CabShopConfig.cacheKey`, for the same reason: this side WINS the orchestrator's
4701
4814
  * `input.cacheKey ?? config.shop.cacheKey` (and an explicit value skips the config read entirely), so
@@ -4703,9 +4816,9 @@ var WidgetSettingsInputSchema = z29.object({
4703
4816
  * and pin the edge copy permanently stale. `.catch(undefined)` because a bogus buster should degrade
4704
4817
  * the read to un-busted, never 400 a widget render.
4705
4818
  */
4706
- cacheKey: z29.number().int().positive().optional().catch(void 0),
4707
- id: z29.number(),
4708
- key: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4819
+ cacheKey: z30.number().int().positive().optional().catch(void 0),
4820
+ id: z30.number(),
4821
+ key: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4709
4822
  /**
4710
4823
  * Monetize context for inline hydration: when present AND the widget converts to a Monetize marker,
4711
4824
  * the server fetches/composes the offer cards inside this same request (no second `/cab/monetize`
@@ -4714,25 +4827,25 @@ var WidgetSettingsInputSchema = z29.object({
4714
4827
  * `privacyPolicyUrl` comes from the widget settings server-side (more authoritative than a relay).
4715
4828
  */
4716
4829
  monetize: MonetizeInputSchema.omit({ privacyPolicyUrl: true, shop: true }).optional(),
4717
- shop: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
4830
+ shop: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
4718
4831
  /** Stable per-buyer id; when present, enables sticky server-side A/B variant selection. */
4719
- visitorId: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional()
4832
+ visitorId: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional()
4720
4833
  });
4721
- var AnalyticsEventInputSchema = z29.object({
4722
- name: z29.string().max(REQUEST_BOUNDS.STRING_KEY_MAX),
4723
- properties: z29.record(z29.string().max(REQUEST_BOUNDS.STRING_KEY_MAX), z29.unknown()).optional(),
4724
- sentAt: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional()
4834
+ var AnalyticsEventInputSchema = z30.object({
4835
+ name: z30.string().max(REQUEST_BOUNDS.STRING_KEY_MAX),
4836
+ properties: z30.record(z30.string().max(REQUEST_BOUNDS.STRING_KEY_MAX), z30.unknown()).optional(),
4837
+ sentAt: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional()
4725
4838
  });
4726
- var AnalyticsInputSchema = z29.object({
4839
+ var AnalyticsInputSchema = z30.object({
4727
4840
  /** Rebuy cart token (the `r_cart_token` cart attribute), when available — primary aggregation key. */
4728
- cartToken: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4841
+ cartToken: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4729
4842
  /** Shopify checkout token, when available — drives cart/checkout aggregation downstream. */
4730
- checkoutToken: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4731
- events: z29.array(AnalyticsEventInputSchema).max(REQUEST_BOUNDS.EVENTS_MAX),
4732
- shop: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
4733
- visitorId: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
4843
+ checkoutToken: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4844
+ events: z30.array(AnalyticsEventInputSchema).max(REQUEST_BOUNDS.EVENTS_MAX),
4845
+ shop: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
4846
+ visitorId: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
4734
4847
  /** The widget id (smartBlockId) the events belong to. */
4735
- widgetId: z29.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional()
4848
+ widgetId: z30.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional()
4736
4849
  });
4737
4850
  export {
4738
4851
  AnalyticsEventInputSchema,
@@ -4740,8 +4853,10 @@ export {
4740
4853
  CartInputSchema,
4741
4854
  CartLineInputSchema,
4742
4855
  DataSourceInputSchema,
4856
+ GiftAdjustmentSchema,
4743
4857
  GiftInputSchema,
4744
4858
  GiftValidationInputSchema,
4859
+ GiftValidationResponseSchema,
4745
4860
  MonetizeInputSchema,
4746
4861
  MonetizeTransactionItemSchema,
4747
4862
  NotFoundError,
@@ -4,7 +4,8 @@
4
4
  * `*Input` twins in `./shared` are now `z.infer` of these schemas (single source of truth, no drift).
5
5
  *
6
6
  * These model the wire shape only: no `.default()`s (a request field is either sent or omitted — defaulting
7
- * would diverge `z.infer` from the twin it replaces and hide a missing field), and unknown keys are stripped
7
+ * would diverge `z.infer` from the twin it replaces and hide a missing field) — the two exceptions are
8
+ * `GiftInputSchema.kind` and `.quantity`, additive fields whose defaults ARE the cross-version contract — and unknown keys are stripped
8
9
  * by the plain `z.object`, which is the intended lenient request-parse behavior. Upper bounds
9
10
  * (`REQUEST_BOUNDS`) ARE part of the wire contract: the `/cab/*` routes are public and unauthenticated, so
10
11
  * the schema is the only edge guard against unbounded payloads.
@@ -128,8 +129,13 @@ export declare const DataSourceInputSchema: z.ZodObject<{
128
129
  export declare const GiftInputSchema: z.ZodObject<{
129
130
  cost: z.ZodNumber;
130
131
  discounted: z.ZodBoolean;
132
+ kind: z.ZodDefault<z.ZodEnum<{
133
+ gwp: "gwp";
134
+ sgwp: "sgwp";
135
+ }>>;
131
136
  lineId: z.ZodString;
132
137
  productId: z.ZodNumber;
138
+ quantity: z.ZodDefault<z.ZodNumber>;
133
139
  widgetId: z.ZodNumber;
134
140
  }, z.core.$strip>;
135
141
  /** Input for `validateGifts`. */
@@ -159,14 +165,39 @@ export declare const GiftValidationInputSchema: z.ZodObject<{
159
165
  gifts: z.ZodArray<z.ZodObject<{
160
166
  cost: z.ZodNumber;
161
167
  discounted: z.ZodBoolean;
168
+ kind: z.ZodDefault<z.ZodEnum<{
169
+ gwp: "gwp";
170
+ sgwp: "sgwp";
171
+ }>>;
162
172
  lineId: z.ZodString;
163
173
  productId: z.ZodNumber;
174
+ quantity: z.ZodDefault<z.ZodNumber>;
164
175
  widgetId: z.ZodNumber;
165
176
  }, z.core.$strip>>;
166
177
  language: z.ZodOptional<z.ZodString>;
167
178
  shop: z.ZodString;
168
179
  visitorId: z.ZodOptional<z.ZodString>;
169
180
  }, z.core.$strip>;
181
+ /** One per-line quantity target in the gift-validation receipt; `0` means remove the line. */
182
+ export declare const GiftAdjustmentSchema: z.ZodObject<{
183
+ lineId: z.ZodString;
184
+ quantity: z.ZodNumber;
185
+ }, z.core.$strip>;
186
+ /**
187
+ * The gift-validation RECEIPT: whole-line removals beside per-line quantity targets. `validateGifts`
188
+ * derives its return type from this, so the two lists stay one contract. `adjust` defaults so a proxy that
189
+ * predates the field still answers a valid `{ remove }` receipt. One verdict per line: a line id in both
190
+ * lists, or twice in either, is a server bug — the client would have to pick a winner (or mutate a line
191
+ * twice), so the contract forbids all three here and `validateGifts` upholds them (strictest verdict wins,
192
+ * named once) even when a request names one `lineId` more than once.
193
+ */
194
+ export declare const GiftValidationResponseSchema: z.ZodObject<{
195
+ adjust: z.ZodDefault<z.ZodArray<z.ZodObject<{
196
+ lineId: z.ZodString;
197
+ quantity: z.ZodNumber;
198
+ }, z.core.$strip>>>;
199
+ remove: z.ZodArray<z.ZodString>;
200
+ }, z.core.$strip>;
170
201
  /**
171
202
  * A rich transaction line the ads engine prices/targets on — the full per-line detail React sends. Distinct
172
203
  * from a cart line: it carries the merchandise title/type/unit-price the rule-evaluation cart context omits.
@@ -318,8 +349,10 @@ export type AnalyticsInput = z.infer<typeof AnalyticsInputSchema>;
318
349
  export type CartInput = z.infer<typeof CartInputSchema>;
319
350
  export type CartLineInput = z.infer<typeof CartLineInputSchema>;
320
351
  export type DataSourceInput = z.infer<typeof DataSourceInputSchema>;
352
+ export type GiftAdjustment = z.infer<typeof GiftAdjustmentSchema>;
321
353
  export type GiftInput = z.infer<typeof GiftInputSchema>;
322
354
  export type GiftValidationInput = z.infer<typeof GiftValidationInputSchema>;
355
+ export type GiftValidationResponse = z.infer<typeof GiftValidationResponseSchema>;
323
356
  export type MonetizeInput = z.infer<typeof MonetizeInputSchema>;
324
357
  export type MonetizeTransactionItem = z.infer<typeof MonetizeTransactionItemSchema>;
325
358
  export type UserConfigInput = z.infer<typeof UserConfigInputSchema>;
@@ -0,0 +1,25 @@
1
+ import { type SgwpMilestone } from '../server/giftWidgets';
2
+ import { type CartInput, type GiftInput } from '../server/shared';
3
+ /**
4
+ * The subtotal a selectable-GWP milestone is judged against: the rule subtotal in shop cents
5
+ * ({@link shopSubtotalCents} — the same figure the GWP rules see) minus the cost of EVERY selectable gift
6
+ * line in the cart, from every widget and regardless of price. A gift may legitimately carry a price (a $3
7
+ * gift covering shipping), and counting it would let the gift hold its own threshold met after the
8
+ * qualifying items are gone (React `getSgwpUnlockSubtotalCents`). `cost` is a presentment MAJOR-unit
9
+ * line total, so the gift costs are summed in presentment first and converted through the rate ONCE —
10
+ * rounding each line separately can drift a cent from the summed figure at a threshold boundary.
11
+ *
12
+ * `gwp` lines are NOT subtracted, on purpose (React excluded selectable lines only). A discounted GWP gift
13
+ * is free, so it adds nothing to spend; one that lost its discount is in the same receipt's `remove`, and
14
+ * once the client applies that the next validation judges the milestones against the reduced subtotal —
15
+ * self-healing within a round trip, with any residue on the merchant's side, never the buyer's.
16
+ */
17
+ export declare const sgwpQualifyingSubtotalCents: (cart: CartInput, gifts: GiftInput[]) => number;
18
+ /**
19
+ * Gift units the cart has unlocked: Σ `selectable_product_quantity` over the milestones whose unlock price is
20
+ * at or below the subtotal, both in shop cents. `unlock_price` is authored in shop MAJOR units, so it is
21
+ * ×100 and rounded exactly as React's `getMilestoneUnlockPriceCents` did (a float residue like 150.5 × 100
22
+ * = 15050.000000000002 must not push a threshold a cent past a subtotal that meets it). Cumulative, not
23
+ * stepped: every met milestone contributes — the retired React validator's semantics, reproduced.
24
+ */
25
+ export declare const unlockedCapacity: (milestones: SgwpMilestone[], subtotalCents: number) => number;
@@ -0,0 +1,19 @@
1
+ import { type GiftAdjustment, type GiftInput, type GiftValidationInput, type ServerContext } from '../server/shared';
2
+ /**
3
+ * Per-line quantity targets that bring one widget's selectable gifts back within `capacity` units. Surplus
4
+ * units come off the FIRST lines in request order: the request mirrors `shopify.lines`, whose index 0 is
5
+ * the most recently added line (Shopify's cart convention), so this is LIFO by add time — the earliest-
6
+ * claimed gifts survive, exactly as the retired React `computeSgwpLineAdjustments` walked them. A line the
7
+ * surplus consumes whole targets `0` (remove); a line it only dents targets its reduced quantity; a line it
8
+ * never reaches gets no entry.
9
+ */
10
+ export declare const computeSgwpAdjustments: (gifts: GiftInput[], capacity: number) => GiftAdjustment[];
11
+ /**
12
+ * The selectable-GWP half of the gift-validation receipt: for each `sgwp` widget in the request, read its
13
+ * `milestones` from its widget settings and trim the widget's lines to the capacity the cart has unlocked.
14
+ * Entirely fail-safe, like the GWP qualification path: an upstream failure, a deleted widget, or absent,
15
+ * empty, or malformed milestones all keep the widget's gifts (no entries). The GWP charged-gift rule never
16
+ * applies here — a selectable gift may be legitimately priced. Widgets are independent: one failing
17
+ * fetch never disturbs another's verdict.
18
+ */
19
+ export declare const validateSelectableGifts: (input: GiftValidationInput, ctx: ServerContext) => Promise<GiftAdjustment[]>;
@@ -27,14 +27,15 @@ export type UpstreamFetch = (path: string, params: Record<string, string>, optio
27
27
  }) => Promise<unknown>;
28
28
  /**
29
29
  * A drift signal the orchestrators emit when a per-item payload salvage drops something: a malformed
30
- * offer product, an invalid/unknown section stubbed out, an unreadable rule verdict, or a shop-config
31
- * entry the shared schema inerted (`shopConfig`). Distinct from a hard {@link UpstreamError} — the
30
+ * offer product, an invalid/unknown section stubbed out, an unreadable rule verdict, a shop-config
31
+ * entry the shared schema inerted (`shopConfig`), or unreadable selectable-gift milestones
32
+ * (`giftMilestones` — the widget's gifts are then kept). Distinct from a hard {@link UpstreamError} — the
32
33
  * request still succeeds (degraded), so this is the only trace that an upstream contract moved. Optional
33
34
  * on {@link ServerContext}; the host (rebuy-api) wires it to its logger.
34
35
  */
35
36
  export type ServerDrift = {
36
37
  detail: string;
37
- kind: 'b2bAvailability' | 'dataSource' | 'monetizeOffer' | 'product' | 'ruleVerdict' | 'section' | 'shopConfig';
38
+ kind: 'b2bAvailability' | 'dataSource' | 'giftMilestones' | 'monetizeOffer' | 'product' | 'ruleVerdict' | 'section' | 'shopConfig';
38
39
  };
39
40
  /** A {@link ServerDrift} sink, as the orchestrators pass it around internally. */
40
41
  export type Reporter = (event: ServerDrift) => void;
@@ -69,4 +70,4 @@ export declare class NotFoundError extends Error {
69
70
  * requests against the exact contract the orchestrators are typed on. Kept re-exported here so the
70
71
  * `@rebuy/rebuy/server` type surface is unchanged.
71
72
  */
72
- export type { AnalyticsEventInput, AnalyticsInput, CartInput, CartLineInput, DataSourceInput, GiftInput, GiftValidationInput, MonetizeInput, MonetizeTransactionItem, UserConfigInput, WidgetSettingsInput, } from '../server/requestSchemas';
73
+ export type { AnalyticsEventInput, AnalyticsInput, CartInput, CartLineInput, DataSourceInput, GiftAdjustment, GiftInput, GiftValidationInput, GiftValidationResponse, MonetizeInput, MonetizeTransactionItem, UserConfigInput, WidgetSettingsInput, } from '../server/requestSchemas';