@12-apps/discounts 1.12.0 → 1.14.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 (38) hide show
  1. package/package.json +7 -6
  2. package/prisma/discounts.prisma +23 -0
  3. package/prisma/migrations/20260830120000_discount_weekly_schedule/migration.sql +40 -0
  4. package/src/engine/combo-offer.ts +21 -3
  5. package/src/engine/eligibility.ts +76 -0
  6. package/src/engine/en-US.ts +1 -0
  7. package/src/engine/evaluate.ts +8 -2
  8. package/src/engine/kinds.ts +7 -0
  9. package/src/engine/preview.ts +32 -6
  10. package/src/engine/pt-BR.ts +8 -0
  11. package/src/engine/schedule.ts +191 -0
  12. package/src/engine/teaser.ts +150 -0
  13. package/src/engine/types.ts +36 -0
  14. package/src/index.ts +32 -0
  15. package/src/react/api.ts +13 -0
  16. package/src/react/copy.ts +5 -0
  17. package/src/react/create-web-discounts.tsx +39 -1
  18. package/src/react/discount-actions-menu.tsx +4 -0
  19. package/src/react/discount-form-fields.tsx +90 -6
  20. package/src/react/discount-form-validate.ts +40 -0
  21. package/src/react/discount-form.tsx +38 -2
  22. package/src/react/discounts-screen.tsx +14 -29
  23. package/src/react/en-US.ts +45 -0
  24. package/src/react/format.ts +15 -0
  25. package/src/react/pt-BR.ts +45 -0
  26. package/src/react/row.ts +64 -0
  27. package/src/react/schedule-builder.tsx +253 -0
  28. package/src/react/schedule-copy.ts +74 -0
  29. package/src/react/schedule-summary.ts +100 -0
  30. package/src/react/screen-bindings.ts +57 -0
  31. package/src/react/table-config.ts +15 -0
  32. package/src/server/copy.ts +3 -0
  33. package/src/server/en-US.ts +1 -0
  34. package/src/server/mcp.ts +32 -0
  35. package/src/server/pt-BR.ts +1 -0
  36. package/src/server/store.ts +8 -0
  37. package/src/server/validate.ts +43 -0
  38. package/src/timezone.ts +96 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/discounts",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/e2e/**"
@@ -24,7 +24,8 @@
24
24
  "./e2e": {
25
25
  "types": "./dist/e2e/index.d.ts",
26
26
  "default": "./dist/e2e/index.js"
27
- }
27
+ },
28
+ "./timezone": "./src/timezone.ts"
28
29
  },
29
30
  "scripts": {
30
31
  "build": "tsc -p tsconfig.e2e.json",
@@ -40,10 +41,10 @@
40
41
  "typecheck": "tsc --noEmit"
41
42
  },
42
43
  "dependencies": {
43
- "@12-apps/app-shell": "^5.5.0",
44
+ "@12-apps/app-shell": "^5.11.0",
44
45
  "@12-apps/forms-core": "^1.21.0",
45
46
  "@12-apps/shared-helpers": "^2.1.0",
46
- "@12-apps/ui": "^6.10.0",
47
+ "@12-apps/ui": "^6.42.0",
47
48
  "@mui/icons-material": "^6.5.0",
48
49
  "zod": "^4.3.5"
49
50
  },
@@ -77,9 +78,9 @@
77
78
  },
