@12-apps/discounts 1.11.0 → 1.13.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 (60) hide show
  1. package/dist/e2e/globs.d.ts +40 -0
  2. package/dist/e2e/globs.d.ts.map +1 -0
  3. package/dist/e2e/globs.js +66 -0
  4. package/dist/e2e/globs.js.map +1 -0
  5. package/dist/e2e/index.d.ts +12 -0
  6. package/dist/e2e/index.d.ts.map +1 -0
  7. package/dist/e2e/index.js +12 -0
  8. package/dist/e2e/index.js.map +1 -0
  9. package/dist/e2e/steps/journey.steps.d.ts +2 -0
  10. package/dist/e2e/steps/journey.steps.d.ts.map +1 -0
  11. package/dist/e2e/steps/journey.steps.js +146 -0
  12. package/dist/e2e/steps/journey.steps.js.map +1 -0
  13. package/dist/e2e/world.d.ts +76 -0
  14. package/dist/e2e/world.d.ts.map +1 -0
  15. package/dist/e2e/world.js +25 -0
  16. package/dist/e2e/world.js.map +1 -0
  17. package/features/promotions.feature +52 -0
  18. package/package.json +28 -9
  19. package/prisma/discounts.prisma +23 -0
  20. package/prisma/migrations/20260830120000_discount_weekly_schedule/migration.sql +40 -0
  21. package/src/e2e/globs.ts +70 -0
  22. package/src/e2e/index.ts +16 -0
  23. package/src/e2e/steps/journey.steps.ts +170 -0
  24. package/src/e2e/world.ts +93 -0
  25. package/src/engine/combo-offer.ts +21 -3
  26. package/src/engine/eligibility.ts +76 -0
  27. package/src/engine/en-US.ts +1 -0
  28. package/src/engine/evaluate.ts +8 -2
  29. package/src/engine/kinds.ts +7 -0
  30. package/src/engine/preview.ts +32 -6
  31. package/src/engine/pt-BR.ts +8 -0
  32. package/src/engine/schedule.ts +191 -0
  33. package/src/engine/teaser.ts +150 -0
  34. package/src/engine/types.ts +36 -0
  35. package/src/index.ts +32 -0
  36. package/src/manifest/index.ts +12 -0
  37. package/src/react/api.ts +13 -0
  38. package/src/react/copy.ts +3 -0
  39. package/src/react/create-web-discounts.tsx +39 -1
  40. package/src/react/discount-actions-menu.tsx +10 -0
  41. package/src/react/discount-form-fields.tsx +90 -6
  42. package/src/react/discount-form-validate.ts +40 -0
  43. package/src/react/discount-form.tsx +38 -2
  44. package/src/react/discounts-screen.tsx +12 -28
  45. package/src/react/en-US.ts +44 -0
  46. package/src/react/format.ts +15 -0
  47. package/src/react/pt-BR.ts +44 -0
  48. package/src/react/row.ts +64 -0
  49. package/src/react/schedule-builder.tsx +253 -0
  50. package/src/react/schedule-copy.ts +74 -0
  51. package/src/react/schedule-summary.ts +100 -0
  52. package/src/react/screen-bindings.ts +57 -0
  53. package/src/react/table-config.ts +15 -0
  54. package/src/server/copy.ts +3 -0
  55. package/src/server/en-US.ts +1 -0
  56. package/src/server/mcp.ts +32 -0
  57. package/src/server/pt-BR.ts +1 -0
  58. package/src/server/store.ts +8 -0
  59. package/src/server/validate.ts +43 -0
  60. package/src/timezone.ts +96 -0
