@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.
- package/dist/e2e/globs.d.ts +40 -0
- package/dist/e2e/globs.d.ts.map +1 -0
- package/dist/e2e/globs.js +66 -0
- package/dist/e2e/globs.js.map +1 -0
- package/dist/e2e/index.d.ts +12 -0
- package/dist/e2e/index.d.ts.map +1 -0
- package/dist/e2e/index.js +12 -0
- package/dist/e2e/index.js.map +1 -0
- package/dist/e2e/steps/journey.steps.d.ts +2 -0
- package/dist/e2e/steps/journey.steps.d.ts.map +1 -0
- package/dist/e2e/steps/journey.steps.js +146 -0
- package/dist/e2e/steps/journey.steps.js.map +1 -0
- package/dist/e2e/world.d.ts +76 -0
- package/dist/e2e/world.d.ts.map +1 -0
- package/dist/e2e/world.js +25 -0
- package/dist/e2e/world.js.map +1 -0
- package/features/promotions.feature +52 -0
- package/package.json +28 -9
- package/prisma/discounts.prisma +23 -0
- package/prisma/migrations/20260830120000_discount_weekly_schedule/migration.sql +40 -0
- package/src/e2e/globs.ts +70 -0
- package/src/e2e/index.ts +16 -0
- package/src/e2e/steps/journey.steps.ts +170 -0
- package/src/e2e/world.ts +93 -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/manifest/index.ts +12 -0
- package/src/react/api.ts +13 -0
- package/src/react/copy.ts +3 -0
- package/src/react/create-web-discounts.tsx +39 -1
- package/src/react/discount-actions-menu.tsx +10 -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 +12 -28
- package/src/react/en-US.ts +44 -0
- package/src/react/format.ts +15 -0
- package/src/react/pt-BR.ts +44 -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/src/e2e/globs.ts
ADDED
|
@@ -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');
|
package/src/e2e/index.ts
ADDED
|
@@ -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
|
+
});
|
package/src/e2e/world.ts
ADDED
|
@@ -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(
|
|
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.",
|