@12-apps/discounts 1.0.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/README.md +110 -0
- package/package.json +73 -0
- package/src/engine/allocate.ts +133 -0
- package/src/engine/eligibility.ts +160 -0
- package/src/engine/evaluate-passes.ts +217 -0
- package/src/engine/evaluate.ts +339 -0
- package/src/engine/kinds.ts +87 -0
- package/src/engine/preview.ts +164 -0
- package/src/engine/pt-BR.ts +26 -0
- package/src/engine/rejection-copy.ts +69 -0
- package/src/engine/types.ts +147 -0
- package/src/index.ts +69 -0
- package/src/manifest/index.ts +52 -0
- package/src/manifest/server.ts +23 -0
- package/src/server/contribution.ts +42 -0
- package/src/server/copy.ts +64 -0
- package/src/server/index.ts +54 -0
- package/src/server/mcp.ts +197 -0
- package/src/server/pt-BR.ts +26 -0
- package/src/server/routes.ts +200 -0
- package/src/server/search.ts +45 -0
- package/src/server/store.ts +101 -0
- package/src/server/validate.ts +296 -0
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# @12-apps/discounts
|
|
2
|
+
|
|
3
|
+
The discount / promotion domain, extracted from its origin host and made
|
|
4
|
+
host-agnostic: an **evaluator** that decides what a cart actually pays, and an
|
|
5
|
+
**admin CRUD surface** shipped as wiring-contract route descriptors over a
|
|
6
|
+
store port.
|
|
7
|
+
|
|
8
|
+
No database, no framework, no clock, and **no baked user copy**.
|
|
9
|
+
|
|
10
|
+
## What is in the box
|
|
11
|
+
|
|
12
|
+
| entry | what it is |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `@12-apps/discounts` | the engine: `evaluateDiscounts`, `previewItemDiscount`, the vocabulary, the rejection copy port |
|
|
15
|
+
| `@12-apps/discounts/pt-BR` | the pt-BR rejection pack, imported by hand |
|
|
16
|
+
| `@12-apps/discounts/server` | `createApiDiscounts`, the `DiscountStore` port, the permission contribution, the MCP tools |
|
|
17
|
+
| `@12-apps/discounts/server/pt-BR` | the pt-BR pack for the admin surface's sentences |
|
|
18
|
+
| `@12-apps/discounts/manifest` | the shared wiring manifest |
|
|
19
|
+
| `@12-apps/discounts/manifest/server` | the `http` capability |
|
|
20
|
+
|
|
21
|
+
## The model
|
|
22
|
+
|
|
23
|
+
A discount is a **percentage** (basis points, 1..10000) or a **fixed amount**
|
|
24
|
+
(integer cents), scoped to the whole **order**, to **categories**, or to
|
|
25
|
+
specific **items**, fired either **automatically** when its conditions hold or
|
|
26
|
+
by the buyer typing a **coupon code**. Money is integer cents throughout; a
|
|
27
|
+
percentage is basis points, so "12,5%" needs no decimal column anywhere.
|
|
28
|
+
|
|
29
|
+
The evaluator is a pure function of `{ lines, rules, couponCode, now }`:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { evaluateDiscounts } from "@12-apps/discounts";
|
|
33
|
+
|
|
34
|
+
const outcome = evaluateDiscounts({ lines, rules, couponCode, now: new Date() });
|
|
35
|
+
// outcome.subtotalCents, .discountTotalCents, .totalCents
|
|
36
|
+
// outcome.applied[] — what removed money, frozen for the order snapshot
|
|
37
|
+
// outcome.lines[] — per-line cents removed; Σ equals discountTotalCents
|
|
38
|
+
// outcome.rejections[] — only for what the buyer ASKED for, or lost a stack race
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Three properties it holds to the cent, and the suite pins each:
|
|
42
|
+
|
|
43
|
+
- `Σ applied[].amountCents === discountTotalCents === Σ lines[].discountCents`;
|
|
44
|
+
- narrowest scope first, so an order-wide promo can never give away money an
|
|
45
|
+
item-level promo already removed;
|
|
46
|
+
- a discounted order is never worth **less than one cent**. That is a payment
|
|
47
|
+
constraint, not a pricing one — providers reject a zero-amount charge, so an
|
|
48
|
+
order discounted to nothing could never be settled at all. The floor is
|
|
49
|
+
applied *during* allocation, never as a post-hoc trim, which is what keeps
|
|
50
|
+
the sum identity above true.
|
|
51
|
+
|
|
52
|
+
`previewItemDiscount` answers the other question — what a catalog card should
|
|
53
|
+
strike through *before* there is a cart — from the same rules, so a card and a
|
|
54
|
+
cart cannot quote different numbers.
|
|
55
|
+
|
|
56
|
+
## The admin surface
|
|
57
|
+
|
|
58
|
+
`createApiDiscounts({ store, copy })` returns five `WireRoute` descriptors —
|
|
59
|
+
list, read, create, re-state, archive — each **declaring the permission it
|
|
60
|
+
needs** (`discounts:read` / `discounts:write`), so a host's coverage gates can
|
|
61
|
+
read the policy off the assembled table instead of scanning route files.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { createApiDiscounts } from "@12-apps/discounts/server";
|
|
65
|
+
import { PT_BR_DISCOUNTS_SERVER_COPY } from "@12-apps/discounts/server/pt-BR";
|
|
66
|
+
|
|
67
|
+
const { routes } = createApiDiscounts({ store, copy: PT_BR_DISCOUNTS_SERVER_COPY });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Or, through the contract:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { discountsManifest } from "@12-apps/discounts/manifest";
|
|
74
|
+
import { discountsServerManifest } from "@12-apps/discounts/manifest/server";
|
|
75
|
+
|
|
76
|
+
host.adoptServer({
|
|
77
|
+
manifest: discountsManifest,
|
|
78
|
+
server: discountsServerManifest,
|
|
79
|
+
bindings: { http: { mountPath: "/api/admin/:tenantSlug", config: { store, copy } } },
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Delete is a **soft archive**, and that is a product decision: the orders that
|
|
84
|
+
already redeemed a discount keep their snapshot, and its redemption counter
|
|
85
|
+
stays readable for reporting.
|
|
86
|
+
|
|
87
|
+
## What the host still owns
|
|
88
|
+
|
|
89
|
+
**The database.** A discount's rows relate to a host's own catalog and orders —
|
|
90
|
+
targets point at its categories and items, redemptions at its orders and its
|
|
91
|
+
buyers — so neither wiring `db` mode qualifies and the manifest declares none.
|
|
92
|
+
The host owns the schema, the tenant scoping, the transactions and the
|
|
93
|
+
uniqueness conflicts, and answers the `DiscountStore` port. What travels is the
|
|
94
|
+
RULE, not the storage.
|
|
95
|
+
|
|
96
|
+
**Every sentence a human reads.** `DiscountRejectionCopy` (buyer-facing) and
|
|
97
|
+
`DiscountsServerCopy` (operator-facing) are required config with **no
|
|
98
|
+
defaults** — `createApiDiscounts` throws at construction naming every missing
|
|
99
|
+
key. pt-BR packs ship as named exports a host passes by hand, so choosing a
|
|
100
|
+
language is a line in the host's diff rather than a silence.
|
|
101
|
+
|
|
102
|
+
**Who is calling, and whether the plan allows it.** The actor arrives already
|
|
103
|
+
resolved (`{ clientId }`); authentication, tenant resolution, RBAC tiers and
|
|
104
|
+
plan gates stay where they belong.
|
|
105
|
+
|
|
106
|
+
**The words on a pill.** The validity filter a promotions grid usually wants —
|
|
107
|
+
running / scheduled / ended — compares two nullable columns against "now",
|
|
108
|
+
which no `filterableField` expresses, and its values are words a host chose. So
|
|
109
|
+
a host extends the advertised list query and reads its own key back out in its
|
|
110
|
+
store. See `DiscountListInput`.
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@12-apps/discounts",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "The discount/promotion domain: a host-agnostic evaluator (percentage or fixed amount, order/category/item scope, automatic or coupon-triggered, stacking and exclusivity, integer-cent allocation) plus the admin CRUD surface as wiring-contract route descriptors over a store port. No database, no framework, no baked user copy.",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./pt-BR": "./src/engine/pt-BR.ts",
|
|
9
|
+
"./server": "./src/server/index.ts",
|
|
10
|
+
"./server/pt-BR": "./src/server/pt-BR.ts",
|
|
11
|
+
"./manifest": "./src/manifest/index.ts",
|
|
12
|
+
"./manifest/server": "./src/manifest/server.ts",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"clean": "rm -rf node_modules coverage dist",
|
|
17
|
+
"test": "node ../../scripts/vitest-with-teardown.mjs run",
|
|
18
|
+
"test:watch": "vitest watch",
|
|
19
|
+
"lint": "eslint src --max-warnings 0",
|
|
20
|
+
"check-types": "tsc --noEmit",
|
|
21
|
+
"typecheck": "tsc --noEmit"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@12-apps/shared-helpers": "^2.0.1",
|
|
25
|
+
"zod": "^4.3.5"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@12-apps/wiring": ">=1.3.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependenciesMeta": {
|
|
31
|
+
"@12-apps/wiring": {
|
|
32
|
+
"optional": true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@12-apps/eslint-config": "^1.21.0",
|
|
37
|
+
"@12-apps/typescript-config": "^1.20.0",
|
|
38
|
+
"@12-apps/wiring": "^1.5.0",
|
|
39
|
+
"@types/node": "^22.10.6",
|
|
40
|
+
"eslint": "^9.39.1",
|
|
41
|
+
"eslint-plugin-test-flakiness": "^1.4.0",
|
|
42
|
+
"typescript": "^5.9.2",
|
|
43
|
+
"vitest": "^3.2.4"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=22.0.0"
|
|
47
|
+
},
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"registry": "https://registry.npmjs.org",
|
|
51
|
+
"access": "public"
|
|
52
|
+
},
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "git+https://github.com/12-apps/shared-packages.git",
|
|
56
|
+
"directory": "packages/discounts"
|
|
57
|
+
},
|
|
58
|
+
"files": [
|
|
59
|
+
"src",
|
|
60
|
+
"dist",
|
|
61
|
+
"*.js",
|
|
62
|
+
"*.mjs",
|
|
63
|
+
"*.md",
|
|
64
|
+
"!eslint.config.js",
|
|
65
|
+
"!**/__tests__/**",
|
|
66
|
+
"!**/tests/**",
|
|
67
|
+
"!**/*.test.*",
|
|
68
|
+
"!**/*.spec.*",
|
|
69
|
+
"!**/*.stories.*",
|
|
70
|
+
"!**/*.test-story.*",
|
|
71
|
+
"!**/test-helpers.*"
|
|
72
|
+
]
|
|
73
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { MAX_PERCENT_OFF_BP, MIN_PAYABLE_TOTAL_CENTS } from "./kinds";
|
|
2
|
+
import type { DiscountRule } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* MONEY side of the discount evaluator (FUT-245): rules R5 (how many cents one
|
|
6
|
+
* discount is worth against a base) and R7 (how those cents are split across
|
|
7
|
+
* the lines it covers), from the numbered rule set in `./evaluate.ts`.
|
|
8
|
+
*
|
|
9
|
+
* Everything here is integer cents. The single float division lives in the
|
|
10
|
+
* largest-remainder method and is bounded — both operands are well under 2^31,
|
|
11
|
+
* so `amount * remaining` never leaves the exactly-representable integer range
|
|
12
|
+
* and the fraction is only ever used as a SORT KEY, never as money.
|
|
13
|
+
*
|
|
14
|
+
* Kept in its own module because it is the part a reviewer wants to read (and
|
|
15
|
+
* a test wants to drive) on its own, without a cart around it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* R5 — the raw value of one discount against a base.
|
|
20
|
+
*
|
|
21
|
+
* `PERCENTAGE` FLOORS: the house absorbs the sub-cent, never the buyer's total
|
|
22
|
+
* drifting negative. `FIXED_AMOUNT` is clamped to the base, so "R$ 20 off" on a
|
|
23
|
+
* base of R$ 12 removes R$ 12 and never issues a credit. A result of 0 means
|
|
24
|
+
* the discount must NOT be applied — an `R$ 0,00` line is not a promotion.
|
|
25
|
+
*/
|
|
26
|
+
export function rawAmountCents(rule: DiscountRule, baseCents: number): number {
|
|
27
|
+
if (baseCents <= 0) return 0;
|
|
28
|
+
const raw =
|
|
29
|
+
rule.type === "PERCENTAGE"
|
|
30
|
+
? Math.floor((baseCents * (rule.percentOffBp ?? 0)) / MAX_PERCENT_OFF_BP)
|
|
31
|
+
: (rule.amountOffCents ?? 0);
|
|
32
|
+
return Math.max(0, Math.min(raw, baseCents));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* R9 — the most that may ever be taken off a gross amount: everything above
|
|
37
|
+
* `MIN_PAYABLE_TOTAL_CENTS`, because an order the provider cannot charge is
|
|
38
|
+
* worse for the buyer than an order that costs one cent more (see the constant's
|
|
39
|
+
* own prose for the payment-path reason).
|
|
40
|
+
*
|
|
41
|
+
* A gross of zero returns zero, not a negative number: an empty cart, or one
|
|
42
|
+
* holding only free items, stays at zero and simply gets no discount. The floor
|
|
43
|
+
* caps what a promotion may remove; it never manufactures a total out of
|
|
44
|
+
* nothing, and it can never push a total ABOVE its own subtotal.
|
|
45
|
+
*
|
|
46
|
+
* This is the ONE place the floor becomes a number. The evaluator measures its
|
|
47
|
+
* per-application headroom against it (`./evaluate-passes.ts`), the final total
|
|
48
|
+
* is re-clamped with it (`./evaluate.ts`) and the menu badge caps its previewed
|
|
49
|
+
* price with it (`./preview.ts`) — three surfaces, one rule, so a card can never
|
|
50
|
+
* advertise a price the cart would refuse to honour.
|
|
51
|
+
*/
|
|
52
|
+
export function maxDiscountableCents(grossCents: number): number {
|
|
53
|
+
if (grossCents <= 0) return 0;
|
|
54
|
+
return Math.max(0, grossCents - MIN_PAYABLE_TOTAL_CENTS);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface AllocationPart {
|
|
58
|
+
lineId: string;
|
|
59
|
+
/** Position in the input map — the last, stable tie-break. */
|
|
60
|
+
index: number;
|
|
61
|
+
remainingCents: number;
|
|
62
|
+
flooredCents: number;
|
|
63
|
+
/** `exact - floor`, a sort key only. */
|
|
64
|
+
fraction: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toParts(
|
|
68
|
+
amountCents: number,
|
|
69
|
+
entries: readonly (readonly [string, number])[],
|
|
70
|
+
baseTotalCents: number,
|
|
71
|
+
): AllocationPart[] {
|
|
72
|
+
return entries.map(([lineId, remainingCents], index) => {
|
|
73
|
+
const exact = (amountCents * remainingCents) / baseTotalCents;
|
|
74
|
+
const flooredCents = Math.floor(exact);
|
|
75
|
+
return { lineId, index, remainingCents, flooredCents, fraction: exact - flooredCents };
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Largest remainder first, then the bigger line, then input order. */
|
|
80
|
+
function compareParts(a: AllocationPart, b: AllocationPart): number {
|
|
81
|
+
if (a.fraction !== b.fraction) return b.fraction - a.fraction;
|
|
82
|
+
if (a.remainingCents !== b.remainingCents) return b.remainingCents - a.remainingCents;
|
|
83
|
+
return a.index - b.index;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The invariant this whole module exists for: the parts must reconstitute the
|
|
88
|
+
* whole. A leak here would show up as an order whose `discount_total_cents`
|
|
89
|
+
* disagrees with the sum of its `order_items.discount_cents`, which is exactly
|
|
90
|
+
* the reconciliation the COGS engine already refuses to let drift.
|
|
91
|
+
*/
|
|
92
|
+
function assertAllocationSums(
|
|
93
|
+
allocation: ReadonlyMap<string, number>,
|
|
94
|
+
amountCents: number,
|
|
95
|
+
): void {
|
|
96
|
+
let total = 0;
|
|
97
|
+
for (const cents of allocation.values()) total += cents;
|
|
98
|
+
if (total !== amountCents) {
|
|
99
|
+
throw new Error(`Discount allocation summed to ${total}, expected ${amountCents}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* R7 — split `amountCents` across the covered lines in proportion to what is
|
|
105
|
+
* still discountable on each of them, by the LARGEST-REMAINDER method: floor
|
|
106
|
+
* every share, then hand the leftover cents one at a time to the lines with the
|
|
107
|
+
* biggest discarded fraction. The parts therefore sum to `amountCents` exactly,
|
|
108
|
+
* with no per-line rounding leak, and no line is ever allocated more than it had
|
|
109
|
+
* remaining (a line's exact share can only reach its remaining when the discount
|
|
110
|
+
* takes the whole base, in which case there is no leftover cent to hand out).
|
|
111
|
+
*
|
|
112
|
+
* A base of zero — every covered line already fully discounted, or free — gives
|
|
113
|
+
* an all-zero allocation; R5 has already turned that case into a rejection.
|
|
114
|
+
*/
|
|
115
|
+
export function allocateByLargestRemainder(
|
|
116
|
+
amountCents: number,
|
|
117
|
+
remaining: ReadonlyMap<string, number>,
|
|
118
|
+
): Map<string, number> {
|
|
119
|
+
const entries = [...remaining.entries()];
|
|
120
|
+
const allocation = new Map<string, number>(entries.map(([lineId]) => [lineId, 0]));
|
|
121
|
+
const baseTotalCents = entries.reduce((sum, [, cents]) => sum + cents, 0);
|
|
122
|
+
if (amountCents <= 0 || baseTotalCents <= 0) return allocation;
|
|
123
|
+
|
|
124
|
+
const parts = toParts(amountCents, entries, baseTotalCents);
|
|
125
|
+
let deficit = amountCents - parts.reduce((sum, part) => sum + part.flooredCents, 0);
|
|
126
|
+
for (const part of [...parts].sort(compareParts)) {
|
|
127
|
+
const bonus = deficit > 0 ? 1 : 0;
|
|
128
|
+
deficit -= bonus;
|
|
129
|
+
allocation.set(part.lineId, part.flooredCents + bonus);
|
|
130
|
+
}
|
|
131
|
+
assertAllocationSums(allocation, amountCents);
|
|
132
|
+
return allocation;
|
|
133
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { DiscountRejectionReason } from "./kinds";
|
|
2
|
+
import type { DiscountCartLine, DiscountRule } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ELIGIBILITY side of the discount evaluator (FUT-245): rules R2 (the screen)
|
|
6
|
+
* and R3 (the covered set) of the numbered rule set documented in
|
|
7
|
+
* `./evaluate.ts`. Pure — no database, no clock, no I/O; `now` arrives inside the
|
|
8
|
+
* context the caller builds, exactly like `composeSelection` takes its live
|
|
9
|
+
* catalog config rather than reading it.
|
|
10
|
+
*
|
|
11
|
+
* R3 is deliberately expressed as a REVERSE index built once per evaluation
|
|
12
|
+
* (`buildLineIndex`) and then read with `Map`/`Set` lookups: matching every
|
|
13
|
+
* target against every line would be the loop-inside-a-loop the quality gate
|
|
14
|
+
* bans, and a whole menu page's badge preview (FUT-246) walks this path.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The cart facts R2 screens a rule against. All resolved by the caller.
|
|
19
|
+
*
|
|
20
|
+
* Not exported: callers pass an object literal, and knip fails the build on an
|
|
21
|
+
* export nothing imports (`quality:knip` — "ship the consumer with the export").
|
|
22
|
+
*/
|
|
23
|
+
interface ScreenContext {
|
|
24
|
+
/** Evaluation instant. Injected — this module never calls `new Date()`. */
|
|
25
|
+
now: Date;
|
|
26
|
+
/**
|
|
27
|
+
* The PRE-discount cart subtotal. Always the untouched subtotal, never a
|
|
28
|
+
* running total: that is what makes the result independent of the order the
|
|
29
|
+
* discounts happen to be applied in.
|
|
30
|
+
*/
|
|
31
|
+
subtotalCents: number;
|
|
32
|
+
/** Whether R3's covered set came back non-empty for this rule. */
|
|
33
|
+
hasEligibleItems: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Lines of the cart indexed by everything a discount can target, so R3 is a
|
|
38
|
+
* `Map` read per target instead of a scan per line.
|
|
39
|
+
*/
|
|
40
|
+
export interface LineIndex {
|
|
41
|
+
/** Category id (own or any ancestor) → the lines filed under it. */
|
|
42
|
+
byCategory: ReadonlyMap<string, readonly string[]>;
|
|
43
|
+
/** Menu-item id (base or chosen variation) → the lines carrying it. */
|
|
44
|
+
byMenuItem: ReadonlyMap<string, readonly string[]>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type IndexPair = readonly [key: string, lineId: string];
|
|
48
|
+
|
|
49
|
+
function groupLineIds(pairs: readonly IndexPair[]): Map<string, string[]> {
|
|
50
|
+
const grouped = new Map<string, string[]>();
|
|
51
|
+
for (const [key, lineId] of pairs) {
|
|
52
|
+
const bucket = grouped.get(key);
|
|
53
|
+
if (bucket) bucket.push(lineId);
|
|
54
|
+
else grouped.set(key, [lineId]);
|
|
55
|
+
}
|
|
56
|
+
return grouped;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A line is reachable by an ITEM-scoped discount through its BASE (grouping)
|
|
61
|
+
* item — the card the admin actually picked — or through the variation the
|
|
62
|
+
* buyer chose, so "50% off Coca" still fires on "Coca zero lata".
|
|
63
|
+
*/
|
|
64
|
+
function menuItemPairs(line: DiscountCartLine): IndexPair[] {
|
|
65
|
+
return [line.menuItemId, line.variationMenuItemId]
|
|
66
|
+
.filter((id): id is string => id !== null)
|
|
67
|
+
.map((id) => [id, line.lineId] as const);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function categoryPairs(line: DiscountCartLine): IndexPair[] {
|
|
71
|
+
return line.categoryPath.map((categoryId) => [categoryId, line.lineId] as const);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Build the per-evaluation reverse index R3 reads. */
|
|
75
|
+
export function buildLineIndex(lines: readonly DiscountCartLine[]): LineIndex {
|
|
76
|
+
return {
|
|
77
|
+
byCategory: groupLineIds(lines.flatMap(categoryPairs)),
|
|
78
|
+
byMenuItem: groupLineIds(lines.flatMap(menuItemPairs)),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function addAll(target: Set<string>, lineIds: readonly string[] | undefined): void {
|
|
83
|
+
for (const lineId of lineIds ?? []) target.add(lineId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* R3 — the covered set: which of the cart's lines this discount is allowed to
|
|
88
|
+
* touch. `ORDER` covers everything; `CATEGORY` covers every line whose category
|
|
89
|
+
* PATH (own category followed by its ancestors) intersects the targets, so a
|
|
90
|
+
* discount on a top-level category automatically reaches items filed under its
|
|
91
|
+
* subcategories; `ITEM` covers every line whose base or variation menu item is
|
|
92
|
+
* targeted.
|
|
93
|
+
*/
|
|
94
|
+
export function coveredLineIds(
|
|
95
|
+
rule: DiscountRule,
|
|
96
|
+
lines: readonly DiscountCartLine[],
|
|
97
|
+
index: LineIndex,
|
|
98
|
+
): ReadonlySet<string> {
|
|
99
|
+
if (rule.scope === "ORDER") return new Set(lines.map((line) => line.lineId));
|
|
100
|
+
const byCategory = rule.scope === "CATEGORY";
|
|
101
|
+
const targets = byCategory ? rule.targetCategoryIds : rule.targetMenuItemIds;
|
|
102
|
+
const bucket = byCategory ? index.byCategory : index.byMenuItem;
|
|
103
|
+
const covered = new Set<string>();
|
|
104
|
+
for (const target of targets) addAll(covered, bucket.get(target));
|
|
105
|
+
return covered;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The scheduling half of R2: switched off, not started yet, or already over.
|
|
110
|
+
* The window is the HALF-OPEN interval `[startsAt, endsAt)` — `endsAt` is
|
|
111
|
+
* exclusive, so an admin who sets it to midnight of the next day means exactly
|
|
112
|
+
* that and not "one more day".
|
|
113
|
+
*/
|
|
114
|
+
function screenWindow(rule: DiscountRule, now: Date): DiscountRejectionReason | null {
|
|
115
|
+
if (!rule.active) return "INACTIVE";
|
|
116
|
+
if (rule.startsAt !== null && now.getTime() < rule.startsAt.getTime()) return "NOT_STARTED";
|
|
117
|
+
if (rule.endsAt !== null && now.getTime() >= rule.endsAt.getTime()) return "EXPIRED";
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The counting half of R2: the two redemption caps and the minimum-subtotal
|
|
123
|
+
* threshold. `buyerUsageCount` is advisory — it is counted from PAID orders at
|
|
124
|
+
* evaluation time, so two concurrent first-time orders by the same buyer can
|
|
125
|
+
* both pass; the global cap is what the PAID-time predicated UPDATE enforces.
|
|
126
|
+
*/
|
|
127
|
+
function screenLimits(rule: DiscountRule, ctx: ScreenContext): DiscountRejectionReason | null {
|
|
128
|
+
if (rule.usageLimit !== null && rule.usageCount >= rule.usageLimit) {
|
|
129
|
+
return "USAGE_LIMIT_REACHED";
|
|
130
|
+
}
|
|
131
|
+
if (rule.perBuyerLimit !== null && rule.buyerUsageCount >= rule.perBuyerLimit) {
|
|
132
|
+
return "BUYER_LIMIT_REACHED";
|
|
133
|
+
}
|
|
134
|
+
if (rule.minSubtotalCents !== null && ctx.subtotalCents < rule.minSubtotalCents) {
|
|
135
|
+
return "MIN_SUBTOTAL_NOT_MET";
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* R2 — the eligibility screen. Returns the FIRST failing predicate's reason, or
|
|
142
|
+
* null when the rule is a live candidate. The order is not arbitrary: it runs
|
|
143
|
+
* from the least to the most cart-specific reason so the message the buyer ends
|
|
144
|
+
* up seeing is the most useful one ("expirado" beats "não vale para os itens do
|
|
145
|
+
* seu carrinho" for a coupon that is both).
|
|
146
|
+
*
|
|
147
|
+
* The coupon-match predicate is NOT here: a CODE-triggered rule the buyer did
|
|
148
|
+
* not type is not a rejection, it is simply not a candidate, and the caller
|
|
149
|
+
* drops it silently.
|
|
150
|
+
*/
|
|
151
|
+
export function screenRule(
|
|
152
|
+
rule: DiscountRule,
|
|
153
|
+
ctx: ScreenContext,
|
|
154
|
+
): DiscountRejectionReason | null {
|
|
155
|
+
const scheduling = screenWindow(rule, ctx.now);
|
|
156
|
+
if (scheduling !== null) return scheduling;
|
|
157
|
+
const limits = screenLimits(rule, ctx);
|
|
158
|
+
if (limits !== null) return limits;
|
|
159
|
+
return ctx.hasEligibleItems ? null : "NO_ELIGIBLE_ITEMS";
|
|
160
|
+
}
|