@@ -0,0 +1,70 @@
1
+ import { createRequire } from 'node:module';
2
+ import { dirname, join } from 'node:path';
3
+
4
+ /**
5
+ * Where this package's journeys and their steps live, ON DISK, in the consumer's
6
+ * `node_modules`.
7
+ *
8
+ * `defineBddConfig` takes filesystem globs, not module specifiers — it hands
9
+ * them to a glob matcher, so `'@12-apps/discounts/features/**'` matches
10
+ * nothing and, worse, matches nothing SILENTLY: bddgen compiles the features it
11
+ * found, finds none, and the run is green with zero journeys.
12
+ *
13
+ * So the paths are RESOLVED here instead of written down by every consumer. A
14
+ * host that hard-codes `node_modules/@12-apps/discounts/...` is broken by
15
+ * pnpm's nested store, by a workspace link, and by this package's own layout
16
+ * changing; resolving from the package's own entry point is correct under all
17
+ * three.
18
+ */
19
+ const require_ = createRequire(import.meta.url);
20
+
21
+ /**
22
+ * The package root, found from a file this package definitely exports.
23
+ * `./package.json` is exported precisely so this lookup needs no guess about
24
+ * directory depth.
25
+ */
26
+ function packageRoot(): string {
27
+ return dirname(require_.resolve('@12-apps/discounts/package.json'));
28
+ }
29
+
30
+ /** Every packaged `.feature`, for `defineBddConfig({ features })`. */
31
+ export const discountsFeatures: string = join(packageRoot(), 'features/**/*.feature');
32
+
33
+ /**
34
+ * The base `defineBddConfig({ featuresRoot })` must be given, and it is NOT
35
+ * optional decoration.
36
+ *
37
+ * bddgen mirrors each feature's path RELATIVE TO `featuresRoot` under
38
+ * `outputDir`. Left unset it defaults to the config's own directory, so a
39
+ * feature living under `node_modules/...` compiles to a path Playwright then
40
+ * IGNORES, because its default `testIgnore` excludes `**\/node_modules/**`.
41
+ *
42
+ * The result is the worst kind of green: bddgen reports the features compiled,
43
+ * Playwright collects zero specs from them, and the run passes with the whole
44
+ * packaged suite silently absent.
45
+ *
46
+ * A host running SEVERAL packaged suites gives `featuresRoot` the directory they
47
+ * all sit under, since bddgen takes exactly one — and that is safe to do,
48
+ * because a feature outside it is a hard exit from bddgen, never a quiet
49
+ * omission.
50
+ */
51
+ export const discountsFeaturesRoot: string = join(packageRoot(), 'features');
52
+
53
+ /**
54
+ * Every packaged step definition, for `defineBddConfig({ steps })`.
55
+ *
56
+ * COMPILED JavaScript, and that is the reason this package has a build step at
57
+ * all while everything else it exports is raw `.ts` through the `exports` map.
58
+ * Those entries are consumed by an application's BUNDLER, which transpiles
59
+ * whatever it is pointed at. These are loaded by NODE — `playwright.config.ts`
60
+ * imports this module, and bddgen imports the step files — and Node refuses to
61
+ * strip types from anything under `node_modules`
62
+ * (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`). Playwright's own TS transform
63
+ * does not rescue them either; it skips `node_modules` by design.
64
+ *
65
+ * The host's own steps glob must ALSO be listed — that is where its
66
+ * `defineDiscountsWorld` call lives, and playwright-bdd imports every step
67
+ * file before any scenario runs, which is what makes the registration land in
68
+ * time in every worker.
69
+ */
70
+ export const discountsSteps: string = join(packageRoot(), 'dist/e2e/steps/**/*.js');
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `@12-apps/discounts/e2e` — the packaged promotions journeys and the port a
3
+ * host implements to run them.
4
+ *
5
+ * The `e2e` capability the manifest declares. A host adds the three globs to
6
+ * its bdd config and calls `defineDiscountsWorld` from inside its own steps
7
+ * glob; every scenario this package ships then runs in that host, including the
8
+ * ones added after it integrated. Nothing is copied, so nothing can rot.
9
+ */
10
+ export { discountsFeatures, discountsFeaturesRoot, discountsSteps } from './globs.js';
11
+ export {
12
+ defineDiscountsWorld,
13
+ discountsWorld,
14
+ type DiscountsFixtures,
15
+ type DiscountsWorld,
16
+ } from './world.js';
@@ -0,0 +1,170 @@
1
+ import { expect, type Locator, type Page } from '@playwright/test';
2
+ import { createBdd } from 'playwright-bdd';
3
+
4
+ import { discountsWorld } from '../world.js';
5
+
6
+ /**
7
+ * The packaged journeys' step definitions.
8
+ *
9
+ * Every locator is a test id THIS package's own components render, which is
10
+ * what makes the scenarios portable: `discounts-grid`, `discount-dialog`,
11
+ * `discount-action-edit`, `combo-slots` and the `total-form-field-*` family
12
+ * mean the same thing in any app that mounts `createWebDiscounts`. Everything a
13
+ * host owns — sign-in, routing, how a percentage reads — arrives through the
14
+ * world port rather than being written down here.
15
+ *
16
+ * ## Why `package.json` declares this file under `sideEffects`
17
+ *
18
+ * Every Given/When/Then below runs at IMPORT time — registering a step is the
19
+ * whole point of the module, and it exports nothing anybody imports by name. To
20
+ * a bundler doing tree-shaking that reads as dead weight, and dropping it is
21
+ * licensed: the result is a suite where bddgen reports the features compiled
22
+ * and every scenario then fails on an undefined step.
23
+ *
24
+ * Not one step asserts a SENTENCE. The origin host's spec did — it clicked
25
+ * `menuitem { name: 'Editar' }` and a `Salvar alterações` button — which is why
26
+ * it could only ever have run in that host: copy is required config, so the next
27
+ * adopter's words are different by construction and the spec would fail for a
28
+ * reason unrelated to promotions working.
29
+ */
30
+ const { Given, When, Then } = createBdd();
31
+
32
+ const GRID = 'discounts-grid';
33
+ const DIALOG = 'discount-dialog';
34
+
35
+ /** The name the current scenario minted, so later steps can find its row. */
36
+ let currentName = '';
37
+
38
+ Given('I am signed in as somebody who may manage promotions', async ({ page }) => {
39
+ await discountsWorld().signInAsManager(page);
40
+ });
41
+
42
+ When('I open the promotions screen', async ({ page }) => {
43
+ await discountsWorld().openDiscountsScreen(page);
44
+ await expect(page.getByTestId(GRID)).toBeVisible();
45
+ });
46
+
47
+ Then('the promotions grid is visible', async ({ page }) => {
48
+ await expect(page.getByTestId(GRID)).toBeVisible();
49
+ });
50
+
51
+ /** The create dialog, open and ready to be filled. */
52
+ async function openCreateDialog(page: Page): Promise<Locator> {
53
+ await page.getByTestId('new-discount-button').click();
54
+ const dialog = page.getByTestId(DIALOG);
55
+ await expect(dialog).toBeVisible();
56
+ return dialog;
57
+ }
58
+
59
+ When('I compose a percentage promotion', async ({ page }) => {
60
+ const { newName, percent } = discountsWorld().fixtures;
61
+ currentName = newName('Journey');
62
+ const dialog = await openCreateDialog(page);
63
+ await dialog.getByTestId('total-form-field-name').fill(currentName);
64
+ // Percentage is the default kind; clicking it anyway is the interaction that
65
+ // proves the toggle drives which value field is mounted.
66
+ await dialog.getByTestId('total-form-field-kind-item-PERCENTAGE').click();
67
+ await dialog.getByTestId('total-form-field-percentOff').fill(percent);
68
+ await dialog.getByTestId('discount-form-submit').click();
69
+ await expect(dialog).toBeHidden();
70
+ });
71
+
72
+ /**
73
+ * The row is found by NAME, and a name is the one user-visible string here that
74
+ * is not copy — the scenario chose it a moment ago, so naming it is naming a
75
+ * fixture rather than an adopter's wording.
76
+ */
77
+ function rowFor(page: Page, name: string): Locator {
78
+ return page.getByTestId(GRID).getByRole('row', { name: new RegExp(name) });
79
+ }
80
+
81
+ Then('the promotion is listed with its rate', async ({ page }) => {
82
+ const row = rowFor(page, currentName);
83
+ await expect(row).toBeVisible();
84
+ // The round trip is the claim: a rate typed as a percentage comes back as one
85
+ // only if the conversion into basis points and back out BOTH ran. How it
86
+ // reads is the host's formatting, so the host states it.
87
+ await expect(row.getByText(discountsWorld().fixtures.percentInGrid)).toBeVisible();
88
+ });
89
+
90
+ When('I start composing a promotion', async ({ page }) => {
91
+ await openCreateDialog(page);
92
+ });
93
+
94
+ Then('the percentage field is the one on screen', async ({ page }) => {
95
+ await expect(page.getByTestId(DIALOG).getByTestId('total-form-field-percentOff')).toBeVisible();
96
+ });
97
+
98
+ When('I switch it to a fixed amount', async ({ page }) => {
99
+ await page.getByTestId(DIALOG).getByTestId('total-form-field-kind-item-FIXED_AMOUNT').click();
100
+ });
101
+
102
+ Then('the amount field REPLACES the percentage one', async ({ page }) => {
103
+ const dialog = page.getByTestId(DIALOG);
104
+ await expect(dialog.getByTestId('total-form-field-amountOff')).toBeVisible();
105
+ // Replaced, not hidden: a merely-hidden percentage input would still submit a
106
+ // stale rate alongside an amount the operator typed.
107
+ await expect(dialog.getByTestId('total-form-field-percentOff')).toHaveCount(0);
108
+ });
109
+
110
+ When('I switch it to a combo', async ({ page }) => {
111
+ await page.getByTestId(DIALOG).getByTestId('total-form-field-kind-item-COMBO').click();
112
+ });
113
+
114
+ Then('the scope question disappears and the group builder takes its place', async ({ page }) => {
115
+ const dialog = page.getByTestId(DIALOG);
116
+ // A combo COVERS the groups it is made of, so scope stops being a question at
117
+ // all — unmounted rather than disabled. That is what stops an operator
118
+ // building a combination the write path then refuses.
119
+ await expect(dialog.getByTestId('total-form-field-scope-item-ORDER')).toHaveCount(0);
120
+ await expect(dialog.getByTestId('combo-slots')).toBeVisible();
121
+ });
122
+
123
+ Then('the scope question comes back for a plain kind', async ({ page }) => {
124
+ const dialog = page.getByTestId(DIALOG);
125
+ await dialog.getByTestId('total-form-field-kind-item-FIXED_AMOUNT').click();
126
+ await expect(dialog.getByTestId('total-form-field-scope-item-ORDER')).toBeVisible();
127
+ });
128
+
129
+ When('I set the activation to a coupon code', async ({ page }) => {
130
+ await page.getByTestId(DIALOG).getByTestId('total-form-field-trigger-item-CODE').click();
131
+ });
132
+
133
+ Then('the coupon field is revealed', async ({ page }) => {
134
+ await expect(page.getByTestId(DIALOG).getByTestId('total-form-field-code')).toBeVisible();
135
+ });
136
+
137
+ /** Open the row's kebab and take one of its entries by its STABLE id. */
138
+ async function chooseRowAction(page: Page, action: 'edit' | 'delete'): Promise<void> {
139
+ await rowFor(page, currentName).locator('[data-testid^="discount-actions-"]').click();
140
+ await page.getByTestId(`discount-action-${action}`).click();
141
+ }
142
+
143
+ When('I rename it from the row menu', async ({ page }) => {
144
+ await chooseRowAction(page, 'edit');
145
+ const dialog = page.getByTestId(DIALOG);
146
+ await expect(dialog).toBeVisible();
147
+ currentName = `${currentName} II`;
148
+ await dialog.getByTestId('total-form-field-name').fill(currentName);
149
+ await dialog.getByTestId('discount-form-submit').click();
150
+ await expect(dialog).toBeHidden();
151
+ });
152
+
153
+ Then('the grid shows the new name', async ({ page }) => {
154
+ // The grid re-read the server — proof the write landed, rather than proof the
155
+ // dialog closed.
156
+ await expect(rowFor(page, currentName)).toBeVisible();
157
+ });
158
+
159
+ When('I delete it from the row menu', async ({ page }) => {
160
+ await chooseRowAction(page, 'delete');
161
+ // The menu entry only OPENS the confirmation; the DELETE leaves when it is
162
+ // confirmed.
163
+ await page.getByTestId('discount-delete-confirm-confirm-button').click();
164
+ });
165
+
166
+ Then('the promotion is gone from the grid', async ({ page }) => {
167
+ // A soft delete: the row leaves the list even though the record survives for
168
+ // its redemption history.
169
+ await expect(rowFor(page, currentName)).toHaveCount(0);
170
+ });
@@ -0,0 +1,93 @@
1
+ import type { Page } from '@playwright/test';
2
+
3
+ /**
4
+ * The port a HOST implements to run the packaged promotions journeys.
5
+ *
6
+ * The journeys are portable because every assertion in them reads a test id
7
+ * this package's own components render — `discounts-grid`, `discount-dialog`,
8
+ * `discount-action-edit`, `total-form-field-*` mean the same thing in any app
9
+ * that mounts `createWebDiscounts`. What is NOT portable is everything around
10
+ * them: how an app signs somebody in, how it routes to the promotions screen,
11
+ * how it returns to a known state, and how it renders a percentage.
12
+ *
13
+ * It replaces a 167-line spec in the origin host (`discounts.e2e.ts`), which
14
+ * asserted this package's test ids from a repo it does not build — the
15
+ * arrangement whose failure mode is silent from here: rename an id inside the
16
+ * package and a spec it cannot see goes red somewhere it does not run.
17
+ */
18
+
19
+ /**
20
+ * Facts about the host that the assertions have to name.
21
+ *
22
+ * A journey has to talk about SOMETHING: a permission to tick, a name to type,
23
+ * a rendered value to look for. None of that can live in a feature file without
24
+ * inventing one host's fixture as though it were everybody's.
25
+ */
26
+ export interface DiscountsFixtures {
27
+ /**
28
+ * A name no seed uses, minted FRESH per call.
29
+ *
30
+ * A function rather than a string because these journeys WRITE: the
31
+ * promotions table carries a per-tenant unique index on the name, so a
32
+ * constant would collide with itself on the second scenario and on every
33
+ * re-run against the same database.
34
+ */
35
+ newName: (prefix: string) => string;
36
+ /** The percentage a create journey types, as the form takes it (e.g. `15`). */
37
+ percent: string;
38
+ /**
39
+ * How that percentage READS in the grid once saved (e.g. `15%`).
40
+ *
41
+ * Stated by the host because formatting is the host's: `createWebDiscounts`
42
+ * takes a locale and a currency, so the same 15 renders differently in two
43
+ * adopters. It is the round-trip that matters — a value typed as a percentage
44
+ * comes back as one only if the conversion into basis points and back out
45
+ * both ran — and this is the half a package cannot spell.
46
+ */
47
+ percentInGrid: string;
48
+ }
49
+
50
+ /** What a host must be able to do for these journeys to run in it. */
51
+ export interface DiscountsWorld {
52
+ /**
53
+ * Put the browser in a known signed-in state as somebody who may manage
54
+ * promotions, and return the store to a known one.
55
+ *
56
+ * These journeys write, so the reset is load-bearing rather than hygiene: a
57
+ * scenario that renamed a promotion would otherwise hand the next one a
58
+ * catalog it did not expect.
59
+ */
60
+ signInAsManager(page: Page): Promise<void>;
61
+ /** Land on the promotions screen and wait until its grid has rendered. */
62
+ openDiscountsScreen(page: Page): Promise<void>;
63
+ fixtures: DiscountsFixtures;
64
+ }
65
+
66
+ let installed: DiscountsWorld | null = null;
67
+
68
+ /**
69
+ * Install the host's implementation. Call this from a module inside the host's
70
+ * OWN steps glob — playwright-bdd imports every step file before any scenario
71
+ * runs, so a top-level call there is registered in time, in every worker.
72
+ */
73
+ export function defineDiscountsWorld(world: DiscountsWorld): void {
74
+ installed = world;
75
+ }
76
+
77
+ /**
78
+ * The installed world, or a refusal naming the fix.
79
+ *
80
+ * Throws rather than returning null: a step that ran against an absent world
81
+ * would fail on whatever it touched next, somewhere unrelated to the actual
82
+ * mistake, which is a diagnosis nobody should have to make twice.
83
+ */
84
+ export function discountsWorld(): DiscountsWorld {
85
+ if (!installed) {
86
+ throw new Error(
87
+ 'No discounts e2e world is installed. Call defineDiscountsWorld({ … }) from a module ' +
88
+ "inside your own `steps` glob — playwright-bdd imports those before any " +
89
+ 'scenario runs, which is what makes the registration land in every worker.',
90
+ );
91
+ }
92
+ return installed;
93
+ }
@@ -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.",