@12-apps/discounts 1.0.0 → 1.2.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 +135 -8
- package/package.json +5 -5
- package/src/engine/allocate.ts +20 -5
- package/src/engine/combo-match.ts +370 -0
- package/src/engine/combo-offer.ts +135 -0
- package/src/engine/eligibility.ts +18 -1
- package/src/engine/evaluate-passes.ts +165 -25
- package/src/engine/evaluate.ts +16 -3
- package/src/engine/kinds.ts +66 -6
- package/src/engine/preview.ts +10 -1
- package/src/engine/pt-BR.ts +8 -0
- package/src/engine/types.ts +74 -0
- package/src/index.ts +37 -0
- package/src/manifest/index.ts +7 -0
- package/src/server/copy.ts +24 -0
- package/src/server/index.ts +5 -0
- package/src/server/logging.ts +118 -0
- package/src/server/mcp.ts +40 -6
- package/src/server/pt-BR.ts +9 -0
- package/src/server/routes.ts +76 -28
- package/src/server/store.ts +16 -0
- package/src/server/validate-combo.ts +161 -0
- package/src/server/validate.ts +47 -5
package/src/engine/types.ts
CHANGED
|
@@ -10,6 +10,38 @@ import type {
|
|
|
10
10
|
* `./evaluate.ts` and whatever loads a host's rows agree on.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* One SLOT of a combo: what may fill it, and how many units it needs for ONE
|
|
15
|
+
* application of the combo.
|
|
16
|
+
*
|
|
17
|
+
* "1 large popcorn + 2 sodas" is two slots — `{ menuItemIds: ["popcorn-lg"],
|
|
18
|
+
* quantity: 1 }` and `{ categoryIds: ["sodas"], quantity: 2 }`. "3 burgers for
|
|
19
|
+
* the price of 2" is ONE slot of quantity 3, whose rule carries
|
|
20
|
+
* `type: "FREE_UNITS"`, `freeUnits: 1`.
|
|
21
|
+
*
|
|
22
|
+
* A slot is satisfied from either list — an item id OR a category — because a
|
|
23
|
+
* merchant thinks in both at once ("a soda" is a category, "the large popcorn"
|
|
24
|
+
* is an item). At least one of the two must be non-empty; a slot naming
|
|
25
|
+
* neither can never be filled, and `toDiscountScalars` refuses it rather than
|
|
26
|
+
* storing a combo that silently never fires.
|
|
27
|
+
*/
|
|
28
|
+
export interface ComboRequirement {
|
|
29
|
+
/**
|
|
30
|
+
* Menu-item ids that fill this slot, matched against a line's BASE item OR
|
|
31
|
+
* its chosen variation — the same reach an `ITEM`-scoped discount has, so
|
|
32
|
+
* "a Coke" still fills the slot when the buyer picked "Coke zero can".
|
|
33
|
+
*/
|
|
34
|
+
menuItemIds: readonly string[];
|
|
35
|
+
/**
|
|
36
|
+
* Category ids that fill this slot, matched against a line's category PATH,
|
|
37
|
+
* so a slot naming a top-level category is filled by its subcategories' items
|
|
38
|
+
* — the same reach a `CATEGORY`-scoped discount has.
|
|
39
|
+
*/
|
|
40
|
+
categoryIds: readonly string[];
|
|
41
|
+
/** Units this slot needs per application. An integer >= 1. */
|
|
42
|
+
quantity: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
13
45
|
/**
|
|
14
46
|
* One discount as the evaluator sees it: a plain value object with every
|
|
15
47
|
* stored and every counted fact already resolved. The evaluator never touches
|
|
@@ -24,11 +56,42 @@ export interface DiscountRule {
|
|
|
24
56
|
percentOffBp: number | null;
|
|
25
57
|
/** Cents off, > 0. Non-null iff `type === "FIXED_AMOUNT"`. */
|
|
26
58
|
amountOffCents: number | null;
|
|
59
|
+
/**
|
|
60
|
+
* What the matched combo group costs, in cents. Non-null iff
|
|
61
|
+
* `type === "BUNDLE_PRICE"`. The discount is the group's gross value minus
|
|
62
|
+
* this, per application.
|
|
63
|
+
*/
|
|
64
|
+
bundlePriceCents?: number | null;
|
|
65
|
+
/**
|
|
66
|
+
* How many units of the matched group are free. Non-null iff
|
|
67
|
+
* `type === "FREE_UNITS"`. The CHEAPEST units of each application are the
|
|
68
|
+
* free ones, which is the buyer-favourable reading of "3 for the price of 2"
|
|
69
|
+
* and the one every merchant means by it.
|
|
70
|
+
*/
|
|
71
|
+
freeUnits?: number | null;
|
|
27
72
|
scope: DiscountScope;
|
|
28
73
|
/** Target category ids. Non-empty iff `scope === "CATEGORY"`. */
|
|
29
74
|
targetCategoryIds: readonly string[];
|
|
30
75
|
/** Target menu-item ids. Non-empty iff `scope === "ITEM"`. */
|
|
31
76
|
targetMenuItemIds: readonly string[];
|
|
77
|
+
/**
|
|
78
|
+
* The combo's slots, in the merchant's own declaration order. Non-empty iff
|
|
79
|
+
* `scope === "COMBO"`; ignored at every other scope.
|
|
80
|
+
*
|
|
81
|
+
* OPTIONAL, unlike the two target arrays above, and that asymmetry is the
|
|
82
|
+
* point: every discount has a scope and a type, so those fields are facts
|
|
83
|
+
* about all of them, while a combo is a CAPABILITY a host opts into. A host
|
|
84
|
+
* that does not sell combos never stores a `COMBO`-scoped rule, so it never
|
|
85
|
+
* has a value to put here, and requiring one would have made adding combos a
|
|
86
|
+
* breaking change for every adopter that does not want them.
|
|
87
|
+
*/
|
|
88
|
+
comboRequirements?: readonly ComboRequirement[];
|
|
89
|
+
/**
|
|
90
|
+
* How many times this combo may apply to ONE cart; null or omitted means as
|
|
91
|
+
* often as the cart can fill its slots. A cart with six burgers takes "3 for
|
|
92
|
+
* the price of 2" twice unless this says otherwise.
|
|
93
|
+
*/
|
|
94
|
+
maxComboApplications?: number | null;
|
|
32
95
|
trigger: DiscountTrigger;
|
|
33
96
|
/** Normalized (trimmed, upper-cased) code. Non-null iff `trigger === "CODE"`. */
|
|
34
97
|
code: string | null;
|
|
@@ -110,6 +173,17 @@ export interface AppliedDiscount {
|
|
|
110
173
|
amountOffCents: number | null;
|
|
111
174
|
/** Cents removed from the order by THIS discount. Always > 0. */
|
|
112
175
|
amountCents: number;
|
|
176
|
+
/**
|
|
177
|
+
* How many times a `COMBO`-scoped discount applied to this cart; absent for
|
|
178
|
+
* every other scope.
|
|
179
|
+
*
|
|
180
|
+
* It is on the SNAPSHOT rather than merely in the evaluation because it is
|
|
181
|
+
* the one combo fact an order cannot re-derive later: the rule's slots may
|
|
182
|
+
* have been re-stated, and the line quantities the combo consumed are not
|
|
183
|
+
* recoverable from the per-line cents alone. A receipt saying "Combo leve 3
|
|
184
|
+
* pague 2 (2x)" needs this number.
|
|
185
|
+
*/
|
|
186
|
+
comboApplications?: number;
|
|
113
187
|
}
|
|
114
188
|
|
|
115
189
|
/** Per-line outcome. `Σ discountCents` equals the order's `discountTotalCents`. */
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
* browser bundle that only prices a cart never resolves it. The wiring
|
|
13
13
|
* manifests are at `@12-apps/discounts/manifest` and `./manifest/server`.
|
|
14
14
|
*
|
|
15
|
+
* COMBOS (FUT-268) live here too, as a SCOPE rather than as a catalog entity:
|
|
16
|
+
* a `COMBO`-scoped rule matches quantified slots against the cart's units and
|
|
17
|
+
* prices the group ("1 large popcorn + 2 sodas for R$ 25", "3 burgers for the
|
|
18
|
+
* price of 2"). What is NOT here — and cannot be, for the same reason the
|
|
19
|
+
* database is not — is a combo as a SELLABLE product: its own menu card, its
|
|
20
|
+
* own cart line, its own order snapshot. That is a host catalog entity with
|
|
21
|
+
* foreign keys into host tables. The pricing RULE travels; the product does not.
|
|
22
|
+
*
|
|
15
23
|
* The DATABASE is deliberately NOT here, and the manifest declares no `db`
|
|
16
24
|
* capability. A discount's rows relate to a host's own catalog and orders —
|
|
17
25
|
* targets point at categories and items, redemptions at orders and buyers —
|
|
@@ -22,10 +30,13 @@
|
|
|
22
30
|
*/
|
|
23
31
|
|
|
24
32
|
export {
|
|
33
|
+
COMBO_ONLY_DISCOUNT_TYPES,
|
|
25
34
|
DISCOUNT_REJECTION_REASONS,
|
|
26
35
|
DISCOUNT_SCOPES,
|
|
27
36
|
DISCOUNT_TRIGGERS,
|
|
28
37
|
DISCOUNT_TYPES,
|
|
38
|
+
MAX_COMBO_SLOTS,
|
|
39
|
+
MAX_COMBO_SLOT_QUANTITY,
|
|
29
40
|
MAX_PERCENT_OFF_BP,
|
|
30
41
|
MIN_PAYABLE_TOTAL_CENTS,
|
|
31
42
|
normalizeDiscountCode,
|
|
@@ -37,6 +48,7 @@ export {
|
|
|
37
48
|
|
|
38
49
|
export type {
|
|
39
50
|
AppliedDiscount,
|
|
51
|
+
ComboRequirement,
|
|
40
52
|
DiscountCartLine,
|
|
41
53
|
DiscountEvaluation,
|
|
42
54
|
DiscountEvaluationInput,
|
|
@@ -53,6 +65,31 @@ export {
|
|
|
53
65
|
type ItemDiscountPreviewInput,
|
|
54
66
|
} from "./engine/preview";
|
|
55
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The COMBO half of the catalog surface (FUT-268). A combo has no single-item
|
|
70
|
+
* price to badge, so a card advertises PARTICIPATION instead — see
|
|
71
|
+
* `./engine/combo-offer.ts` for why that is the only honest thing a card can
|
|
72
|
+
* say about one.
|
|
73
|
+
*/
|
|
74
|
+
export {
|
|
75
|
+
comboOffersForItem,
|
|
76
|
+
type ComboOffer,
|
|
77
|
+
type ComboOffersInput,
|
|
78
|
+
} from "./engine/combo-offer";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The combo matcher, exported because a host that wants to EXPLAIN a combo —
|
|
82
|
+
* "aplicado 2x", "faltam 2 refrigerantes" — needs the same match the evaluator
|
|
83
|
+
* priced, and re-deriving it host-side is how a receipt and a cart start
|
|
84
|
+
* disagreeing about what was in the bundle.
|
|
85
|
+
*/
|
|
86
|
+
export {
|
|
87
|
+
freshComboPool,
|
|
88
|
+
matchCombo,
|
|
89
|
+
type ComboMatch,
|
|
90
|
+
type ComboPool,
|
|
91
|
+
} from "./engine/combo-match";
|
|
92
|
+
|
|
56
93
|
export {
|
|
57
94
|
discountRejectionMessage,
|
|
58
95
|
missingRejectionCopy,
|
package/src/manifest/index.ts
CHANGED
|
@@ -46,6 +46,13 @@ export const discountsManifest = {
|
|
|
46
46
|
* The server half logs through the binder's namespaced logger, so a
|
|
47
47
|
* discounts failure files under `discounts` rather than under the host app
|
|
48
48
|
* or nowhere.
|
|
49
|
+
*
|
|
50
|
+
* `createApiDiscounts` REFUSES to build without one, which is what makes
|
|
51
|
+
* that sentence a fact rather than an aspiration: for the whole of 1.0.x it
|
|
52
|
+
* was neither — the binder built the logger, hung it on
|
|
53
|
+
* `assembled.loggers["@12-apps/discounts"]`, and the package took nothing
|
|
54
|
+
* and logged nothing. A declared capability nobody consumes is worse than an
|
|
55
|
+
* undeclared one, because it reads as finished.
|
|
49
56
|
*/
|
|
50
57
|
observability: { namespace: "discounts" },
|
|
51
58
|
server: ["http"],
|
package/src/server/copy.ts
CHANGED
|
@@ -40,6 +40,22 @@ export interface DiscountsServerCopy {
|
|
|
40
40
|
readonly invalidUsageLimit: string;
|
|
41
41
|
/** 422 — a per-buyer redemption cap of zero or less. */
|
|
42
42
|
readonly invalidPerBuyerLimit: string;
|
|
43
|
+
/** 422 — a BUNDLE_PRICE or FREE_UNITS reward on a discount that is not a combo. */
|
|
44
|
+
readonly comboScopeRequired: string;
|
|
45
|
+
/** 422 — a combo with no slots at all, or more than `MAX_COMBO_SLOTS`. */
|
|
46
|
+
readonly invalidComboSlots: string;
|
|
47
|
+
/** 422 — a combo slot naming neither a product nor a category. */
|
|
48
|
+
readonly comboTargetRequired: string;
|
|
49
|
+
/** 422 — a slot asking for zero units, or more than `MAX_COMBO_SLOT_QUANTITY`. */
|
|
50
|
+
readonly invalidComboQuantity: string;
|
|
51
|
+
/** 422 — a BUNDLE_PRICE combo without a positive price in cents. */
|
|
52
|
+
readonly invalidBundlePrice: string;
|
|
53
|
+
/** 422 — a FREE_UNITS combo without a positive count of free units. */
|
|
54
|
+
readonly invalidFreeUnits: string;
|
|
55
|
+
/** 422 — a combo giving away every unit it asks for, which sells nothing. */
|
|
56
|
+
readonly freeUnitsExceedCombo: string;
|
|
57
|
+
/** 422 — a per-cart combo cap of zero or less. */
|
|
58
|
+
readonly invalidMaxComboApplications: string;
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
const COPY_KEYS: readonly (keyof DiscountsServerCopy)[] = [
|
|
@@ -55,6 +71,14 @@ const COPY_KEYS: readonly (keyof DiscountsServerCopy)[] = [
|
|
|
55
71
|
"invalidMinSubtotal",
|
|
56
72
|
"invalidUsageLimit",
|
|
57
73
|
"invalidPerBuyerLimit",
|
|
74
|
+
"comboScopeRequired",
|
|
75
|
+
"invalidComboSlots",
|
|
76
|
+
"comboTargetRequired",
|
|
77
|
+
"invalidComboQuantity",
|
|
78
|
+
"invalidBundlePrice",
|
|
79
|
+
"invalidFreeUnits",
|
|
80
|
+
"freeUnitsExceedCombo",
|
|
81
|
+
"invalidMaxComboApplications",
|
|
58
82
|
];
|
|
59
83
|
|
|
60
84
|
/** Every key present and non-blank — checked at assembly, like the rest. */
|
package/src/server/index.ts
CHANGED
|
@@ -15,6 +15,8 @@ export {
|
|
|
15
15
|
|
|
16
16
|
export { missingServerCopy, type DiscountsServerCopy } from "./copy";
|
|
17
17
|
|
|
18
|
+
export { errorText, type DiscountsLogger } from "./logging";
|
|
19
|
+
|
|
18
20
|
export {
|
|
19
21
|
DISCOUNTS_PERMISSIONS,
|
|
20
22
|
DISCOUNTS_READ,
|
|
@@ -40,9 +42,12 @@ export {
|
|
|
40
42
|
type DiscountWriteInput,
|
|
41
43
|
} from "./validate";
|
|
42
44
|
|
|
45
|
+
export { comboRequirementsForScope, toComboColumns, type ComboColumns } from "./validate-combo";
|
|
46
|
+
|
|
43
47
|
export { discountSearchConfig } from "./search";
|
|
44
48
|
|
|
45
49
|
export {
|
|
50
|
+
comboRequirementSchema,
|
|
46
51
|
createDiscountBody,
|
|
47
52
|
createDiscountListQuery,
|
|
48
53
|
discountCollectionParams,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { WireRequest, WireResponse } from "@12-apps/wiring";
|
|
2
|
+
|
|
3
|
+
import type { DiscountsActor } from "./routes";
|
|
4
|
+
import { DiscountValidationError } from "./validate";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What this surface says out loud, and to whom (FUT-244).
|
|
8
|
+
*
|
|
9
|
+
* The manifest declares `observability: { namespace: "discounts" }`, which is a
|
|
10
|
+
* PROMISE: a discounts failure files under `discounts` rather than under the
|
|
11
|
+
* host app or nowhere. Declaring it bought nothing on its own — the binder
|
|
12
|
+
* built the namespaced logger, hung it on `assembled.loggers`, and the package
|
|
13
|
+
* never took it, so for the whole of 1.0.x a refused write and a store that
|
|
14
|
+
* threw were equally silent. This module is the other half.
|
|
15
|
+
*
|
|
16
|
+
* ## Why every line is a STRING, and never an object
|
|
17
|
+
*
|
|
18
|
+
* A host's `LoggerPort` is a Winston child in practice, whose formatter runs
|
|
19
|
+
* `util.inspect(…, { depth: 5 })` over any extra argument. That is not a
|
|
20
|
+
* theoretical leak in this org: a provider error retains the provider's parsed
|
|
21
|
+
* response body, buyer name, e-mail and CPF included, so passing the cause as a
|
|
22
|
+
* second argument is precisely how a buyer's details reach a third party.
|
|
23
|
+
* {@link errorText} folds the cause into the sentence instead, which is the
|
|
24
|
+
* same call the origin host's own `errorText` makes and for the same reason.
|
|
25
|
+
*
|
|
26
|
+
* ## Why the sentences here are NOT copy config
|
|
27
|
+
*
|
|
28
|
+
* Everything below is written for a DEVELOPER reading a log, so it is English
|
|
29
|
+
* and it is the package's own — the copy-portability rule governs what a USER
|
|
30
|
+
* reads. Nothing logged here is shown to anyone; the operator's sentence comes
|
|
31
|
+
* from {@link DiscountsServerCopy} and travels in the response body.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The three methods a host's logger must offer — the structural twin of
|
|
36
|
+
* `@12-apps/wiring`'s `LoggerPort` and of `createFeatureLogger`'s return.
|
|
37
|
+
*
|
|
38
|
+
* Declared here rather than imported so that the wiring contract stays a
|
|
39
|
+
* type-only devDependency: a host on no wiring at all still satisfies this by
|
|
40
|
+
* passing any Winston/pino child, and a host on wiring passes
|
|
41
|
+
* `assembled.loggers["@12-apps/discounts"]` unchanged.
|
|
42
|
+
*/
|
|
43
|
+
export interface DiscountsLogger {
|
|
44
|
+
info(message: string): void;
|
|
45
|
+
warn(message: string): void;
|
|
46
|
+
error(message: string): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The cause as a log line — never the object. See the module note. */
|
|
50
|
+
export function errorText(error: unknown): string {
|
|
51
|
+
return error instanceof Error ? error.message : String(error);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What a line may name: the tenant, the route, the discount, the field, the
|
|
56
|
+
* status. Never a body, never a coupon code, never a name an operator typed —
|
|
57
|
+
* a log line is read by whoever can read the log, and none of those are needed
|
|
58
|
+
* to find the failure.
|
|
59
|
+
*/
|
|
60
|
+
function subject(route: string, request: WireRequest<DiscountsActor>): string {
|
|
61
|
+
const id = request.params.id;
|
|
62
|
+
return `${route}${id === undefined ? "" : ` [${id}]`} for tenant ${request.actor.clientId}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Wrap one descriptor's handler so that nothing it does is silent.
|
|
67
|
+
*
|
|
68
|
+
* Three outcomes, three levels, and the split is about who has to act:
|
|
69
|
+
*
|
|
70
|
+
* - a REFUSAL the package itself decided (a 4xx it returned, or the 422 a
|
|
71
|
+
* {@link DiscountValidationError} folds into) is `warn` — the operator
|
|
72
|
+
* typed something the rules do not allow, which is worth seeing when a
|
|
73
|
+
* store reports "it will not let me save" and worth nobody's pager;
|
|
74
|
+
* - anything else THROWN is `error` and is re-thrown unchanged: the store's
|
|
75
|
+
* own failures (a unique clash, a dead connection, a foreign target) belong
|
|
76
|
+
* to the host's error mapping, and swallowing one here to make a tidy
|
|
77
|
+
* response is how a broken database becomes a 404 nobody investigates;
|
|
78
|
+
* - a write that SUCCEEDED is `info`, because a discount is a direct lever on
|
|
79
|
+
* what a buyer is charged and "who repriced the menu at 03:12" is a
|
|
80
|
+
* question that gets asked.
|
|
81
|
+
*
|
|
82
|
+
* The catch is what makes the promise total. Before this, only the two write
|
|
83
|
+
* routes had any catch at all ({@link DiscountValidationError} → 422) and the
|
|
84
|
+
* three read routes had none, so a store that threw inside `list` produced a
|
|
85
|
+
* stack in the host's catch-all with nothing naming discounts.
|
|
86
|
+
*/
|
|
87
|
+
export function observed(
|
|
88
|
+
logger: DiscountsLogger,
|
|
89
|
+
route: string,
|
|
90
|
+
handle: (request: WireRequest<DiscountsActor>) => Promise<WireResponse>,
|
|
91
|
+
): (request: WireRequest<DiscountsActor>) => Promise<WireResponse> {
|
|
92
|
+
return async (request) => {
|
|
93
|
+
try {
|
|
94
|
+
const response = await handle(request);
|
|
95
|
+
if (response.status >= 400) {
|
|
96
|
+
logger.warn(`${subject(route, request)} refused with ${response.status}`);
|
|
97
|
+
}
|
|
98
|
+
return response;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error instanceof DiscountValidationError) {
|
|
101
|
+
logger.warn(`${subject(route, request)} refused with 422 on field "${error.field}"`);
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
logger.error(`${subject(route, request)} failed: ${errorText(error)}`);
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The one line a successful write leaves — see {@link observed}'s third case. */
|
|
111
|
+
export function logWrite(
|
|
112
|
+
logger: DiscountsLogger,
|
|
113
|
+
verb: string,
|
|
114
|
+
request: WireRequest<DiscountsActor>,
|
|
115
|
+
id: string,
|
|
116
|
+
): void {
|
|
117
|
+
logger.info(`${verb} discount ${id} for tenant ${request.actor.clientId}`);
|
|
118
|
+
}
|
package/src/server/mcp.ts
CHANGED
|
@@ -8,7 +8,14 @@ import {
|
|
|
8
8
|
} from "@12-apps/shared-helpers/search";
|
|
9
9
|
import type { WireMcpTool } from "@12-apps/wiring";
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
DISCOUNT_SCOPES,
|
|
13
|
+
DISCOUNT_TRIGGERS,
|
|
14
|
+
DISCOUNT_TYPES,
|
|
15
|
+
MAX_COMBO_SLOTS,
|
|
16
|
+
MAX_COMBO_SLOT_QUANTITY,
|
|
17
|
+
MAX_PERCENT_OFF_BP,
|
|
18
|
+
} from "../engine/kinds";
|
|
12
19
|
import { discountSearchConfig } from "./search";
|
|
13
20
|
|
|
14
21
|
/**
|
|
@@ -46,6 +53,18 @@ export const discountItemParams = z.object({
|
|
|
46
53
|
*/
|
|
47
54
|
const calendarDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
|
48
55
|
|
|
56
|
+
/**
|
|
57
|
+
* One combo slot: what fills it, and how many units of it one application
|
|
58
|
+
* needs. Both target lists default to empty and at least one must be
|
|
59
|
+
* non-empty — a rule `./validate-combo.ts` enforces with the host's own
|
|
60
|
+
* sentence, because a Zod refinement here could only answer in a Zod path.
|
|
61
|
+
*/
|
|
62
|
+
export const comboRequirementSchema = z.object({
|
|
63
|
+
menuItemIds: z.array(z.string().min(1)).default([]),
|
|
64
|
+
categoryIds: z.array(z.string().min(1)).default([]),
|
|
65
|
+
quantity: z.number().int().min(1).max(MAX_COMBO_SLOT_QUANTITY),
|
|
66
|
+
});
|
|
67
|
+
|
|
49
68
|
/**
|
|
50
69
|
* A discount as the API returns it (JSON-serialized: dates become ISO
|
|
51
70
|
* strings). The two join tables are flattened into id arrays.
|
|
@@ -58,6 +77,16 @@ export const discountSchema = z.object({
|
|
|
58
77
|
percentOffBp: z.number().int().nullable(),
|
|
59
78
|
/** Cents off. Non-null iff `type = FIXED_AMOUNT`. */
|
|
60
79
|
amountOffCents: z.number().int().nullable(),
|
|
80
|
+
/**
|
|
81
|
+
* The COMBO half. OPTIONAL in the RESPONSE, unlike in the body, because the
|
|
82
|
+
* record is assembled by a host's store: an adopter that has not added the
|
|
83
|
+
* columns still answers a valid discount, and one that has says so. See
|
|
84
|
+
* `DiscountRecord` in `./store`.
|
|
85
|
+
*/
|
|
86
|
+
bundlePriceCents: z.number().int().nullable().optional(),
|
|
87
|
+
freeUnits: z.number().int().nullable().optional(),
|
|
88
|
+
maxComboApplications: z.number().int().nullable().optional(),
|
|
89
|
+
comboRequirements: z.array(comboRequirementSchema).optional(),
|
|
61
90
|
scope: z.enum(DISCOUNT_SCOPES),
|
|
62
91
|
trigger: z.enum(DISCOUNT_TRIGGERS),
|
|
63
92
|
code: z.string().nullable(),
|
|
@@ -101,15 +130,20 @@ export const listDiscountsQuery = createDiscountListQuery(searchConfigSchema.par
|
|
|
101
130
|
* The write body, shared by create and update: a discount is always saved
|
|
102
131
|
* WHOLE (the form re-states every field), so there is no partial-patch shape
|
|
103
132
|
* to diverge from. The cross-field rules — a rate iff PERCENTAGE, a code iff
|
|
104
|
-
* CODE, a target iff CATEGORY/ITEM,
|
|
105
|
-
*
|
|
106
|
-
*
|
|
133
|
+
* CODE, a target iff CATEGORY/ITEM, a slot list iff COMBO, a combo reward ONLY
|
|
134
|
+
* at COMBO scope, `endsAt` strictly after `startsAt` — are enforced in
|
|
135
|
+
* `./validate` and `./validate-combo`, where they produce a sentence naming the
|
|
136
|
+
* field instead of a Zod path.
|
|
107
137
|
*/
|
|
108
138
|
const discountWriteShape = {
|
|
109
139
|
name: z.string().min(2).max(120),
|
|
110
140
|
type: z.enum(DISCOUNT_TYPES),
|
|
111
141
|
percentOffBp: z.number().int().min(1).max(MAX_PERCENT_OFF_BP).nullable().optional(),
|
|
112
142
|
amountOffCents: z.number().int().positive().nullable().optional(),
|
|
143
|
+
bundlePriceCents: z.number().int().positive().nullable().optional(),
|
|
144
|
+
freeUnits: z.number().int().positive().nullable().optional(),
|
|
145
|
+
comboRequirements: z.array(comboRequirementSchema).max(MAX_COMBO_SLOTS).optional(),
|
|
146
|
+
maxComboApplications: z.number().int().positive().nullable().optional(),
|
|
113
147
|
scope: z.enum(DISCOUNT_SCOPES),
|
|
114
148
|
trigger: z.enum(DISCOUNT_TRIGGERS),
|
|
115
149
|
code: z.string().min(1).max(64).nullable().optional(),
|
|
@@ -145,7 +179,7 @@ export const DISCOUNTS_MCP_TOOLS: readonly WireMcpTool<z.ZodType>[] = [
|
|
|
145
179
|
method: "GET",
|
|
146
180
|
path: "/discounts",
|
|
147
181
|
summary:
|
|
148
|
-
"List a store's discounts and promotions (admin). Backend filter (`q` over name and coupon code, `type_in` PERCENTAGE|FIXED_AMOUNT, `scope_in` ORDER|CATEGORY|ITEM, `trigger_in` AUTOMATIC|CODE, `active`), `sort` (field:asc|desc), and pagination (`page`/`pageSize`). Returns the page plus `pagination` (total, pageCount, hasNextPage).",
|
|
182
|
+
"List a store's discounts and promotions (admin). Backend filter (`q` over name and coupon code, `type_in` PERCENTAGE|FIXED_AMOUNT|BUNDLE_PRICE|FREE_UNITS, `scope_in` ORDER|CATEGORY|ITEM|COMBO, `trigger_in` AUTOMATIC|CODE, `active`), `sort` (field:asc|desc), and pagination (`page`/`pageSize`). Returns the page plus `pagination` (total, pageCount, hasNextPage).",
|
|
149
183
|
tags: ["discounts"],
|
|
150
184
|
params: discountCollectionParams,
|
|
151
185
|
query: listDiscountsQuery,
|
|
@@ -167,7 +201,7 @@ export const DISCOUNTS_MCP_TOOLS: readonly WireMcpTool<z.ZodType>[] = [
|
|
|
167
201
|
method: "POST",
|
|
168
202
|
path: "/discounts",
|
|
169
203
|
summary:
|
|
170
|
-
"Create a discount for a store (admin). A percentage is basis points (1..10000); a fixed amount is integer cents. A CODE-triggered discount needs a coupon code, unique within the store; a CATEGORY/ITEM-scoped one needs at least one target.",
|
|
204
|
+
"Create a discount for a store (admin). A percentage is basis points (1..10000); a fixed amount is integer cents. A CODE-triggered discount needs a coupon code, unique within the store; a CATEGORY/ITEM-scoped one needs at least one target. A COMBO-scoped one is a bundle: `comboRequirements` lists its slots (products and/or categories, with a per-slot `quantity`), and the reward is either `BUNDLE_PRICE` (what the whole matched group costs, in cents) or `FREE_UNITS` (how many of its cheapest units are free — \"3 for the price of 2\" is one slot of quantity 3 with `freeUnits` 1), or an ordinary percentage/fixed amount off the group.",
|
|
171
205
|
tags: ["discounts"],
|
|
172
206
|
params: discountCollectionParams,
|
|
173
207
|
body: createDiscountBody,
|
package/src/server/pt-BR.ts
CHANGED
|
@@ -23,4 +23,13 @@ export const PT_BR_DISCOUNTS_SERVER_COPY: DiscountsServerCopy = {
|
|
|
23
23
|
invalidMinSubtotal: "O pedido mínimo deve ser maior que zero (ou deixe em branco).",
|
|
24
24
|
invalidUsageLimit: "O limite de usos deve ser maior que zero (ou deixe em branco).",
|
|
25
25
|
invalidPerBuyerLimit: "O limite por cliente deve ser maior que zero (ou deixe em branco).",
|
|
26
|
+
comboScopeRequired: "Preço de combo e itens grátis só valem para descontos do tipo combo.",
|
|
27
|
+
invalidComboSlots: "Um combo precisa de pelo menos um grupo de itens.",
|
|
28
|
+
comboTargetRequired: "Selecione ao menos um produto ou categoria para cada grupo do combo.",
|
|
29
|
+
invalidComboQuantity: "Informe uma quantidade maior que zero para cada grupo do combo.",
|
|
30
|
+
invalidBundlePrice: "Informe o preço do combo, maior que zero.",
|
|
31
|
+
invalidFreeUnits: "Informe quantos itens saem de graça, maior que zero.",
|
|
32
|
+
freeUnitsExceedCombo: "O combo precisa cobrar por pelo menos um item.",
|
|
33
|
+
invalidMaxComboApplications:
|
|
34
|
+
"O limite de combos por carrinho deve ser maior que zero (ou deixe em branco).",
|
|
26
35
|
};
|
package/src/server/routes.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { z } from "zod";
|
|
|
3
3
|
import type { WireRequest, WireResponse, WireRoute } from "@12-apps/wiring";
|
|
4
4
|
|
|
5
5
|
import { missingServerCopy, type DiscountsServerCopy } from "./copy";
|
|
6
|
+
import { logWrite, observed, type DiscountsLogger } from "./logging";
|
|
6
7
|
import { listDiscountsQuery } from "./mcp";
|
|
7
8
|
import { DISCOUNTS_READ, DISCOUNTS_WRITE } from "./contribution";
|
|
8
9
|
import type { DiscountListInput, DiscountRecord, DiscountStore } from "./store";
|
|
@@ -45,6 +46,16 @@ export interface DiscountsApiConfig {
|
|
|
45
46
|
store: DiscountStore;
|
|
46
47
|
/** Every sentence this surface answers a human with. Required, no defaults. */
|
|
47
48
|
copy: DiscountsServerCopy;
|
|
49
|
+
/**
|
|
50
|
+
* Where this surface says what it did. Required, and for the same reason
|
|
51
|
+
* `copy` is: a default would be a no-op, and a no-op default is exactly the
|
|
52
|
+
* silence this config exists to end — the manifest declares an
|
|
53
|
+
* `observability` namespace, so a host adopting through
|
|
54
|
+
* `@12-apps/wiring/consumer` already HAS the logger and passes
|
|
55
|
+
* `assembled.loggers["@12-apps/discounts"]`; a host on no wiring passes any
|
|
56
|
+
* `createFeatureLogger`-shaped child. See `./logging`.
|
|
57
|
+
*/
|
|
58
|
+
logger: DiscountsLogger;
|
|
48
59
|
/**
|
|
49
60
|
* The schema the list query is validated against. Optional ONLY because a
|
|
50
61
|
* mechanical default is portable: the package's own, built from the search
|
|
@@ -85,18 +96,33 @@ function writeFrom(body: unknown, copy: DiscountsServerCopy) {
|
|
|
85
96
|
}
|
|
86
97
|
|
|
87
98
|
/**
|
|
88
|
-
*
|
|
99
|
+
* One descriptor's handler, observed and then folded.
|
|
100
|
+
*
|
|
101
|
+
* `observed` logs every outcome and changes none of them (see `./logging`);
|
|
102
|
+
* this fold turns THIS package's own validation failure into the 422 the
|
|
89
103
|
* operator can act on. Everything else — a uniqueness clash, a foreign target,
|
|
90
104
|
* a stale id — is the STORE's to raise, in the host's own error vocabulary,
|
|
91
105
|
* and travels untouched to the host's error mapping.
|
|
106
|
+
*
|
|
107
|
+
* Every route goes through it, reads included. A read cannot raise a
|
|
108
|
+
* `DiscountValidationError`, but it can absolutely throw — and before this the
|
|
109
|
+
* three read routes had no catch at all, so a store that failed inside `list`
|
|
110
|
+
* reached the host's catch-all with nothing in the log naming discounts.
|
|
92
111
|
*/
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
112
|
+
function endpoint(
|
|
113
|
+
config: DiscountsApiConfig,
|
|
114
|
+
route: string,
|
|
115
|
+
handle: (request: WireRequest<DiscountsActor>) => Promise<WireResponse>,
|
|
116
|
+
): (request: WireRequest<DiscountsActor>) => Promise<WireResponse> {
|
|
117
|
+
const observedHandle = observed(config.logger, route, handle);
|
|
118
|
+
return async (request) => {
|
|
119
|
+
try {
|
|
120
|
+
return await observedHandle(request);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error instanceof DiscountValidationError) return fieldError(error);
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
100
126
|
}
|
|
101
127
|
|
|
102
128
|
function listRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
@@ -105,7 +131,7 @@ function listRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
|
105
131
|
method: "GET",
|
|
106
132
|
path: "/discounts",
|
|
107
133
|
permission: DISCOUNTS_READ,
|
|
108
|
-
handle: async ({ actor, query }
|
|
134
|
+
handle: endpoint(config, "GET /discounts", async ({ actor, query }) => {
|
|
109
135
|
const parsed = schema.safeParse(query);
|
|
110
136
|
if (!parsed.success) {
|
|
111
137
|
return { status: 400, body: { error: config.copy.invalidQuery } };
|
|
@@ -114,7 +140,7 @@ function listRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
|
114
140
|
// pagination }` envelope the advertised response describes, and wrapping
|
|
115
141
|
// it again would nest it under a second `data`.
|
|
116
142
|
return ok(await config.store.list(actor.clientId, parsed.data));
|
|
117
|
-
},
|
|
143
|
+
}),
|
|
118
144
|
};
|
|
119
145
|
}
|
|
120
146
|
|
|
@@ -123,11 +149,11 @@ function readRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
|
123
149
|
method: "GET",
|
|
124
150
|
path: "/discounts/:id",
|
|
125
151
|
permission: DISCOUNTS_READ,
|
|
126
|
-
handle: async ({ actor, params }
|
|
152
|
+
handle: endpoint(config, "GET /discounts/:id", async ({ actor, params }) => {
|
|
127
153
|
const record = await config.store.get(actor.clientId, String(params.id));
|
|
128
154
|
if (record === null) return { status: 404, body: { error: config.copy.notFound } };
|
|
129
155
|
return ok({ data: record as DiscountRecord });
|
|
130
|
-
},
|
|
156
|
+
}),
|
|
131
157
|
};
|
|
132
158
|
}
|
|
133
159
|
|
|
@@ -136,10 +162,12 @@ function createRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
|
136
162
|
method: "POST",
|
|
137
163
|
path: "/discounts",
|
|
138
164
|
permission: DISCOUNTS_WRITE,
|
|
139
|
-
handle: (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
)
|
|
165
|
+
handle: endpoint(config, "POST /discounts", async (request) => {
|
|
166
|
+
const { actor, body } = request;
|
|
167
|
+
const created = await config.store.create(actor.clientId, writeFrom(body, config.copy));
|
|
168
|
+
logWrite(config.logger, "created", request, created.id);
|
|
169
|
+
return ok({ data: created });
|
|
170
|
+
}),
|
|
143
171
|
};
|
|
144
172
|
}
|
|
145
173
|
|
|
@@ -148,12 +176,13 @@ function updateRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
|
148
176
|
method: "PATCH",
|
|
149
177
|
path: "/discounts/:id",
|
|
150
178
|
permission: DISCOUNTS_WRITE,
|
|
151
|
-
handle: (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
179
|
+
handle: endpoint(config, "PATCH /discounts/:id", async (request) => {
|
|
180
|
+
const { actor, params, body } = request;
|
|
181
|
+
const id = String(params.id);
|
|
182
|
+
await config.store.update(actor.clientId, id, writeFrom(body, config.copy));
|
|
183
|
+
logWrite(config.logger, "re-stated", request, id);
|
|
184
|
+
return acknowledged(id);
|
|
185
|
+
}),
|
|
157
186
|
};
|
|
158
187
|
}
|
|
159
188
|
|
|
@@ -162,21 +191,39 @@ function archiveRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
|
162
191
|
method: "DELETE",
|
|
163
192
|
path: "/discounts/:id",
|
|
164
193
|
permission: DISCOUNTS_WRITE,
|
|
165
|
-
handle:
|
|
194
|
+
handle: endpoint(config, "DELETE /discounts/:id", async (request) => {
|
|
195
|
+
const { actor, params } = request;
|
|
166
196
|
const id = String(params.id);
|
|
167
197
|
await config.store.archive(actor.clientId, id);
|
|
198
|
+
logWrite(config.logger, "archived", request, id);
|
|
168
199
|
return acknowledged(id);
|
|
169
|
-
},
|
|
200
|
+
}),
|
|
170
201
|
};
|
|
171
202
|
}
|
|
172
203
|
|
|
204
|
+
/** The three methods `observed` calls — asserted before any request arrives. */
|
|
205
|
+
function assertLogger(logger: DiscountsLogger | undefined): void {
|
|
206
|
+
const complete =
|
|
207
|
+
typeof logger?.info === "function" &&
|
|
208
|
+
typeof logger.warn === "function" &&
|
|
209
|
+
typeof logger.error === "function";
|
|
210
|
+
if (complete) return;
|
|
211
|
+
throw new Error(
|
|
212
|
+
"@12-apps/discounts: createApiDiscounts needs a logger with info/warn/error — " +
|
|
213
|
+
"the manifest declares an observability namespace, so a wiring host passes " +
|
|
214
|
+
'assembled.loggers["@12-apps/discounts"]; there is no silent default.',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
173
218
|
/**
|
|
174
219
|
* The five descriptors, config asserted at construction.
|
|
175
220
|
*
|
|
176
|
-
* Copy
|
|
177
|
-
* sentence is a wiring mistake and it should fail
|
|
178
|
-
* — the report-builder doctrine: required fields,
|
|
179
|
-
* assembly.
|
|
221
|
+
* Copy and the logger are checked HERE rather than at first request, because a
|
|
222
|
+
* missing sentence or a missing logger is a wiring mistake and it should fail
|
|
223
|
+
* where the wiring is written — the report-builder doctrine: required fields,
|
|
224
|
+
* no defaults, asserted at assembly. The logger earned that treatment the hard
|
|
225
|
+
* way: for the whole of 1.0.x the manifest promised these routes logged, the
|
|
226
|
+
* host built the namespaced logger, and nothing connected the two.
|
|
180
227
|
*/
|
|
181
228
|
export function createApiDiscounts(config: DiscountsApiConfig): {
|
|
182
229
|
routes: readonly DiscountRoute[];
|
|
@@ -188,6 +235,7 @@ export function createApiDiscounts(config: DiscountsApiConfig): {
|
|
|
188
235
|
"every sentence this surface answers a human with is host config, with no defaults.",
|
|
189
236
|
);
|
|
190
237
|
}
|
|
238
|
+
assertLogger(config.logger);
|
|
191
239
|
return {
|
|
192
240
|
routes: [
|
|
193
241
|
listRoute(config),
|