@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.
@@ -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,
@@ -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"],
@@ -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. */
@@ -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 { DISCOUNT_SCOPES, DISCOUNT_TRIGGERS, DISCOUNT_TYPES, MAX_PERCENT_OFF_BP } from "../engine/kinds";
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, `endsAt` strictly after `startsAt` — are
105
- * enforced in `./validate`, where they produce a sentence naming the field
106
- * instead of a Zod path.
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,
@@ -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
  };
@@ -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
- * Run a write, turning this package's own validation failure into the 422 the
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
- async function attempt(run: () => Promise<WireResponse>): Promise<WireResponse> {
94
- try {
95
- return await run();
96
- } catch (error) {
97
- if (error instanceof DiscountValidationError) return fieldError(error);
98
- throw error;
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 }: WireRequest<DiscountsActor>) => {
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 }: WireRequest<DiscountsActor>) => {
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: ({ actor, body }: WireRequest<DiscountsActor>) =>
140
- attempt(async () =>
141
- ok({ data: await config.store.create(actor.clientId, writeFrom(body, config.copy)) }),
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: ({ actor, params, body }: WireRequest<DiscountsActor>) =>
152
- attempt(async () => {
153
- const id = String(params.id);
154
- await config.store.update(actor.clientId, id, writeFrom(body, config.copy));
155
- return acknowledged(id);
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: async ({ actor, params }: WireRequest<DiscountsActor>) => {
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 is checked HERE rather than at first request, because a missing
177
- * sentence is a wiring mistake and it should fail where the wiring is written
178
- * — the report-builder doctrine: required fields, no defaults, asserted at
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),