@12-apps/discounts 1.14.0 → 1.16.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 CHANGED
@@ -474,6 +474,16 @@ language is a line in the host's diff rather than a silence.
474
474
  resolved (`{ clientId }`); authentication, tenant resolution, RBAC tiers and
475
475
  plan gates stay where they belong.
476
476
 
477
+ **Who is buying for the first time.** A rule saved with `firstOrderOnly`
478
+ (FUT-2825) applies only when the host sets `buyerIsFirstTime: true` on it —
479
+ this package never reads an order, so "has this buyer bought here before" is
480
+ the host's answer, the way `buyerUsageCount` already is. Unknown (`null` or
481
+ omitted: an anonymous cart, a cached menu) is NOT first-time, on purpose: a
482
+ discount the cart shows and checkout then takes back is worse than one that
483
+ appears once the buyer is known. So a first-purchase rule is never badged or
484
+ teased on a catalog card, and two concurrent first orders by one buyer can both
485
+ pass, as with `perBuyerLimit`. Combos keep their own rules and never carry it.
486
+
477
487
  **The words on a pill.** The validity filter a promotions grid usually wants —
478
488
  running / scheduled / ended — compares two nullable columns against "now",
479
489
  which no `filterableField` expresses, and its values are words a host chose. So
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/discounts",
3
- "version": "1.14.0",
3
+ "version": "1.16.0",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/e2e/**"
@@ -41,10 +41,10 @@
41
41
  "typecheck": "tsc --noEmit"
42
42
  },
43
43
  "dependencies": {
44
- "@12-apps/app-shell": "^5.11.0",
44
+ "@12-apps/app-shell": "^5.13.1",
45
45
  "@12-apps/forms-core": "^1.21.0",
46
46
  "@12-apps/shared-helpers": "^2.1.0",
47
- "@12-apps/ui": "^6.42.0",
47
+ "@12-apps/ui": "^6.58.0",
48
48
  "@mui/icons-material": "^6.5.0",
49
49
  "zod": "^4.3.5"
50
50
  },
@@ -109,6 +109,9 @@ model Discount {
109
109
  // Redemptions allowed across every buyer, and per buyer. Null = no cap.
110
110
  usageLimit Int? @map("usage_limit")
111
111
  perBuyerLimit Int? @map("per_buyer_limit")
112
+ // Only a buyer's FIRST purchase at the store (FUT-2825). Who that is, is the
113
+ // host's answer: this package never reads an order.
114
+ firstOrderOnly Boolean @default(false) @map("first_order_only")
112
115
  usageCount Int @default(0) @map("usage_count")
113
116
  // When false this rule stacks with nothing: if it wins it is the only one
114
117
  // applied to the order ("best single discount wins").
@@ -0,0 +1,13 @@
1
+ -- @12-apps/discounts: the first-purchase condition (FUT-2825).
2
+ --
3
+ -- "Só na primeira compra do cliente nesta loja" — a rule the engine screens
4
+ -- against the host's answer to "has this buyer bought here before".
5
+ --
6
+ -- ── PURELY ADDITIVE ───────────────────────────────────────────────────────
7
+ -- One NOT NULL column with a constant default, no backfill: FALSE is exactly
8
+ -- what every existing row already means, so this migration cannot change the
9
+ -- price of anything. Postgres adds a column with a constant default without
10
+ -- rewriting the table.
11
+ --
12
+ -- Replay-safe like its predecessors: `IF NOT EXISTS`.
13
+ ALTER TABLE "discounts" ADD COLUMN IF NOT EXISTS "first_order_only" BOOLEAN NOT NULL DEFAULT false;
@@ -152,6 +152,9 @@ function screenLimits(rule: DiscountRule, ctx: ScreenContext): DiscountRejection
152
152
  if (rule.perBuyerLimit !== null && rule.buyerUsageCount >= rule.perBuyerLimit) {
153
153
  return "BUYER_LIMIT_REACHED";
154
154
  }
155
+ // Advisory in the same way `buyerUsageCount` is: two concurrent first orders
156
+ // by one buyer can both pass (FUT-2825).
157
+ if (rule.firstOrderOnly === true && rule.buyerIsFirstTime !== true) return "NOT_FIRST_ORDER";
155
158
  if (rule.minSubtotalCents !== null && ctx.subtotalCents < rule.minSubtotalCents) {
156
159
  return "MIN_SUBTOTAL_NOT_MET";
157
160
  }
@@ -29,6 +29,7 @@ export const EN_US_DISCOUNT_REJECTION_COPY: DiscountRejectionCopy = {
29
29
  MIN_SUBTOTAL_NOT_MET: `This coupon needs a minimum order of ${MIN_SUBTOTAL_TOKEN}.`,
30
30
  USAGE_LIMIT_REACHED: "This coupon has reached its usage limit.",
31
31
  BUYER_LIMIT_REACHED: "You have already used this coupon.",
32
+ NOT_FIRST_ORDER: "Only valid on your first purchase at this store.",
32
33
  NO_ELIGIBLE_ITEMS: "This coupon does not apply to the items in your cart.",
33
34
  COMBO_NOT_MATCHED: "Your cart does not have every item in this bundle yet.",
34
35
  ZERO_VALUE: "This coupon does not apply to the items in your cart.",
@@ -159,6 +159,7 @@ export const DISCOUNT_REJECTION_REASONS = [
159
159
  "MIN_SUBTOTAL_NOT_MET",
160
160
  "USAGE_LIMIT_REACHED",
161
161
  "BUYER_LIMIT_REACHED",
162
+ "NOT_FIRST_ORDER",
162
163
  "NO_ELIGIBLE_ITEMS",
163
164
  "COMBO_NOT_MATCHED",
164
165
  "ZERO_VALUE",
@@ -60,6 +60,14 @@ import type { DiscountCartLine, DiscountRule } from "./types";
60
60
  * screens the same rule against a real `buyerUsageCount` and reports
61
61
  * `BUYER_LIMIT_REACHED`, before a single cent is charged.
62
62
  *
63
+ * `firstOrderOnly` (FUT-2825) goes the OTHER way, deliberately. A per-buyer
64
+ * cap is exhausted by a few; "first purchase only" excludes EVERY returning
65
+ * buyer, so an optimistic badge would be a wrong price for most of the people
66
+ * reading the menu. The screen settles it without a special case: a catalog
67
+ * read passes no `buyerIsFirstTime`, an unknown buyer is not first-time, and
68
+ * `screenRule` rejects `NOT_FIRST_ORDER` — so no badge. The host advertises the
69
+ * promotion to the buyers it CAN name as first-time, outside the menu.
70
+ *
63
71
  * Pure, like the rest of the engine: `now` is injected and the rules arrive
64
72
  * pre-loaded once per catalog request, never per product.
65
73
  */
