@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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { maxDiscountableCents, rawAmountCents } from "./allocate";
|
|
2
|
+
import { buildLineIndex, coveredLineIds, screenRule } from "./eligibility";
|
|
3
|
+
import { compareRuleOrder } from "./evaluate-passes";
|
|
4
|
+
import type { DiscountCartLine, DiscountRule } from "./types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Menu-badge preview (FUT-246): what a single item's price looks like BEFORE
|
|
8
|
+
* there is a cart, so the storefront can strike the old price through on the
|
|
9
|
+
* catalog card. It reuses R2/R3/R5 from the evaluator rather than re-deriving
|
|
10
|
+
* them — one pricing rule, one implementation.
|
|
11
|
+
*
|
|
12
|
+
* A badge is a PROMISE, so this deliberately considers far less than a real
|
|
13
|
+
* evaluation does:
|
|
14
|
+
* - `AUTOMATIC` only — a coupon price the buyer has not typed yet is not this
|
|
15
|
+
* item's price;
|
|
16
|
+
* - `ORDER` scope is excluded — an order-wide promo is not an item price and
|
|
17
|
+
* would badge the whole menu;
|
|
18
|
+
* - a `minSubtotalCents` threshold is excluded — a card promising a price that
|
|
19
|
+
* only materializes above some basket total is a lie;
|
|
20
|
+
* - `stackable` is ignored: this previews ONE item at ONE price, and which
|
|
21
|
+
* promotions end up combining is a cart-level question (R8) that cannot be
|
|
22
|
+
* answered from a menu card.
|
|
23
|
+
*
|
|
24
|
+
* The R9 payable floor applies here too: the badged price is capped so it never
|
|
25
|
+
* drops below `MIN_PAYABLE_TOTAL_CENTS`, exactly as the evaluator caps a cart.
|
|
26
|
+
* A card advertising a zero price would be promising a free item the checkout
|
|
27
|
+
* is structurally unable to deliver — the provider rejects a zero charge — so a
|
|
28
|
+
* 100%-off item badges at one cent, and an item already priced at one cent
|
|
29
|
+
* badges nothing at all. Both surfaces read the floor from the same
|
|
30
|
+
* `maxDiscountableCents` in `./allocate.ts`, which is what keeps the card, the
|
|
31
|
+
* product sheet and the cart quoting the same number.
|
|
32
|
+
*
|
|
33
|
+
* `perBuyerLimit` is the ONE place that promise is knowingly weaker, and it is
|
|
34
|
+
* documented here rather than quietly inconsistent. The screen itself DOES
|
|
35
|
+
* honour the cap — `screenRule` rejects `BUYER_LIMIT_REACHED` — but only
|
|
36
|
+
* against the `buyerUsageCount` the caller resolved — and a catalog badge is
|
|
37
|
+
* typically an ANONYMOUS read, where a host has no buyer to count against and
|
|
38
|
+
* passes 0. A buyer who has already exhausted a once-per-buyer promotion
|
|
39
|
+
* therefore still sees its badge.
|
|
40
|
+
*
|
|
41
|
+
* Two alternatives were weighed and rejected:
|
|
42
|
+
*
|
|
43
|
+
* 1. drop every per-buyer-capped rule from the badge, the way
|
|
44
|
+
* `minSubtotalCents` is dropped. That would hide a first-purchase promotion
|
|
45
|
+
* from precisely the first-time buyers it exists for — wrong for the
|
|
46
|
+
* overwhelming majority to protect a minority from a mild disappointment;
|
|
47
|
+
* 2. resolve the buyer on these reads. A product sheet often could, but a
|
|
48
|
+
* catalog listing is deliberately unauthenticated — and a buyer-aware sheet
|
|
49
|
+
* over an anonymous card reintroduces the card/sheet price disagreement
|
|
50
|
+
* FUT-246 was filed about, one screen further in.
|
|
51
|
+
*
|
|
52
|
+
* So the badge stays optimistic and the CART tells the truth: the evaluator
|
|
53
|
+
* screens the same rule against a real `buyerUsageCount` and reports
|
|
54
|
+
* `BUYER_LIMIT_REACHED`, before a single cent is charged.
|
|
55
|
+
*
|
|
56
|
+
* Pure, like the rest of the engine: `now` is injected and the rules arrive
|
|
57
|
+
* pre-loaded once per catalog request, never per product.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
export interface ItemDiscountPreviewInput {
|
|
61
|
+
/** The BASE (grouping) menu-item id the card renders. */
|
|
62
|
+
menuItemId: string;
|
|
63
|
+
/** The variation whose price the card shows, when it shows one. */
|
|
64
|
+
variationMenuItemId?: string | null;
|
|
65
|
+
/** The item's own category id followed by its ancestors, nearest first. */
|
|
66
|
+
categoryPath: readonly string[];
|
|
67
|
+
/** The price the card would otherwise show, in integer cents. */
|
|
68
|
+
priceCents: number;
|
|
69
|
+
/** The tenant's live discount rules, loaded once for the whole menu. */
|
|
70
|
+
rules: readonly DiscountRule[];
|
|
71
|
+
/** Evaluation instant. Injected — this module never calls `new Date()`. */
|
|
72
|
+
now: Date;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What a host needs to render the badge: the struck price and its label. */
|
|
76
|
+
export interface ItemDiscountPreview {
|
|
77
|
+
/**
|
|
78
|
+
* The price to show, in integer cents. Always strictly below `priceCents` and
|
|
79
|
+
* never below `MIN_PAYABLE_TOTAL_CENTS` — a card may not advertise zero.
|
|
80
|
+
*/
|
|
81
|
+
discountedPriceCents: number;
|
|
82
|
+
/**
|
|
83
|
+
* What to name the promotion on the ribbon: the merchant's own discount name,
|
|
84
|
+
* which is already copy they wrote, in their own language. The `-NN%` badge is
|
|
85
|
+
* derived downstream from the two prices, so no money is formatted here.
|
|
86
|
+
*/
|
|
87
|
+
promoLabel: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The synthetic one-line cart the preview screens against. */
|
|
91
|
+
const PREVIEW_LINE_ID = "preview";
|
|
92
|
+
|
|
93
|
+
function isBadgeable(rule: DiscountRule): boolean {
|
|
94
|
+
return (
|
|
95
|
+
rule.trigger === "AUTOMATIC" && rule.scope !== "ORDER" && rule.minSubtotalCents === null
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toPreviewLine(input: ItemDiscountPreviewInput): DiscountCartLine {
|
|
100
|
+
return {
|
|
101
|
+
lineId: PREVIEW_LINE_ID,
|
|
102
|
+
menuItemId: input.menuItemId,
|
|
103
|
+
variationMenuItemId: input.variationMenuItemId ?? null,
|
|
104
|
+
categoryPath: input.categoryPath,
|
|
105
|
+
quantity: 1,
|
|
106
|
+
unitPriceCents: input.priceCents,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The best badge for this item: the biggest saving, ties broken by the same
|
|
112
|
+
* canonical rule order the evaluator uses, so the card and the cart never
|
|
113
|
+
* disagree about which of two competing promotions is "the" one.
|
|
114
|
+
*
|
|
115
|
+
* Amounts are capped by R9 BEFORE they are ranked, so the winner is the one that
|
|
116
|
+
* really takes the most off the badged price — the same reason the evaluator's
|
|
117
|
+
* R8 compares post-clamp outcomes. A rule left with nothing to remove falls out
|
|
118
|
+
* on the `> 0` filter and is simply not badged.
|
|
119
|
+
*/
|
|
120
|
+
function bestBadgeRule(
|
|
121
|
+
input: ItemDiscountPreviewInput,
|
|
122
|
+
line: DiscountCartLine,
|
|
123
|
+
): { rule: DiscountRule; amountCents: number } | null {
|
|
124
|
+
const index = buildLineIndex([line]);
|
|
125
|
+
const scored = input.rules
|
|
126
|
+
.filter(isBadgeable)
|
|
127
|
+
.filter(
|
|
128
|
+
(rule) =>
|
|
129
|
+
screenRule(rule, {
|
|
130
|
+
now: input.now,
|
|
131
|
+
subtotalCents: input.priceCents,
|
|
132
|
+
hasEligibleItems: coveredLineIds(rule, [line], index).has(PREVIEW_LINE_ID),
|
|
133
|
+
}) === null,
|
|
134
|
+
)
|
|
135
|
+
.map((rule) => ({
|
|
136
|
+
rule,
|
|
137
|
+
amountCents: Math.min(
|
|
138
|
+
rawAmountCents(rule, input.priceCents),
|
|
139
|
+
maxDiscountableCents(input.priceCents),
|
|
140
|
+
),
|
|
141
|
+
}))
|
|
142
|
+
.filter((entry) => entry.amountCents > 0);
|
|
143
|
+
scored.sort((a, b) => {
|
|
144
|
+
const byAmount = b.amountCents - a.amountCents;
|
|
145
|
+
return byAmount !== 0 ? byAmount : compareRuleOrder(a.rule, b.rule);
|
|
146
|
+
});
|
|
147
|
+
return scored[0] ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The discounted price to badge this menu item with, or null when no automatic
|
|
152
|
+
* item-or-category promotion currently removes a whole cent from it — a
|
|
153
|
+
* "-0%" badge is worse than no badge at all.
|
|
154
|
+
*/
|
|
155
|
+
export function previewItemDiscount(
|
|
156
|
+
input: ItemDiscountPreviewInput,
|
|
157
|
+
): ItemDiscountPreview | null {
|
|
158
|
+
const best = bestBadgeRule(input, toPreviewLine(input));
|
|
159
|
+
if (best === null) return null;
|
|
160
|
+
return {
|
|
161
|
+
discountedPriceCents: input.priceCents - best.amountCents,
|
|
162
|
+
promoLabel: best.rule.name,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { MIN_SUBTOTAL_TOKEN, type DiscountRejectionCopy } from "./rejection-copy";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The pt-BR pack — a NAMED export a host passes by hand
|
|
5
|
+
* (`rejectionCopy: PT_BR_DISCOUNT_REJECTION_COPY`), never a default. The
|
|
6
|
+
* filename is what exempts this file from the copy-portability gate:
|
|
7
|
+
* Portuguese may ship, it may not be silent.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately coarser than the reason set: switched off, not started yet and
|
|
10
|
+
* expired all read the same, because the difference only leaks how the
|
|
11
|
+
* merchant schedules promotions and the buyer could not act on it either way.
|
|
12
|
+
*/
|
|
13
|
+
export const PT_BR_DISCOUNT_REJECTION_COPY: DiscountRejectionCopy = {
|
|
14
|
+
UNKNOWN_CODE: "Cupom inválido ou expirado.",
|
|
15
|
+
INACTIVE: "Cupom inválido ou expirado.",
|
|
16
|
+
NOT_STARTED: "Cupom inválido ou expirado.",
|
|
17
|
+
EXPIRED: "Cupom inválido ou expirado.",
|
|
18
|
+
MIN_SUBTOTAL_NOT_MET: `Este cupom exige um pedido mínimo de ${MIN_SUBTOTAL_TOKEN}.`,
|
|
19
|
+
USAGE_LIMIT_REACHED: "Este cupom já atingiu o limite de uso.",
|
|
20
|
+
BUYER_LIMIT_REACHED: "Você já usou este cupom.",
|
|
21
|
+
NO_ELIGIBLE_ITEMS: "Este cupom não vale para os itens do seu carrinho.",
|
|
22
|
+
ZERO_VALUE: "Este cupom não vale para os itens do seu carrinho.",
|
|
23
|
+
NOT_STACKABLE: "Outra promoção já aplicada é melhor que este cupom.",
|
|
24
|
+
EMPTY_CART: "Seu carrinho está vazio.",
|
|
25
|
+
minSubtotalUnknown: "Este cupom exige um valor mínimo de pedido.",
|
|
26
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { DISCOUNT_REJECTION_REASONS, type DiscountRejectionReason } from "./kinds";
|
|
2
|
+
import type { DiscountRejection } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The sentence a buyer is shown when a discount they asked for did not stick
|
|
6
|
+
* (FUT-235) — required HOST config, with NO defaults.
|
|
7
|
+
*
|
|
8
|
+
* The evaluator's reasons are machine-readable and stay the package's own; the
|
|
9
|
+
* SENTENCE is the host's, because it is the only part a user reads. A default
|
|
10
|
+
* in the origin host's language reads as finished to the next host right up
|
|
11
|
+
* until someone sees it, which is the rule the copy-portability gate enforces
|
|
12
|
+
* repo-wide. A pt-BR host imports {@link PT_BR_DISCOUNT_REJECTION_COPY} from
|
|
13
|
+
* `@12-apps/discounts/pt-BR` and passes it by hand — one reviewable line.
|
|
14
|
+
*
|
|
15
|
+
* Hosts are encouraged to make the copy COARSER than the reasons. A coupon
|
|
16
|
+
* that is switched off, has not started yet or is over can honestly read the
|
|
17
|
+
* same to the buyer: the distinction only leaks how the merchant schedules
|
|
18
|
+
* their promotions, and there is nothing the buyer could do differently.
|
|
19
|
+
*/
|
|
20
|
+
export type DiscountRejectionCopy = Readonly<
|
|
21
|
+
Record<DiscountRejectionReason, string>
|
|
22
|
+
> & {
|
|
23
|
+
/**
|
|
24
|
+
* Said when `MIN_SUBTOTAL_NOT_MET` arrives WITHOUT its threshold — never
|
|
25
|
+
* show the raw token to a buyer.
|
|
26
|
+
*/
|
|
27
|
+
readonly minSubtotalUnknown: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The placeholder {@link DiscountRejectionCopy.MIN_SUBTOTAL_NOT_MET} may carry,
|
|
32
|
+
* replaced with the formatted threshold. A sentence that omits it is left
|
|
33
|
+
* alone, so a host that would rather not quote the number simply does not
|
|
34
|
+
* write the token.
|
|
35
|
+
*/
|
|
36
|
+
export const MIN_SUBTOTAL_TOKEN = "{minimum}";
|
|
37
|
+
|
|
38
|
+
const COPY_KEYS: readonly (keyof DiscountRejectionCopy)[] = [
|
|
39
|
+
...DISCOUNT_REJECTION_REASONS,
|
|
40
|
+
"minSubtotalUnknown",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Every key present and non-blank — the assembly-time check, so a host that
|
|
45
|
+
* forgot a reason learns at wiring rather than from a buyer staring at
|
|
46
|
+
* `undefined`.
|
|
47
|
+
*/
|
|
48
|
+
export function missingRejectionCopy(copy: DiscountRejectionCopy | undefined): string[] {
|
|
49
|
+
if (copy === undefined) return [...COPY_KEYS];
|
|
50
|
+
return COPY_KEYS.filter((key) => typeof copy[key] !== "string" || copy[key].trim() === "");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The message for one rejection, with the minimum-subtotal threshold filled in
|
|
55
|
+
* when the reason carries one and the host's sentence asks for it.
|
|
56
|
+
*
|
|
57
|
+
* `formatCents` is INJECTED rather than imported: money formatting is a
|
|
58
|
+
* locale and currency decision, and this package has neither.
|
|
59
|
+
*/
|
|
60
|
+
export function discountRejectionMessage(
|
|
61
|
+
rejection: DiscountRejection,
|
|
62
|
+
copy: DiscountRejectionCopy,
|
|
63
|
+
formatCents: (cents: number) => string,
|
|
64
|
+
): string {
|
|
65
|
+
const template = copy[rejection.reason];
|
|
66
|
+
if (!template.includes(MIN_SUBTOTAL_TOKEN)) return template;
|
|
67
|
+
if (rejection.minSubtotalCents === undefined) return copy.minSubtotalUnknown;
|
|
68
|
+
return template.replace(MIN_SUBTOTAL_TOKEN, formatCents(rejection.minSubtotalCents));
|
|
69
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DiscountRejectionReason,
|
|
3
|
+
DiscountScope,
|
|
4
|
+
DiscountTrigger,
|
|
5
|
+
DiscountType,
|
|
6
|
+
} from "./kinds";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The evaluator's type contract (FUT-245). Types only, no logic — the shape
|
|
10
|
+
* `./evaluate.ts` and whatever loads a host's rows agree on.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One discount as the evaluator sees it: a plain value object with every
|
|
15
|
+
* stored and every counted fact already resolved. The evaluator never touches
|
|
16
|
+
* a database and never reads the clock — the host's loader builds these from
|
|
17
|
+
* its own rows, and `now` is injected.
|
|
18
|
+
*/
|
|
19
|
+
export interface DiscountRule {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
type: DiscountType;
|
|
23
|
+
/** 1..10000 basis points. Non-null iff `type === "PERCENTAGE"`. */
|
|
24
|
+
percentOffBp: number | null;
|
|
25
|
+
/** Cents off, > 0. Non-null iff `type === "FIXED_AMOUNT"`. */
|
|
26
|
+
amountOffCents: number | null;
|
|
27
|
+
scope: DiscountScope;
|
|
28
|
+
/** Target category ids. Non-empty iff `scope === "CATEGORY"`. */
|
|
29
|
+
targetCategoryIds: readonly string[];
|
|
30
|
+
/** Target menu-item ids. Non-empty iff `scope === "ITEM"`. */
|
|
31
|
+
targetMenuItemIds: readonly string[];
|
|
32
|
+
trigger: DiscountTrigger;
|
|
33
|
+
/** Normalized (trimmed, upper-cased) code. Non-null iff `trigger === "CODE"`. */
|
|
34
|
+
code: string | null;
|
|
35
|
+
active: boolean;
|
|
36
|
+
/** Inclusive lower bound of the active window; null = already open. */
|
|
37
|
+
startsAt: Date | null;
|
|
38
|
+
/** EXCLUSIVE upper bound of the active window; null = never closes. */
|
|
39
|
+
endsAt: Date | null;
|
|
40
|
+
/** Minimum PRE-discount subtotal in cents; null = no threshold. */
|
|
41
|
+
minSubtotalCents: number | null;
|
|
42
|
+
/** Global redemption cap; null = uncapped. */
|
|
43
|
+
usageLimit: number | null;
|
|
44
|
+
/** Redemptions already counted globally. */
|
|
45
|
+
usageCount: number;
|
|
46
|
+
/** Per-buyer redemption cap; null = uncapped. */
|
|
47
|
+
perBuyerLimit: number | null;
|
|
48
|
+
/** Redemptions this buyer has already PAID for. 0 for an anonymous cart. */
|
|
49
|
+
buyerUsageCount: number;
|
|
50
|
+
/** False ⇒ exclusive: if it wins, nothing else applies. */
|
|
51
|
+
stackable: boolean;
|
|
52
|
+
/** Deterministic tie-break key, ascending. */
|
|
53
|
+
createdAt: Date;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One cart line, priced exactly as the host prices it: `unitPriceCents`
|
|
58
|
+
* already includes the chosen variation and every paid extra, so the
|
|
59
|
+
* evaluator never re-derives money.
|
|
60
|
+
*/
|
|
61
|
+
export interface DiscountCartLine {
|
|
62
|
+
/** Cart-line id (or order-line index) — the allocation key. */
|
|
63
|
+
lineId: string;
|
|
64
|
+
/** The BASE (grouping) menu-item id — the card the admin picked as a target. */
|
|
65
|
+
menuItemId: string;
|
|
66
|
+
/** The chosen variation's menu-item id, when the line has one. */
|
|
67
|
+
variationMenuItemId: string | null;
|
|
68
|
+
/**
|
|
69
|
+
* The line's own category id followed by its ancestors, nearest first, so a
|
|
70
|
+
* discount on a top-level category also covers its subcategories' items.
|
|
71
|
+
* Empty for an uncategorized item.
|
|
72
|
+
*/
|
|
73
|
+
categoryPath: readonly string[];
|
|
74
|
+
quantity: number;
|
|
75
|
+
/** Composed unit price in integer cents. */
|
|
76
|
+
unitPriceCents: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface DiscountEvaluationInput {
|
|
80
|
+
lines: readonly DiscountCartLine[];
|
|
81
|
+
/** Every non-archived discount of the tenant; the evaluator screens them. */
|
|
82
|
+
rules: readonly DiscountRule[];
|
|
83
|
+
/** Raw code as the buyer typed it, or null. Normalized internally. */
|
|
84
|
+
couponCode: string | null;
|
|
85
|
+
/** Evaluation instant. Injected — the evaluator never calls `new Date()`. */
|
|
86
|
+
now: Date;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Why a discount the buyer asked for was not applied. */
|
|
90
|
+
export interface DiscountRejection {
|
|
91
|
+
/** Null only for `UNKNOWN_CODE`, where no rule matched. */
|
|
92
|
+
discountId: string | null;
|
|
93
|
+
/** The code involved, when the rejection concerns one. */
|
|
94
|
+
code: string | null;
|
|
95
|
+
reason: DiscountRejectionReason;
|
|
96
|
+
/** Present on `MIN_SUBTOTAL_NOT_MET`: the threshold, in cents. */
|
|
97
|
+
minSubtotalCents?: number;
|
|
98
|
+
/** Present on `NOT_STACKABLE`: the discount that displaced this one. */
|
|
99
|
+
supersededByDiscountId?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A discount that actually removed money, frozen for the order snapshot. */
|
|
103
|
+
export interface AppliedDiscount {
|
|
104
|
+
discountId: string;
|
|
105
|
+
name: string;
|
|
106
|
+
code: string | null;
|
|
107
|
+
type: DiscountType;
|
|
108
|
+
scope: DiscountScope;
|
|
109
|
+
percentOffBp: number | null;
|
|
110
|
+
amountOffCents: number | null;
|
|
111
|
+
/** Cents removed from the order by THIS discount. Always > 0. */
|
|
112
|
+
amountCents: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Per-line outcome. `Σ discountCents` equals the order's `discountTotalCents`. */
|
|
116
|
+
export interface DiscountLineAdjustment {
|
|
117
|
+
lineId: string;
|
|
118
|
+
/** `unitPriceCents * quantity`, before any discount. */
|
|
119
|
+
lineGrossCents: number;
|
|
120
|
+
/** Cents removed from this line across every applied discount. */
|
|
121
|
+
discountCents: number;
|
|
122
|
+
/** `lineGrossCents - discountCents`. Never negative. */
|
|
123
|
+
lineNetCents: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface DiscountEvaluation {
|
|
127
|
+
/** Σ lineGrossCents. */
|
|
128
|
+
subtotalCents: number;
|
|
129
|
+
/**
|
|
130
|
+
* Σ applied[].amountCents, bounded by R9's payable floor:
|
|
131
|
+
* `0 <= discountTotalCents <= max(0, subtotalCents - MIN_PAYABLE_TOTAL_CENTS)`.
|
|
132
|
+
*/
|
|
133
|
+
discountTotalCents: number;
|
|
134
|
+
/**
|
|
135
|
+
* `subtotalCents - discountTotalCents`. Never negative, never above
|
|
136
|
+
* `subtotalCents`, and — whenever anything was discounted at all — never below
|
|
137
|
+
* `MIN_PAYABLE_TOTAL_CENTS`, because the provider cannot be asked to raise a
|
|
138
|
+
* R$ 0,00 charge. See the R9 block in `./evaluate.ts`: this deliberately
|
|
139
|
+
* departs from the FUT-235 spec, which called for landing on exactly zero.
|
|
140
|
+
*/
|
|
141
|
+
totalCents: number;
|
|
142
|
+
applied: readonly AppliedDiscount[];
|
|
143
|
+
/** One entry per input line, in input order. */
|
|
144
|
+
lines: readonly DiscountLineAdjustment[];
|
|
145
|
+
/** Only for discounts the buyer explicitly invoked or that lost a stack race. */
|
|
146
|
+
rejections: readonly DiscountRejection[];
|
|
147
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/discounts` — the discount/promotion domain, host-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* The root entry is the ENGINE: given a priced cart, a tenant's rules and an
|
|
5
|
+
* instant, decide which promotions apply, how many cents each removes, and
|
|
6
|
+
* how those cents land on the individual lines. It reaches no database, no
|
|
7
|
+
* clock, no framework and no locale — `now` is injected, the rules arrive
|
|
8
|
+
* pre-loaded, and every user-facing sentence is host config
|
|
9
|
+
* (`./rejection-copy`, with a pt-BR pack at `@12-apps/discounts/pt-BR`).
|
|
10
|
+
*
|
|
11
|
+
* The admin CRUD surface lives behind `@12-apps/discounts/server`, so a
|
|
12
|
+
* browser bundle that only prices a cart never resolves it. The wiring
|
|
13
|
+
* manifests are at `@12-apps/discounts/manifest` and `./manifest/server`.
|
|
14
|
+
*
|
|
15
|
+
* The DATABASE is deliberately NOT here, and the manifest declares no `db`
|
|
16
|
+
* capability. A discount's rows relate to a host's own catalog and orders —
|
|
17
|
+
* targets point at categories and items, redemptions at orders and buyers —
|
|
18
|
+
* so a shipped Prisma partial would only compile inside a host that already
|
|
19
|
+
* has those tables under those names. What is portable is the RULE, not the
|
|
20
|
+
* storage: the host owns the schema and hands this package rows through the
|
|
21
|
+
* `DiscountStore` port and the `DiscountRule` value object.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
DISCOUNT_REJECTION_REASONS,
|
|
26
|
+
DISCOUNT_SCOPES,
|
|
27
|
+
DISCOUNT_TRIGGERS,
|
|
28
|
+
DISCOUNT_TYPES,
|
|
29
|
+
MAX_PERCENT_OFF_BP,
|
|
30
|
+
MIN_PAYABLE_TOTAL_CENTS,
|
|
31
|
+
normalizeDiscountCode,
|
|
32
|
+
type DiscountRejectionReason,
|
|
33
|
+
type DiscountScope,
|
|
34
|
+
type DiscountTrigger,
|
|
35
|
+
type DiscountType,
|
|
36
|
+
} from "./engine/kinds";
|
|
37
|
+
|
|
38
|
+
export type {
|
|
39
|
+
AppliedDiscount,
|
|
40
|
+
DiscountCartLine,
|
|
41
|
+
DiscountEvaluation,
|
|
42
|
+
DiscountEvaluationInput,
|
|
43
|
+
DiscountLineAdjustment,
|
|
44
|
+
DiscountRejection,
|
|
45
|
+
DiscountRule,
|
|
46
|
+
} from "./engine/types";
|
|
47
|
+
|
|
48
|
+
export { evaluateDiscounts } from "./engine/evaluate";
|
|
49
|
+
|
|
50
|
+
export {
|
|
51
|
+
previewItemDiscount,
|
|
52
|
+
type ItemDiscountPreview,
|
|
53
|
+
type ItemDiscountPreviewInput,
|
|
54
|
+
} from "./engine/preview";
|
|
55
|
+
|
|
56
|
+
export {
|
|
57
|
+
discountRejectionMessage,
|
|
58
|
+
missingRejectionCopy,
|
|
59
|
+
MIN_SUBTOTAL_TOKEN,
|
|
60
|
+
type DiscountRejectionCopy,
|
|
61
|
+
} from "./engine/rejection-copy";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The allocation primitives, exported because the badge preview and any host
|
|
65
|
+
* that quotes a discounted price outside a cart must read the payable floor
|
|
66
|
+
* from the SAME place the evaluator caps against — a second implementation is
|
|
67
|
+
* how a card and a cart start quoting different numbers.
|
|
68
|
+
*/
|
|
69
|
+
export { maxDiscountableCents, rawAmountCents } from "./engine/allocate";
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/discounts/manifest` — the SHARED wiring manifest.
|
|
3
|
+
*
|
|
4
|
+
* Data every runtime can hold: identity, the permission contribution, the MCP
|
|
5
|
+
* tools, and the INVENTORY of the runtime manifest (`./manifest/server`). A
|
|
6
|
+
* host adopts this through `@12-apps/wiring/consumer`, and the inventory is
|
|
7
|
+
* what makes an unanswered capability a red `assemble()` instead of a silent
|
|
8
|
+
* gap.
|
|
9
|
+
*
|
|
10
|
+
* `@12-apps/wiring` is a TYPE-ONLY devDependency here, on purpose: the
|
|
11
|
+
* manifests are plain values `satisfies`-checked against the contract, and the
|
|
12
|
+
* producer factories' assertions run in this package's own test suite
|
|
13
|
+
* (`__tests__/manifest.test.ts`) — the same "fails in the package's own test
|
|
14
|
+
* run" guarantee with zero runtime dependencies added.
|
|
15
|
+
*
|
|
16
|
+
* ## Two capabilities this package deliberately does NOT declare
|
|
17
|
+
*
|
|
18
|
+
* **`db`.** A discount's rows relate to a host's own catalog and orders:
|
|
19
|
+
* targets point at its categories and items, redemptions at its orders and
|
|
20
|
+
* buyers. A `composed` partial may in principle relate to host models, but one
|
|
21
|
+
* shipped from here would only compile inside a host that already has those
|
|
22
|
+
* tables under those names — and `isolated` is ruled out by the same
|
|
23
|
+
* relations, which is exactly the qualification the wiring db contract states
|
|
24
|
+
* ("a package qualifies only when its models carry no relation into host
|
|
25
|
+
* tables"). What is portable is the RULE, not the storage: the host owns the
|
|
26
|
+
* schema and answers `DiscountStore`.
|
|
27
|
+
*
|
|
28
|
+
* **`web`.** The admin grid, the form and the target pickers stay host
|
|
29
|
+
* surfaces for now. They are three quarters product copy and host design
|
|
30
|
+
* system, and a surface declared before its copy is host config is the exact
|
|
31
|
+
* anti-pattern the copy-portability gate exists to refuse. The permission ids
|
|
32
|
+
* and the tool schemas are enough for a host to build them against.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { PackageManifest } from "@12-apps/wiring";
|
|
36
|
+
|
|
37
|
+
import { DISCOUNTS_PERMISSIONS } from "../server/contribution";
|
|
38
|
+
import { DISCOUNTS_MCP_TOOLS } from "../server/mcp";
|
|
39
|
+
|
|
40
|
+
export const discountsManifest = {
|
|
41
|
+
name: "@12-apps/discounts",
|
|
42
|
+
contract: 1,
|
|
43
|
+
permissions: DISCOUNTS_PERMISSIONS,
|
|
44
|
+
mcp: { endpoints: DISCOUNTS_MCP_TOOLS },
|
|
45
|
+
/**
|
|
46
|
+
* The server half logs through the binder's namespaced logger, so a
|
|
47
|
+
* discounts failure files under `discounts` rather than under the host app
|
|
48
|
+
* or nowhere.
|
|
49
|
+
*/
|
|
50
|
+
observability: { namespace: "discounts" },
|
|
51
|
+
server: ["http"],
|
|
52
|
+
} as const satisfies PackageManifest;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/discounts/manifest/server` — the server capabilities.
|
|
3
|
+
*
|
|
4
|
+
* One contribution: `http`, whose `create` IS {@link createApiDiscounts},
|
|
5
|
+
* unchanged. The descriptors it returns already satisfy the wiring contract's
|
|
6
|
+
* `WireRoute` — including the POLICY half, so a host's coverage gates can read
|
|
7
|
+
* each route's required permission off the assembled table instead of
|
|
8
|
+
* scanning route files for a guard literal.
|
|
9
|
+
*
|
|
10
|
+
* Behind its own subpath so a web bundle importing the shared manifest never
|
|
11
|
+
* resolves the server half. A plain `satisfies`-checked value — see `./index`
|
|
12
|
+
* for why the contract package stays a type-only devDependency; the inventory
|
|
13
|
+
* check against the shared manifest runs in the test suite.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { AnyServerManifest } from "@12-apps/wiring";
|
|
17
|
+
|
|
18
|
+
import { createApiDiscounts } from "../server/routes";
|
|
19
|
+
|
|
20
|
+
export const discountsServerManifest = {
|
|
21
|
+
name: "@12-apps/discounts",
|
|
22
|
+
http: { create: createApiDiscounts },
|
|
23
|
+
} as const satisfies AnyServerManifest;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { WirePermissionsContribution } from "@12-apps/wiring";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* THIS PACKAGE'S OWN PERMISSION CONTRIBUTION.
|
|
5
|
+
*
|
|
6
|
+
* The discounts surface decides two privileges, so it declares them rather
|
|
7
|
+
* than leaving each host to spell them: seeing which promotions are running,
|
|
8
|
+
* and changing what the store charges. They are SEPARATE on purpose (FUT-235)
|
|
9
|
+
* — "show me the promos" is a reasonable thing to grant a floor manager, and
|
|
10
|
+
* it is not the same act as repricing the menu — and neither is folded into a
|
|
11
|
+
* catalog permission, because a discount is not catalog content.
|
|
12
|
+
*
|
|
13
|
+
* There is deliberately no `discounts:approve`. A discount is a pricing rule
|
|
14
|
+
* carrying a LIVE redemption counter, not versioned content: restoring
|
|
15
|
+
* "version 3" of a coupon whose `usageCount` has since moved is meaningless,
|
|
16
|
+
* so there is no approval branch and therefore no author-cannot-approve pair
|
|
17
|
+
* to declare.
|
|
18
|
+
*
|
|
19
|
+
* The shape is `@12-apps/rbac`'s `PermissionContribution`, taken from the
|
|
20
|
+
* wiring contract's type-only twin rather than by depending on rbac: a host
|
|
21
|
+
* may run any RBAC, or none, and the contribution is plain data, so
|
|
22
|
+
* `composePermissions(DISCOUNTS_PERMISSIONS, …)` accepts it structurally.
|
|
23
|
+
*
|
|
24
|
+
* The LABELS are absent, and that is the same rule as the copy ports: a label
|
|
25
|
+
* is a word in a language, and the segment vocabularies a host composes are
|
|
26
|
+
* its own. A host that shows a permission picker supplies them there.
|
|
27
|
+
*/
|
|
28
|
+
export const DISCOUNTS_PERMISSIONS = {
|
|
29
|
+
source: "@12-apps/discounts",
|
|
30
|
+
ids: ["discounts:read", "discounts:write"],
|
|
31
|
+
permissions: {
|
|
32
|
+
/** See the promotions list and read one — RBAC alone decides. */
|
|
33
|
+
"discounts:read": { kind: "class" },
|
|
34
|
+
/** Create, re-state or end a promotion — the repricing privilege. */
|
|
35
|
+
"discounts:write": { kind: "class" },
|
|
36
|
+
},
|
|
37
|
+
} as const satisfies WirePermissionsContribution;
|
|
38
|
+
|
|
39
|
+
/** The read privilege's id, so a host maps a token rather than a string. */
|
|
40
|
+
export const DISCOUNTS_READ = "discounts:read";
|
|
41
|
+
/** The write privilege's id. */
|
|
42
|
+
export const DISCOUNTS_WRITE = "discounts:write";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every string the discounts API surface answers a HUMAN with — required host
|
|
3
|
+
* config, with NO defaults, deliberately (the payments extraction's doctrine,
|
|
4
|
+
* enforced repo-wide by the copy-portability gate): a default in the origin
|
|
5
|
+
* host's language reads as finished to the next host right up until a user
|
|
6
|
+
* sees it. The machine-readable halves of a failure — the status, and the
|
|
7
|
+
* FIELD the operator must fix — stay the package's own; only the sentence is
|
|
8
|
+
* the host's.
|
|
9
|
+
*
|
|
10
|
+
* A pt-BR host imports {@link PT_BR_DISCOUNTS_SERVER_COPY} from
|
|
11
|
+
* `@12-apps/discounts/server/pt-BR` and passes it by hand — one reviewable
|
|
12
|
+
* line, never a silence.
|
|
13
|
+
*
|
|
14
|
+
* The keys are named after the RULE, not the field, because several rules
|
|
15
|
+
* report against the same form input (`targets` covers both scopes) and one
|
|
16
|
+
* rule reports against two (`invalidDate` is raised for either bound).
|
|
17
|
+
*/
|
|
18
|
+
export interface DiscountsServerCopy {
|
|
19
|
+
/** 400 — the list query did not parse (unknown sort field, bad page). */
|
|
20
|
+
readonly invalidQuery: string;
|
|
21
|
+
/** 404 — the id names no live discount of this tenant. */
|
|
22
|
+
readonly notFound: string;
|
|
23
|
+
/** 422 — a PERCENTAGE discount without a rate in `1..MAX_PERCENT_OFF_BP`. */
|
|
24
|
+
readonly invalidPercent: string;
|
|
25
|
+
/** 422 — a FIXED_AMOUNT discount without a positive amount in cents. */
|
|
26
|
+
readonly invalidAmount: string;
|
|
27
|
+
/** 422 — a CODE-triggered discount with no coupon code to type. */
|
|
28
|
+
readonly codeRequired: string;
|
|
29
|
+
/** 422 — a CATEGORY-scoped discount targeting no category. */
|
|
30
|
+
readonly categoryTargetRequired: string;
|
|
31
|
+
/** 422 — an ITEM-scoped discount targeting no item. */
|
|
32
|
+
readonly itemTargetRequired: string;
|
|
33
|
+
/** 422 — a window bound that is not a calendar date. */
|
|
34
|
+
readonly invalidDate: string;
|
|
35
|
+
/** 422 — the window closes at or before it opens, so it is never open. */
|
|
36
|
+
readonly endsBeforeStarts: string;
|
|
37
|
+
/** 422 — a minimum-basket threshold of zero or less. */
|
|
38
|
+
readonly invalidMinSubtotal: string;
|
|
39
|
+
/** 422 — a global redemption cap of zero or less. */
|
|
40
|
+
readonly invalidUsageLimit: string;
|
|
41
|
+
/** 422 — a per-buyer redemption cap of zero or less. */
|
|
42
|
+
readonly invalidPerBuyerLimit: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const COPY_KEYS: readonly (keyof DiscountsServerCopy)[] = [
|
|
46
|
+
"invalidQuery",
|
|
47
|
+
"notFound",
|
|
48
|
+
"invalidPercent",
|
|
49
|
+
"invalidAmount",
|
|
50
|
+
"codeRequired",
|
|
51
|
+
"categoryTargetRequired",
|
|
52
|
+
"itemTargetRequired",
|
|
53
|
+
"invalidDate",
|
|
54
|
+
"endsBeforeStarts",
|
|
55
|
+
"invalidMinSubtotal",
|
|
56
|
+
"invalidUsageLimit",
|
|
57
|
+
"invalidPerBuyerLimit",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** Every key present and non-blank — checked at assembly, like the rest. */
|
|
61
|
+
export function missingServerCopy(copy: DiscountsServerCopy | undefined): string[] {
|
|
62
|
+
if (copy === undefined) return [...COPY_KEYS];
|
|
63
|
+
return COPY_KEYS.filter((key) => typeof copy[key] !== "string" || copy[key].trim() === "");
|
|
64
|
+
}
|