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