@@ -33,6 +33,7 @@ export const PT_BR_DISCOUNT_REJECTION_COPY: DiscountRejectionCopy = {
33
33
  MIN_SUBTOTAL_NOT_MET: `Este cupom exige um pedido mínimo de ${MIN_SUBTOTAL_TOKEN}.`,
34
34
  USAGE_LIMIT_REACHED: "Este cupom já atingiu o limite de uso.",
35
35
  BUYER_LIMIT_REACHED: "Você já usou este cupom.",
36
+ NOT_FIRST_ORDER: "Só vale na primeira compra nesta loja.",
36
37
  NO_ELIGIBLE_ITEMS: "Este cupom não vale para os itens do seu carrinho.",
37
38
  COMBO_NOT_MATCHED: "Seu carrinho ainda não tem todos os itens deste combo.",
38
39
  ZERO_VALUE: "Este cupom não vale para os itens do seu carrinho.",
@@ -102,6 +102,9 @@ function isTeasable(rule: DiscountRule, now: Date, localNow: LocalClock | null):
102
102
  */
103
103
  function isTeasableShape(rule: DiscountRule): boolean {
104
104
  if (rule.trigger !== "AUTOMATIC" || !rule.active || rule.scope === "ORDER") return false;
105
+ // A first-purchase rule is never teased: a card cannot know the buyer, and a
106
+ // returning one would be promised a price the cart refuses (FUT-2825).
107
+ if (rule.firstOrderOnly === true) return false;
105
108
  return rule.schedule !== null && rule.schedule !== undefined;
106
109
  }
107
110
 
@@ -121,6 +121,23 @@ export interface DiscountRule {
121
121
  perBuyerLimit: number | null;
122
122
  /** Redemptions this buyer has already PAID for. 0 for an anonymous cart. */
123
123
  buyerUsageCount: number;
124
+ /**
125
+ * The rule applies only to a buyer's FIRST purchase at the store
126
+ * (FUT-2825). Omitted = false, which is every rule that predates it.
127
+ *
128
+ * OPTIONAL for the reason `schedule` is: an adopter that does not offer
129
+ * first-purchase promotions keeps compiling, and keeps pricing exactly as
130
+ * before.
131
+ */
132
+ firstOrderOnly?: boolean;
133
+ /**
134
+ * Whether THIS buyer has no purchase at the store yet — the host's answer,
135
+ * since only the host knows its orders. `null` or omitted = unknown (an
136
+ * anonymous cart, the cached menu), which is NOT first-time: a discount the
137
+ * cart shows and checkout then takes away is worse than one that appears
138
+ * once the buyer is known.
139
+ */
140
+ buyerIsFirstTime?: boolean | null;
124
141
  /** False ⇒ exclusive: if it wins, nothing else applies. */
125
142
  stackable: boolean;
126
143
  /** Deterministic tie-break key, ascending. */
package/src/react/api.ts CHANGED
@@ -36,6 +36,8 @@ export interface DiscountWireRecord {
36
36
  minSubtotalCents: number | null;
37
37
  usageLimit: number | null;
38
38
  perBuyerLimit: number | null;
39
+ /** Only a buyer's first purchase at the store (FUT-2825); absent = false. */
40
+ firstOrderOnly?: boolean;
39
41
  usageCount: number;
40
42
  stackable: boolean;
41
43
  active: boolean;
@@ -98,6 +100,8 @@ export interface DiscountFormPayload {
98
100
  usageLimit: string;
99
101
  perBuyerLimit: string;
100
102
  stackable: boolean;
103
+ /** The first-purchase switch (FUT-2825); absent = false. */
104
+ firstOrderOnly?: boolean;
101
105
  active: boolean;
102
106
  categoryIds: string[];
103
107
  menuItemIds: string[];
@@ -182,6 +186,7 @@ export function toWriteBody(
182
186
  usageLimit: toCount(input.usageLimit, formatters),
183
187
  perBuyerLimit: toCount(input.perBuyerLimit, formatters),
184
188
  stackable: input.stackable,
189
+ firstOrderOnly: input.firstOrderOnly === true,
185
190
  active: input.active,
186
191
  // Narrowed to the scope on the way OUT as well as on the way in: leaving a
187
192
  // stale id list on a rule whose scope changed is how a promotion goes on
package/src/react/copy.ts CHANGED
@@ -144,6 +144,9 @@ export interface DiscountsFormCopy {
144
144
  readonly activeHint: string;
145
145
  readonly stackable: string;
146
146
  readonly stackableHint: string;
147
+ /** The first-purchase condition (FUT-2825), and what it does to the menu. */
148
+ readonly firstOrderOnly: string;
149
+ readonly firstOrderOnlyHint: string;
147
150
  /** The banner over a form the operator must fix before it will send. */
148
151
  readonly reviewFields: string;
149
152
  /** The banner over a write the SERVER refused. */
@@ -15,6 +15,7 @@ import { Stack } from "@12-apps/ui/mui/Stack";
15
15
  import type { DiscountSchedule } from "../engine/schedule";
16
16
  import type { ComboRequirement } from "../engine/types";
17
17
 
18
+ import { FirstOrderField } from "./first-order-field";
18
19
  import type { DiscountFormPayload, DiscountsApiClient, DiscountWireRecord, WireTargetGroup } from "./api";
19
20
  import type { DiscountsWebCopy } from "./copy";
20
21
  import {
@@ -51,10 +52,16 @@ import type { DiscountsFormatters } from "./format";
51
52
  * `setFieldErrors` and paints the same inputs red.
52
53
  */
53
54
 
55
+ /** A stored rule's first-purchase switch (FUT-2825); off for a new rule and an older record. */
56
+ function savedFirstOrderOnly(editing: DiscountWireRecord | null): boolean {
57
+ return editing !== null && editing.firstOrderOnly === true;
58
+ }
59
+
54
60
  /** The values `total-form` cannot hold, kept beside it and merged at submit. */
55
61
  function useNonStringState(editing: DiscountWireRecord | null) {
56
62
  const [active, setActive] = useState<boolean>(editing?.active ?? true);
57
63
  const [stackable, setStackable] = useState<boolean>(editing?.stackable ?? true);
64
+ const [firstOrderOnly, setFirstOrderOnly] = useState<boolean>(() => savedFirstOrderOnly(editing));
58
65
  const [categoryIds, setCategoryIds] = useState<string[]>(editing?.categoryIds ?? []);
59
66
  const [menuItemIds, setMenuItemIds] = useState<string[]>(editing?.menuItemIds ?? []);
60
67
  // The combo's groups are the third array, and the one whose ORDER matters:
@@ -81,6 +88,7 @@ function useNonStringState(editing: DiscountWireRecord | null) {
81
88
  return {
82
89
  active,
83
90
  stackable,
91
+ firstOrderOnly,
84
92
  categoryIds,
85
93
  menuItemIds,
86
94
  comboRequirements,
@@ -88,6 +96,7 @@ function useNonStringState(editing: DiscountWireRecord | null) {
88
96
  schedule,
89
97
  setActive,
90
98
  setStackable,
99
+ setFirstOrderOnly,
91
100
  setCategoryIds,
92
101
  setMenuItemIds,
93
102
  setComboRequirements,
@@ -153,6 +162,7 @@ function makeSubmit(
153
162
  ...typeAndScopeFor(values),
154
163
  ...targets,
155
164
  stackable: extra.stackable,
165
+ firstOrderOnly: extra.firstOrderOnly,
156
166
  active: extra.active,
157
167
  };
158
168
  const result = editing
@@ -211,6 +221,11 @@ export function DiscountForm(props: DiscountFormProps): JSX.Element {
211
221
  onScheduleEnabledChange={extra.setScheduleEnabled}
212
222
  />
213
223
  <LimitFields copy={copy} currencyField={currencyField} />
224
+ <FirstOrderField
225
+ copy={copy}
226
+ checked={extra.firstOrderOnly}
227
+ onChange={extra.setFirstOrderOnly}
228
+ />
214
229
  <DiscountSwitches
215
230
  copy={copy}
216
231
  active={extra.active}
@@ -12,6 +12,7 @@ import {
12
12
  type KindListCardProps,
13
13
  } from "@12-apps/ui/data-display/CardKit";
14
14
  import { Chip } from "@12-apps/ui/data-display/Chip";
15
+ import { Stack } from "@12-apps/ui/mui/Stack";
15
16
  import {
16
17
  BaseListCard,
17
18
  type ListCardCellConfig,
@@ -124,12 +125,17 @@ function RuleFacts({
124
125
  <Fact
125
126
  label={copy.screen.columns.window}
126
127
  value={
127
- <Chip
128
- label={copy.labels.window[row.windowState] ?? row.windowState}
129
- size="sm"
130
- variant={row.windowState === "RUNNING" ? "filled" : "outlined"}
131
- color={WINDOW_COLOR[row.windowState] ?? "neutral"}
132
- />
128
+ // The state AND its dates: a phone-width row hides the window cell
129
+ // (FUT-3184), so "Sem prazo" or the period has to be readable here.
130
+ <Stack direction="row" spacing={1} alignItems="center" useFlexGap flexWrap="wrap">
131
+ <Chip
132
+ label={copy.labels.window[row.windowState] ?? row.windowState}
133
+ size="sm"
134
+ variant={row.windowState === "RUNNING" ? "filled" : "outlined"}
135
+ color={WINDOW_COLOR[row.windowState] ?? "neutral"}
136
+ />
137
+ <span>{row.windowLabel}</span>
138
+ </Stack>
133
139
  }
134
140
  />
135
141
  <Fact label={copy.card.usage} value={`${row.usageCount} / ${cap}`} />
@@ -168,6 +168,9 @@ export const EN_US_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
168
168
  stackable: "Stackable",
169
169
  stackableHint:
170
170
  "When off, this promotion is exclusive: if it wins, no other one is applied.",
171
+ firstOrderOnly: "Only on the customer's first purchase at this store",
172
+ firstOrderOnlyHint:
173
+ "Only for customers who have never bought here. Menu prices do not change: the discount shows in the cart of whoever can use it.",
171
174
  reviewFields: "Check the highlighted fields.",
172
175
  saveFailed: "Could not save the discount",
173
176
  nameRequired: "Give the promotion a name.",
@@ -0,0 +1,40 @@
1
+ import type { JSX } from "react";
2
+
3
+ import { Switch } from "@12-apps/ui/form/Switch";
4
+ import { useFormContext } from "@12-apps/ui/form/total-form";
5
+
6
+ import type { DiscountsWebCopy } from "./copy";
7
+ import { isComboKind } from "./form-kind";
8
+
9
+ /**
10
+ * "Só na primeira compra do cliente nesta loja" (FUT-2825).
11
+ *
12
+ * Offered at the ORDER, CATEGORY and ITEM scopes only. A combo keeps its own
13
+ * rules — "leve 3, pague 2" is a deal about the basket, not about who is
14
+ * buying — so the switch is not drawn for one, and the server folds a combo's
15
+ * value to false whatever a body sends.
16
+ *
17
+ * Its state is the parent's, like the two switches below it, so submit can
18
+ * send it: `total-form` holds strings.
19
+ */
20
+ export function FirstOrderField({
21
+ copy,
22
+ checked,
23
+ onChange,
24
+ }: {
25
+ copy: DiscountsWebCopy;
26
+ checked: boolean;
27
+ onChange: (next: boolean) => void;
28
+ }): JSX.Element | null {
29
+ const { values } = useFormContext();
30
+ if (isComboKind(values.kind ?? "PERCENTAGE") || values.scope === "COMBO") return null;
31
+ return (
32
+ <Switch
33
+ checked={checked}
34
+ onChange={(event) => onChange(event.target.checked)}
35
+ label={copy.form.firstOrderOnly}
36
+ description={copy.form.firstOrderOnlyHint}
37
+ dataTestId="discount-first-order-only"
38
+ />
39
+ );
40
+ }
@@ -158,6 +158,9 @@ export const PT_BR_DISCOUNTS_WEB_COPY: DiscountsWebCopy = {
158
158
  stackable: "Acumulável",
159
159
  stackableHint:
160
160
  "Quando desligado, esta promoção é exclusiva: se vencer, nenhuma outra é aplicada.",
161
+ firstOrderOnly: "Só na primeira compra do cliente nesta loja",
162
+ firstOrderOnlyHint:
163
+ "Vale só para quem nunca comprou aqui. O preço do cardápio não muda: o desconto aparece no carrinho de quem pode usar.",
161
164
  reviewFields: "Revise os campos destacados.",
162
165
  saveFailed: "Não foi possível salvar o desconto",
163
166
  nameRequired: "Informe o nome da promoção.",
package/src/server/mcp.ts CHANGED
@@ -127,6 +127,7 @@ export const discountSchema = z.object({
127
127
  minSubtotalCents: z.number().int().nullable(),
128
128
  usageLimit: z.number().int().nullable(),
129
129
  perBuyerLimit: z.number().int().nullable(),
130
+ firstOrderOnly: z.boolean().optional(),
130
131
  usageCount: z.number().int(),
131
132
  stackable: z.boolean(),
132
133
  active: z.boolean(),
@@ -185,6 +186,7 @@ const discountWriteShape = {
185
186
  minSubtotalCents: z.number().int().positive().nullable().optional(),
186
187
  usageLimit: z.number().int().positive().nullable().optional(),
187
188
  perBuyerLimit: z.number().int().positive().nullable().optional(),
189
+ firstOrderOnly: z.boolean().optional(),
188
190
  stackable: z.boolean(),
189
191
  active: z.boolean(),
190
192
  categoryIds: z.array(z.string().min(1)).optional(),
@@ -44,6 +44,11 @@ export interface DiscountRecord {
44
44
  minSubtotalCents: number | null;
45
45
  usageLimit: number | null;
46
46
  perBuyerLimit: number | null;
47
+ /**
48
+ * Only a buyer's first purchase at the store (FUT-2825). OPTIONAL for the
49
+ * reason `schedule` is: a host without the column answers the same record.
50
+ */
51
+ firstOrderOnly?: boolean;
47
52
  usageCount: number;
48
53
  stackable: boolean;
49
54
  active: boolean;
@@ -63,6 +63,11 @@ export interface DiscountWriteInput {
63
63
  minSubtotalCents: number | null;
64
64
  usageLimit: number | null;
65
65
  perBuyerLimit: number | null;
66
+ /**
67
+ * Only a buyer's FIRST purchase at the store (FUT-2825). Optional for the
68
+ * reason `schedule` is; omitted = false.
69
+ */
70
+ firstOrderOnly?: boolean;
66
71
  stackable: boolean;
67
72
  active: boolean;
68
73
  /** Target category ids — read only when `scope === "CATEGORY"`. */
@@ -235,6 +240,8 @@ export interface DiscountScalars {
235
240
  minSubtotalCents: number | null;
236
241
  usageLimit: number | null;
237
242
  perBuyerLimit: number | null;
243
+ /** Only a buyer's first purchase at the store. Always false on a COMBO. */
244
+ firstOrderOnly: boolean;
238
245
  stackable: boolean;
239
246
  active: boolean;
240
247
  }
@@ -292,11 +299,21 @@ export function toDiscountScalars(
292
299
  minSubtotalCents: input.minSubtotalCents,
293
300
  usageLimit: input.usageLimit,
294
301
  perBuyerLimit: input.perBuyerLimit,
302
+ firstOrderOnly: firstOrderOnlyOf(input),
295
303
  stackable: input.stackable,
296
304
  active: input.active,
297
305
  };
298
306
  }
299
307
 
308
+ /**
309
+ * A combo keeps its own rules (FUT-2825): the form never offers the condition
310
+ * there, and a body that sends it anyway is folded to false, the way a code
311
+ * left behind by flipping the trigger is folded to null.
312
+ */
313
+ function firstOrderOnlyOf(input: DiscountWriteInput): boolean {
314
+ return input.scope !== "COMBO" && input.firstOrderOnly === true;
315
+ }
316
+
300
317
  /**
301
318
  * The validated wire body as the routes receive it — every optional field is
302
319
  * `T | null | undefined` because the wire lets a client omit what does not
@@ -322,6 +339,7 @@ export interface DiscountWriteBody {
322
339
  minSubtotalCents?: number | null;
323
340
  usageLimit?: number | null;
324
341
  perBuyerLimit?: number | null;
342
+ firstOrderOnly?: boolean;
325
343
  stackable: boolean;
326
344
  active: boolean;
327
345
  categoryIds?: string[];
@@ -357,6 +375,7 @@ export function toDiscountWriteInput(body: DiscountWriteBody): DiscountWriteInpu
357
375
  minSubtotalCents: orNull(body.minSubtotalCents),
358
376
  usageLimit: orNull(body.usageLimit),
359
377
  perBuyerLimit: orNull(body.perBuyerLimit),
378
+ firstOrderOnly: body.firstOrderOnly === true,
360
379
  stackable: body.stackable,
361
380
  active: body.active,
362
381
  categoryIds: orEmpty(body.categoryIds),