@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 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
+ }