78
79
  "devDependencies": {
79
80
  "@12-apps/eslint-config": "^1.22.0",
80
- "@12-apps/i18n": "^1.2.0",
81
+ "@12-apps/i18n": "^1.3.0",
81
82
  "@12-apps/typescript-config": "^1.21.0",
82
- "@12-apps/wiring": "^1.14.0",
83
+ "@12-apps/wiring": "^1.16.0",
83
84
  "@electric-sql/pglite": "^0.2.17",
84
85
  "@emotion/react": "^11.14.0",
85
86
  "@emotion/styled": "^11.14.0",
@@ -79,6 +79,29 @@ model Discount {
79
79
  // either side = open-ended.
80
80
  startsAt DateTime? @map("starts_at")
81
81
  endsAt DateTime? @map("ends_at")
82
+ // The WEEKLY schedule INSIDE that window (FUT-996) — "toda sexta, das 16:00
83
+ // às 20:00", "segunda e terça à tarde". NULL = always, within
84
+ // [starts_at, ends_at), which is what every row predating the feature means.
85
+ //
86
+ // {"windows":[{"days":[4],"from":"16:00","to":"20:00"}]}
87
+ //
88
+ // `days` is Monday-first 0..6 — the axis every hours surface uses, and NOT
89
+ // `Date#getDay()`'s Sunday-first one. `from`/`to` are "HH:MM" in the STORE's
90
+ // timezone, half-open [from, to) exactly as the campaign window is, and a
91
+ // `to` earlier than `from` runs past midnight rather than being invalid.
92
+ //
93
+ // JSON rather than a fourth table because the recurrence is not usefully
94
+ // SQL-queryable (the grid's vigência filter already cannot express the
95
+ // campaign window either), and a fourth table would put a third `include` on
96
+ // the storefront's hottest read. The cost is that the DATABASE cannot check
97
+ // this shape: `assertSchedule` is the only gate, and the evaluator treats a
98
+ // malformed window as covering NOTHING, so a bad blob is a promotion that
99
+ // does not fire rather than a cart priced wrong.
100
+ //
101
+ // A recurring window is a WALL-CLOCK fact, so it is only meaningful against
102
+ // the store's own timezone. The engine never reads one: a host resolves the
103
+ // instant into a weekday and a minute-of-day and passes those in.
104
+ schedule Json? @map("schedule")
82
105
  // Minimum PRE-DISCOUNT cart subtotal, in cents. Always compared against the
83
106
  // untouched subtotal, never a running total — which is what makes the result
84
107
  // independent of the order the discounts are applied in.
@@ -0,0 +1,40 @@
1
+ -- @12-apps/discounts: the WEEKLY schedule (FUT-996).
2
+ --
3
+ -- "Toda sexta, das 16:00 às 20:00" — a recurring window INSIDE the campaign
4
+ -- `[starts_at, ends_at)`, which until now was the only scheduling a rule had.
5
+ --
6
+ -- ── PURELY ADDITIVE, AND THAT IS THE POINT ────────────────────────────────
7
+ -- One nullable column, no backfill, no CHECK to widen, nothing dropped. NULL
8
+ -- means "always, within the campaign window", which is exactly what every
9
+ -- existing row already means — so this migration cannot change the price of
10
+ -- anything, and the release carrying it needs none of the expand/contract
11
+ -- ceremony a destructive DDL would (see the host's CLAUDE.md on why a
12
+ -- `DROP COLUMN` takes production down for the length of a healthcheck).
13
+ --
14
+ -- Replay-safe like its predecessor: `IF NOT EXISTS` throughout.
15
+ --
16
+ -- ── WHY JSON RATHER THAN A FOURTH TABLE ───────────────────────────────────
17
+ -- The recurrence is not SQL-queryable in any useful way. The admin grid's
18
+ -- vigência filter already punts on comparing two nullable timestamps against
19
+ -- `now()` and rides as a separate parameter the store resolves, so a schedule
20
+ -- table would buy queryability that nothing asks for — while adding a third
21
+ -- `include` to `loadDiscountRules`, which is on the storefront's hottest read
22
+ -- (a whole menu page's badge preview walks it once per request).
23
+ --
24
+ -- The trade is stated plainly: the database cannot check this column's SHAPE.
25
+ -- `assertSchedule` in `src/server/validate.ts` is the only gate in front of it,
26
+ -- and the evaluator is deliberately tolerant of a row that got past it — a
27
+ -- malformed window covers NOTHING rather than everything, so the failure mode
28
+ -- of a bad blob is a promotion that does not fire, never a cart priced at zero.
29
+ --
30
+ -- Shape, for a reader with only the database in front of them:
31
+ --
32
+ -- {"windows":[{"days":[4],"from":"16:00","to":"20:00"}]}
33
+ --
34
+ -- * `days` — Monday-first, 0..6. NOT `Date#getDay()`'s Sunday-first axis.
35
+ -- * `from`/`to` — "HH:MM", 24-hour, in the STORE's timezone, half-open
36
+ -- [from, to) exactly as [starts_at, ends_at) is.
37
+ -- * `to` < `from` means the window runs past midnight (a bar shutting at
38
+ -- 02:00), which is the most common happy hour there is.
39
+
40
+ ALTER TABLE "discounts" ADD COLUMN IF NOT EXISTS "schedule" JSONB;
@@ -1,5 +1,6 @@
1
1
  import { comboRuleAcceptsLine } from "./combo-match";
2
2
  import { compareRuleOrder } from "./evaluate-passes";
3
+ import { scheduleCovers, type LocalClock } from "./schedule";
3
4
  import type { DiscountType } from "./kinds";
4
5
  import type { ComboRequirement, DiscountCartLine, DiscountRule } from "./types";
5
6
 
@@ -74,6 +75,8 @@ export interface ComboOffersInput {
74
75
  rules: readonly DiscountRule[];
75
76
  /** Evaluation instant. Injected — this module never calls `new Date()`. */
76
77
  now: Date;
78
+ /** `now` as the store's wall clock, for a combo that only runs some hours. */
79
+ localNow?: LocalClock | null;
77
80
  }
78
81
 
79
82
  /** The synthetic one-unit line a slot is tested against. */
@@ -99,11 +102,22 @@ function toOfferLine(input: ComboOffersInput): DiscountCartLine {
99
102
  * a covered set), and a card has no cart to answer them from. Feeding it
100
103
  * zeroes would reject every combo carrying a minimum basket, which is precisely
101
104
  * the sort a merchant advertises.
105
+ *
106
+ * It DOES honour the weekly schedule (FUT-996), and that is not optional: this
107
+ * predicate is a SECOND implementation of "inside its window", so a schedule
108
+ * taught only to `screenRule` would leave a Tuesday-afternoon combo advertising
109
+ * itself around the clock while the cart refused to price it. The duplication
110
+ * above is deliberate; a duplication that disagrees is not.
102
111
  */
103
- function isAdvertisable(rule: DiscountRule, now: Date): boolean {
112
+ function isAdvertisable(
113
+ rule: DiscountRule,
114
+ now: Date,
115
+ localNow: LocalClock | null | undefined,
116
+ ): boolean {
104
117
  if (rule.scope !== "COMBO" || rule.trigger !== "AUTOMATIC" || !rule.active) return false;
105
118
  if (rule.startsAt !== null && now.getTime() < rule.startsAt.getTime()) return false;
106
119
  if (rule.endsAt !== null && now.getTime() >= rule.endsAt.getTime()) return false;
120
+ if (!scheduleCovers(rule.schedule, localNow)) return false;
107
121
  return rule.usageLimit === null || rule.usageCount < rule.usageLimit;
108
122
  }
109
123
 
@@ -139,9 +153,10 @@ function toOffer(rule: DiscountRule): ComboOffer {
139
153
  export function advertisableCombos(
140
154
  rules: readonly DiscountRule[],
141
155
  now: Date,
156
+ localNow?: LocalClock | null,
142
157
  ): readonly ComboOffer[] {
143
158
  return rules
144
- .filter((rule) => isAdvertisable(rule, now))
159
+ .filter((rule) => isAdvertisable(rule, now, localNow))
145
160
  .sort(compareRuleOrder)
146
161
  .map(toOffer);
147
162
  }
@@ -155,7 +170,10 @@ export function advertisableCombos(
155
170
  export function comboOffersForItem(input: ComboOffersInput): readonly ComboOffer[] {
156
171
  const line = toOfferLine(input);
157
172
  return input.rules
158
- .filter((rule) => isAdvertisable(rule, input.now) && comboRuleAcceptsLine(rule, line))
173
+ .filter(
174
+ (rule) =>
175
+ isAdvertisable(rule, input.now, input.localNow) && comboRuleAcceptsLine(rule, line),
176
+ )
159
177
  .sort(compareRuleOrder)
160
178
  .map(toOffer);
161
179
  }
@@ -1,5 +1,6 @@
1
1
  import { comboCoveredLineIds } from "./combo-match";
2
2
  import type { DiscountRejectionReason } from "./kinds";
3
+ import { scheduleCovers, type LocalClock } from "./schedule";
3
4
  import type { DiscountCartLine, DiscountRule } from "./types";
4
5
 
5
6
  /**
@@ -32,6 +33,15 @@ interface ScreenContext {
32
33
  subtotalCents: number;
33
34
  /** Whether R3's covered set came back non-empty for this rule. */
34
35
  hasEligibleItems: boolean;
36
+ /**
37
+ * True when R3 covered lines the SCHEDULE then took away (FUT-996) — i.e.
38
+ * the rule would have applied but not at this hour.
39
+ *
40
+ * Kept separate from `hasEligibleItems` because the two are different
41
+ * sentences to a buyer: "não vale para os itens do seu carrinho" is a dead
42
+ * end, and "o happy hour começa às 16:00" is an invitation.
43
+ */
44
+ blockedBySchedule: boolean;
35
45
  }
36
46
 
37
47
  /**
@@ -148,6 +158,69 @@ function screenLimits(rule: DiscountRule, ctx: ScreenContext): DiscountRejection
148
158
  return null;
149
159
  }
150
160
 
161
+ /**
162
+ * Which lines a SCHEDULED rule may touch, and whether the schedule is what
163
+ * emptied the set (FUT-996).
164
+ *
165
+ * Screening is PER LINE, against each line's own `committedLocal`, because a
166
+ * cart is not committed all at once: a beer added at 19:55 and one added at
167
+ * 20:05 are two different facts, and pricing them alike would mean either
168
+ * charging for a promotion the shopper earned or honouring one they did not.
169
+ *
170
+ * The three scopes answer it differently, and each difference is forced:
171
+ *
172
+ * - **`ORDER`** names no line at all, so there is nothing to anchor to and it
173
+ * screens against `localNow` — the checkout instant. An order-wide discount
174
+ * is a fact about the order, and the order comes into existence at checkout.
175
+ * - **`COMBO`** is RE-MATCHED over the qualifying lines rather than
176
+ * intersected, because a combo's coverage is not a set intersection but a
177
+ * match: "leve 3, pague 2 na terça à tarde" must not assemble itself from two
178
+ * units bought in the afternoon and one bought at night. Intersecting after
179
+ * the fact would have kept the units and lost the requirement.
180
+ * - **`ITEM` / `CATEGORY`** intersect, which is exactly what they mean.
181
+ */
182
+ function scheduledCoverage(
183
+ rule: DiscountRule,
184
+ lines: readonly DiscountCartLine[],
185
+ pristine: ReadonlySet<string>,
186
+ localNow: LocalClock | null | undefined,
187
+ ): { covered: ReadonlySet<string>; blockedBySchedule: boolean } {
188
+ if (rule.schedule === null || rule.schedule === undefined) {
189
+ return { covered: pristine, blockedBySchedule: false };
190
+ }
191
+ if (rule.scope === "ORDER") {
192
+ const running = scheduleCovers(rule.schedule, localNow);
193
+ return {
194
+ covered: running ? pristine : new Set<string>(),
195
+ blockedBySchedule: !running && pristine.size > 0,
196
+ };
197
+ }
198
+ const qualifying = lines.filter((line) =>
199
+ scheduleCovers(rule.schedule, line.committedLocal),
200
+ );
201
+ const covered =
202
+ rule.scope === "COMBO"
203
+ ? comboCoveredLineIds(rule, qualifying)
204
+ : new Set([...pristine].filter((lineId) => qualifying.some((l) => l.lineId === lineId)));
205
+ return { covered, blockedBySchedule: covered.size === 0 && pristine.size > 0 };
206
+ }
207
+
208
+ /**
209
+ * R3 + the schedule, in one call: what this rule may touch right now, and
210
+ * whether the schedule is the reason it may touch nothing.
211
+ *
212
+ * The two are returned together because a caller needs both to say the right
213
+ * thing, and computing them apart would mean matching a combo twice.
214
+ */
215
+ export function coveredLineIdsNow(
216
+ rule: DiscountRule,
217
+ lines: readonly DiscountCartLine[],
218
+ index: LineIndex,
219
+ localNow: LocalClock | null | undefined,
220
+ ): { covered: ReadonlySet<string>; blockedBySchedule: boolean } {
221
+ return scheduledCoverage(rule, lines, coveredLineIds(rule, lines, index), localNow);
222
+ }
223
+
151
224
  /**
152
225
  * R2 — the eligibility screen. Returns the FIRST failing predicate's reason, or
153
226
  * null when the rule is a live candidate. The order is not arbitrary: it runs
@@ -173,5 +246,8 @@ export function screenRule(
173
246
  const limits = screenLimits(rule, ctx);
174
247
  if (limits !== null) return limits;
175
248
  if (ctx.hasEligibleItems) return null;
249
+ // Before the two coarse reasons, because it is the one the buyer can act on:
250
+ // this rule DOES cover their cart, just not at this hour.
251
+ if (ctx.blockedBySchedule) return "OUT_OF_SCHEDULE";
176
252
  return rule.scope === "COMBO" ? "COMBO_NOT_MATCHED" : "NO_ELIGIBLE_ITEMS";
177
253
  }
@@ -25,6 +25,7 @@ export const EN_US_DISCOUNT_REJECTION_COPY: DiscountRejectionCopy = {
25
25
  INACTIVE: "That coupon is invalid or expired.",
26
26
  NOT_STARTED: "That coupon is invalid or expired.",
27
27
  EXPIRED: "That coupon is invalid or expired.",
28
+ OUT_OF_SCHEDULE: "This promotion is not running right now.",
28
29
  MIN_SUBTOTAL_NOT_MET: `This coupon needs a minimum order of ${MIN_SUBTOTAL_TOKEN}.`,
29
30
  USAGE_LIMIT_REACHED: "This coupon has reached its usage limit.",
30
31
  BUYER_LIMIT_REACHED: "You have already used this coupon.",
@@ -1,6 +1,6 @@
1
1
  import { maxDiscountableCents } from "./allocate";
2
2
  import { normalizeDiscountCode } from "./kinds";
3
- import { buildLineIndex, coveredLineIds, screenRule } from "./eligibility";
3
+ import { buildLineIndex, coveredLineIdsNow, screenRule } from "./eligibility";
4
4
  import {
5
5
  compareRuleOrder,
6
6
  lineGrossCents,
@@ -187,11 +187,17 @@ function screenOneRule(
187
187
  context: { subtotalCents: number; couponCode: string | null; index: LineIndex },
188
188
  ): Screened {
189
189
  if (rule.trigger === "CODE" && rule.code !== context.couponCode) return { kind: "skipped" };
190
- const covered = coveredLineIds(rule, input.lines, context.index);
190
+ const { covered, blockedBySchedule } = coveredLineIdsNow(
191
+ rule,
192
+ input.lines,
193
+ context.index,
194
+ input.localNow,
195
+ );
191
196
  const reason = screenRule(rule, {
192
197
  now: input.now,
193
198
  subtotalCents: context.subtotalCents,
194
199
  hasEligibleItems: covered.size > 0,
200
+ blockedBySchedule,
195
201
  });
196
202
  if (reason === null) return { kind: "candidate", candidate: { rule, covered } };
197
203
  if (rule.trigger !== "CODE") return { kind: "skipped" };
@@ -135,6 +135,12 @@ export function discountWindowState(
135
135
  * exclusive one — a failing AUTOMATIC discount is silently skipped, because a
136
136
  * buyer must not be shown promotions they cannot have.
137
137
  *
138
+ * `OUT_OF_SCHEDULE` earns its own key for the same reason `COMBO_NOT_MATCHED`
139
+ * does, and NOT the one `NOT_STARTED`/`EXPIRED` share. A campaign that has
140
+ * ended is over; there is nothing the buyer can do. A promotion that runs on
141
+ * Fridays from 16:00 is one they can simply come back for, and "expirado" would
142
+ * send them away from a shop that will sell it to them in an hour.
143
+ *
138
144
  * `COMBO_NOT_MATCHED` is deliberately NOT folded into `NO_ELIGIBLE_ITEMS`,
139
145
  * even though the two are the same predicate ("R3 covered nothing"), because
140
146
  * the rest of this set is coarse for a reason that does not hold here: those
@@ -149,6 +155,7 @@ export const DISCOUNT_REJECTION_REASONS = [
149
155
  "INACTIVE",
150
156
  "NOT_STARTED",
151
157
  "EXPIRED",
158
+ "OUT_OF_SCHEDULE",
152
159
  "MIN_SUBTOTAL_NOT_MET",
153
160
  "USAGE_LIMIT_REACHED",
154
161
  "BUYER_LIMIT_REACHED",
@@ -1,6 +1,7 @@
1
1
  import { maxDiscountableCents, rawAmountCents } from "./allocate";
2
- import { buildLineIndex, coveredLineIds, screenRule } from "./eligibility";
2
+ import { buildLineIndex, coveredLineIdsNow, screenRule } from "./eligibility";
3
3
  import { compareRuleOrder } from "./evaluate-passes";
4
+ import type { LocalClock } from "./schedule";
4
5
  import type { DiscountCartLine, DiscountRule } from "./types";
5
6
 
6
7
  /**
@@ -76,6 +77,19 @@ export interface ItemDiscountPreviewInput {
76
77
  rules: readonly DiscountRule[];
77
78
  /** Evaluation instant. Injected — this module never calls `new Date()`. */
78
79
  now: Date;
80
+ /**
81
+ * `now` as the STORE's wall clock (FUT-996), for a rule that only runs on
82
+ * certain days and hours.
83
+ *
84
+ * A badge is a PRICE, and the price a card may promise is the one a shopper
85
+ * adding this item RIGHT NOW would get — so the preview screens the schedule
86
+ * against `now` for both halves: it is the evaluation instant and, for
87
+ * anything added off the back of this card, the commit instant too.
88
+ *
89
+ * Omitted or null → scheduled rules badge as if always running, the same
90
+ * fail-open direction the rest of the schedule takes.
91
+ */
92
+ localNow?: LocalClock | null;
79
93
  }
80
94
 
81
95
  /** What a host needs to render the badge: the struck price and its label. */
@@ -113,6 +127,10 @@ function toPreviewLine(input: ItemDiscountPreviewInput): DiscountCartLine {
113
127
  categoryPath: input.categoryPath,
114
128
  quantity: 1,
115
129
  unitPriceCents: input.priceCents,
130
+ // The shopper is looking at this card NOW, so the instant it would be
131
+ // committed at is `now` — which is what lets one predicate answer both the
132
+ // badge and the cart without the card promising an hour it cannot deliver.
133
+ committedLocal: input.localNow ?? null,
116
134
  };
117
135
  }
118
136
 
@@ -133,14 +151,22 @@ function bestBadgeRule(
133
151
  const index = buildLineIndex([line]);
134
152
  const scored = input.rules
135
153
  .filter(isBadgeable)
136
- .filter(
137
- (rule) =>
154
+ .filter((rule) => {
155
+ const { covered, blockedBySchedule } = coveredLineIdsNow(
156
+ rule,
157
+ [line],
158
+ index,
159
+ input.localNow,
160
+ );
161
+ return (
138
162
  screenRule(rule, {
139
163
  now: input.now,
140
164
  subtotalCents: input.priceCents,
141
- hasEligibleItems: coveredLineIds(rule, [line], index).has(PREVIEW_LINE_ID),
142
- }) === null,
143
- )
165
+ hasEligibleItems: covered.has(PREVIEW_LINE_ID),
166
+ blockedBySchedule,
167
+ }) === null
168
+ );
169
+ })
144
170
  .map((rule) => ({
145
171
  rule,
146
172
  amountCents: Math.min(
@@ -10,6 +10,13 @@ import { MIN_SUBTOTAL_TOKEN, type DiscountRejectionCopy } from "./rejection-copy
10
10
  * expired all read the same, because the difference only leaks how the
11
11
  * merchant schedules promotions and the buyer could not act on it either way.
12
12
  *
13
+ * `OUT_OF_SCHEDULE` is the SECOND place it stops, and by the same test.
14
+ * "Inválido ou expirado" sends a buyer away from a shop that will sell them
15
+ * this in an hour; a promotion that runs on Friday afternoons is one they can
16
+ * simply come back for. It stays vague about WHEN on purpose — the hours are on
17
+ * the card, and a rejection message that recited a merchant's whole schedule
18
+ * would be both long and a leak.
19
+ *
13
20
  * `COMBO_NOT_MATCHED` is the one place that coarsening stops, and for the same
14
21
  * test read the other way: a buyer one soda short of the combo can finish it,
15
22
  * so telling them WHICH kind of failure this was is the difference between a
@@ -22,6 +29,7 @@ export const PT_BR_DISCOUNT_REJECTION_COPY: DiscountRejectionCopy = {
22
29
  INACTIVE: "Cupom inválido ou expirado.",
23
30
  NOT_STARTED: "Cupom inválido ou expirado.",
24
31
  EXPIRED: "Cupom inválido ou expirado.",
32
+ OUT_OF_SCHEDULE: "Esta promoção não está valendo agora.",
25
33
  MIN_SUBTOTAL_NOT_MET: `Este cupom exige um pedido mínimo de ${MIN_SUBTOTAL_TOKEN}.`,
26
34
  USAGE_LIMIT_REACHED: "Este cupom já atingiu o limite de uso.",
27
35
  BUYER_LIMIT_REACHED: "Você já usou este cupom.",
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The WEEKLY schedule inside a discount's campaign window (FUT-996).
3
+ *
4
+ * `startsAt`/`endsAt` answer "for how long is this campaign on?". This answers
5
+ * the second question a merchant actually asks — "and within that, on which
6
+ * days and at what hours?" — so that "toda sexta, das 16:00 às 20:00" and
7
+ * "segunda e terça à tarde" are expressible at all.
8
+ *
9
+ * Pure and clock-free, like the rest of the engine: it never calls `new Date()`
10
+ * and never touches `Intl`. A recurring window is a WALL-CLOCK fact and is
11
+ * therefore only meaningful in the store's own timezone — but resolving an
12
+ * IANA zone is the HOST's job, because this module runs in an offline tool
13
+ * generator, a browser bundle and a worker, and none of them may depend on a
14
+ * timezone database. The host resolves an instant into a {@link LocalClock} and
15
+ * passes it in; everything here is integer arithmetic over that.
16
+ *
17
+ * A rule with no schedule is every rule that exists today, and answers `true`
18
+ * unconditionally — which is what makes this a strictly additive change.
19
+ */
20
+
21
+ /** Monday-first, matching the seven-entry week every hours surface uses. */
22
+ export const MAX_SCHEDULE_WINDOWS = 7;
23
+
24
+ /**
25
+ * One "these days, these hours" row of a schedule.
26
+ *
27
+ * A LIST of these rather than one shared time range, because "sexta 16–20 e
28
+ * sábado 12–16" is one promotion to the merchant selling it and two rows here.
29
+ * Deliberately NOT a per-weekday hours table (the shape `openingHours` uses):
30
+ * that editor asks seven questions to answer one, and a promotion is a
31
+ * sentence, not a business week.
32
+ */
33
+ export interface DiscountScheduleWindow {
34
+ /**
35
+ * Weekdays this window runs on, **Monday-first, 0..6**.
36
+ *
37
+ * Monday-first because that is what `WeekHours` and every hours screen in a
38
+ * host already uses; `Date#getDay()` is Sunday-first and the conversion is
39
+ * `(getDay() + 6) % 7`. Stated here because a schedule that is off by one day
40
+ * is a promotion that runs on the wrong day and looks like a pricing bug.
41
+ */
42
+ days: readonly number[];
43
+ /** `HH:MM`, 24-hour, in the STORE's timezone. Inclusive. */
44
+ from: string;
45
+ /** `HH:MM`, 24-hour, in the STORE's timezone. EXCLUSIVE. */
46
+ to: string;
47
+ }
48
+
49
+ /** A rule's weekly schedule. Null/absent = always, within the campaign window. */
50
+ export interface DiscountSchedule {
51
+ windows: readonly DiscountScheduleWindow[];
52
+ }
53
+
54
+ /**
55
+ * An instant already resolved into the store's own wall clock.
56
+ *
57
+ * Two integers rather than a `Date`, so this module cannot be tempted to read a
58
+ * timezone: by the time a value reaches here the only question left is
59
+ * arithmetic. The host builds it once per line and once per evaluation.
60
+ */
61
+ export interface LocalClock {
62
+ /** Monday-first, 0..6 — the same axis as {@link DiscountScheduleWindow.days}. */
63
+ weekday: number;
64
+ /** Minutes since local midnight, 0..1439. */
65
+ minutes: number;
66
+ }
67
+
68
+ /** `HH:MM` → minutes since midnight, or null when it is not a time at all. */
69
+ export function toMinutes(value: string): number | null {
70
+ const match = /^(\d{2}):(\d{2})$/.exec(value);
71
+ if (match === null) return null;
72
+ const hours = Number(match[1]);
73
+ const minutes = Number(match[2]);
74
+ if (hours > 23 || minutes > 59) return null;
75
+ return hours * 60 + minutes;
76
+ }
77
+
78
+ /** The weekday before this one, on the Monday-first axis. */
79
+ function previousWeekday(weekday: number): number {
80
+ return (weekday + 6) % 7;
81
+ }
82
+
83
+ /**
84
+ * Whether one window is running at `clock`.
85
+ *
86
+ * A `to` EARLIER than `from` is a bar that shuts at 02:00, not a typo — the
87
+ * window runs past midnight into the next day, exactly as `minutesOpen` reads a
88
+ * store's opening hours. Treating it as invalid would refuse the single most
89
+ * common shape of a happy hour.
90
+ *
91
+ * So an overnight window is two intervals: `[from, 24:00)` on each of its own
92
+ * days, and `[00:00, to)` on the day AFTER each of them — which is why the
93
+ * second test asks about `previousWeekday`. A Saturday 00:30 instant is inside
94
+ * a Friday 22:00–02:00 window, and no amount of testing Saturday against
95
+ * `days` would ever discover that.
96
+ *
97
+ * Half-open `[from, to)`, matching `[startsAt, endsAt)`: a window ending at
98
+ * 20:00 is over the instant 20:00 begins.
99
+ */
100
+ function windowCovers(window: DiscountScheduleWindow, clock: LocalClock): boolean {
101
+ const from = toMinutes(window.from);
102
+ const to = toMinutes(window.to);
103
+ // A malformed or empty window covers NOTHING rather than everything: it can
104
+ // only arrive by bypassing the validator, and the safe reading of "I cannot
105
+ // tell what this means" is not "charge the discount".
106
+ if (from === null || to === null || from === to) return false;
107
+ const days = new Set(window.days);
108
+ if (to > from) {
109
+ return days.has(clock.weekday) && clock.minutes >= from && clock.minutes < to;
110
+ }
111
+ if (days.has(clock.weekday) && clock.minutes >= from) return true;
112
+ return days.has(previousWeekday(clock.weekday)) && clock.minutes < to;
113
+ }
114
+
115
+ /**
116
+ * Whether the schedule is running at `clock` — true if ANY window is.
117
+ *
118
+ * `null` schedule → always true, which is every rule that predates this
119
+ * feature. A schedule with no windows is a rule an operator half-built; it
120
+ * covers nothing, and the validator refuses to save one.
121
+ *
122
+ * `clock` of `null` → **true**, and this is the load-bearing failure
123
+ * direction. It means the host could not resolve a timezone, and the same
124
+ * unknown makes `inStoreZone` fall back to the caller's clock rather than throw
125
+ * ("an unknown zone must not take the storefront down — nor refuse a payment")
126
+ * and `readStoreHours` keep a store selling. Failing the other way would
127
+ * silently switch off every scheduled promotion in a store on one bad timezone
128
+ * string — with nothing red anywhere, because a promotion that does not fire
129
+ * looks exactly like a promotion nobody qualified for.
130
+ */
131
+ export function scheduleCovers(
132
+ schedule: DiscountSchedule | null | undefined,
133
+ clock: LocalClock | null | undefined,
134
+ ): boolean {
135
+ if (schedule === null || schedule === undefined) return true;
136
+ if (clock === null || clock === undefined) return true;
137
+ return schedule.windows.some((window) => windowCovers(window, clock));
138
+ }
139
+
140
+ /**
141
+ * Whether a schedule is one this engine can act on at all.
142
+ *
143
+ * Used by the validator and by hosts reading a stored blob back; the evaluator
144
+ * itself is deliberately tolerant (see `windowCovers`) because refusing to
145
+ * price a cart is never the right answer to a bad row.
146
+ */
147
+ export function isUsableSchedule(schedule: DiscountSchedule): boolean {
148
+ if (schedule.windows.length === 0) return false;
149
+ if (schedule.windows.length > MAX_SCHEDULE_WINDOWS) return false;
150
+ return schedule.windows.every(
151
+ (window) =>
152
+ window.days.length > 0 &&
153
+ window.days.every((day) => Number.isInteger(day) && day >= 0 && day <= 6) &&
154
+ toMinutes(window.from) !== null &&
155
+ toMinutes(window.to) !== null &&
156
+ window.from !== window.to,
157
+ );
158
+ }
159
+
160
+ /**
161
+ * The next window that OPENS later today, or null — the teaser's whole input
162
+ * (FUT-996).
163
+ *
164
+ * "Later today" and not "next Friday": a card promising an offer six days out
165
+ * is noise on a screen the shopper is reading now, and it advertises a price
166
+ * they cannot have for almost a week. Scoped to the current weekday, the label
167
+ * is a reason to wait rather than a reason to leave.
168
+ *
169
+ * A window already running returns nothing from here — that is the BADGE's job,
170
+ * and a card must never carry both. The two are mutually exclusive by
171
+ * construction because this only looks at windows whose `from` is still ahead
172
+ * on the clock.
173
+ *
174
+ * Ties break on the earliest opening, so a card teases the offer that arrives
175
+ * soonest rather than whichever row the operator happened to add first.
176
+ */
177
+ export function nextWindowToday(
178
+ schedule: DiscountSchedule | null | undefined,
179
+ clock: LocalClock | null | undefined,
180
+ ): DiscountScheduleWindow | null {
181
+ if (schedule === null || schedule === undefined) return null;
182
+ if (clock === null || clock === undefined) return null;
183
+ const upcoming = schedule.windows
184
+ .filter((window) => {
185
+ const from = toMinutes(window.from);
186
+ if (from === null || from <= clock.minutes) return false;
187
+ return window.days.includes(clock.weekday);
188
+ })
189
+ .sort((a, b) => (toMinutes(a.from) ?? 0) - (toMinutes(b.from) ?? 0));
190
+ return upcoming[0] ?? null;
191
+ }