@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.
- package/package.json +7 -6
- package/prisma/discounts.prisma +23 -0
- package/prisma/migrations/20260830120000_discount_weekly_schedule/migration.sql +40 -0
- package/src/engine/combo-offer.ts +21 -3
- package/src/engine/eligibility.ts +76 -0
- package/src/engine/en-US.ts +1 -0
- package/src/engine/evaluate.ts +8 -2
- package/src/engine/kinds.ts +7 -0
- package/src/engine/preview.ts +32 -6
- package/src/engine/pt-BR.ts +8 -0
- package/src/engine/schedule.ts +191 -0
- package/src/engine/teaser.ts +150 -0
- package/src/engine/types.ts +36 -0
- package/src/index.ts +32 -0
- package/src/react/api.ts +13 -0
- package/src/react/copy.ts +5 -0
- package/src/react/create-web-discounts.tsx +39 -1
- package/src/react/discount-actions-menu.tsx +4 -0
- package/src/react/discount-form-fields.tsx +90 -6
- package/src/react/discount-form-validate.ts +40 -0
- package/src/react/discount-form.tsx +38 -2
- package/src/react/discounts-screen.tsx +14 -29
- package/src/react/en-US.ts +45 -0
- package/src/react/format.ts +15 -0
- package/src/react/pt-BR.ts +45 -0
- package/src/react/row.ts +64 -0
- package/src/react/schedule-builder.tsx +253 -0
- package/src/react/schedule-copy.ts +74 -0
- package/src/react/schedule-summary.ts +100 -0
- package/src/react/screen-bindings.ts +57 -0
- package/src/react/table-config.ts +15 -0
- package/src/server/copy.ts +3 -0
- package/src/server/en-US.ts +1 -0
- package/src/server/mcp.ts +32 -0
- package/src/server/pt-BR.ts +1 -0
- package/src/server/store.ts +8 -0
- package/src/server/validate.ts +43 -0
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
81
|
+
"@12-apps/i18n": "^1.3.0",
|
|
81
82
|
"@12-apps/typescript-config": "^1.21.0",
|
|
82
|
-
"@12-apps/wiring": "^1.
|
|
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",
|
package/prisma/discounts.prisma
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
}
|
package/src/engine/en-US.ts
CHANGED
|
@@ -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.",
|
package/src/engine/evaluate.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { maxDiscountableCents } from "./allocate";
|
|
2
2
|
import { normalizeDiscountCode } from "./kinds";
|
|
3
|
-
import { buildLineIndex,
|
|
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 =
|
|
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" };
|
package/src/engine/kinds.ts
CHANGED
|
@@ -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",
|
package/src/engine/preview.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { maxDiscountableCents, rawAmountCents } from "./allocate";
|
|
2
|
-
import { buildLineIndex,
|
|
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
|
-
(
|
|
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:
|
|
142
|
-
|
|
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(
|
package/src/engine/pt-BR.ts
CHANGED
|
@@ -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
|
+
}
|