@zolve/offers 0.3.1 → 0.3.2

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
@@ -16,12 +16,11 @@ const decision = decide(doc, { placement: 'cart', cart, visitorId, at: new Date(
16
16
 
17
17
  // Cobrança: o preço de cada linha aceita (com offerId), conferindo as condições de novo com o carrinho real.
18
18
  const priced = price(doc, { cart, visitorId, at });
19
- // priced.lines[i]: { status: 'ok', unitPrice, compareAtPrice } | { status: 'ineligible', reason }
19
+ // priced.lines[i]: { status: 'ok', unitPrice, compareAtPrice, discountedQuantity } | { status: 'ineligible', reason }
20
20
  ```
21
21
 
22
- `unitPrice` é por unidade e vale para a **quantidade toda** da linha (duas unidades da oferta custam duas vezes o
23
- preço da oferta), como a function de desconto do app sempre cobrou (`appliesToEachItem`). `compareAtPrice` é o preço da
24
- loja no documento, quando maior que o da oferta (o mesmo do `decide`).
22
+ `unitPrice` é por unidade e vale para `discountedQuantity` unidades da linha (0.3.2); o resto da linha vai a preço
23
+ cheio. `compareAtPrice` é o preço da loja no documento, quando maior que o da oferta (o mesmo do `decide`).
25
24
 
26
25
  `offerKind(doc, offer)` (0.2.0) dá o tipo e a direção de uma oferta cadastrada sem carrinho, com os gatilhos de
27
26
  `cart_has_any` no papel do carrinho escolhido: é o que o editor do Commerce OS mostra.
@@ -60,9 +59,8 @@ o `decide` deu ao visitante.
60
59
  `visitorId`, `holdout` é sempre `false`.
61
60
  - **Só o que a cliente escolheu** é gatilho, subtotal, vaga extra e combo, no `decide` e na reconferência do `price`:
62
61
  linha de oferta aceita não sustenta outra. A exceção é a **linha de troca** (`offerId` + `replacedItem` de uma oferta
63
- `replace` do documento, item trocado do mesmo produto): é o que a cliente leva no lugar do que escolheu e conta como
64
- escolhida para as **outras** ofertas. Para a própria troca vale o item trocado, nunca a linha dela (não se sustenta
65
- com o próprio preço). O `replacedItem` fora de `doc.items` é reconhecido pelo `storeProductId` da linha (a troca é no
62
+ `replace` do documento, item trocado do mesmo produto), que desde a 0.3.2 conta como o **item trocado** para todas as
63
+ ofertas (ver abaixo). O `replacedItem` fora de `doc.items` é reconhecido pelo `storeProductId` da linha (a troca é no
66
64
  mesmo produto da loja). Só conta como escolhida a troca cuja oferta `replace` está ligada (com a variante do
67
65
  teste do visitante), dentro da validade no `at` e de tipo que o pacote conhece; senão é linha de oferta comum e não
68
66
  sustenta outra oferta.
@@ -75,13 +73,50 @@ o `decide` deu ao visitante.
75
73
  ordem do documento, em que a oferta é elegível; nenhuma elegível, a primeira. Linha recusada ainda ocupa a vaga no
76
74
  `decide` (a ponte pode cobrá-la cheia); id que o documento não tem não ocupa vaga.
77
75
  - **Vitrine e cobrança concordam**: para o mesmo documento e carrinho, toda oferta que o `decide` mostra e a cliente
78
- aceita sai `ok` no `price`, ao preço mostrado (é um teste de propriedade do pacote). Para isso a linha acrescentada leva o
76
+ aceita (e, desde a 0.3.2, toda combinação delas, com troca, vaga extra e combo) sai `ok` no `price`, ao preço
77
+ mostrado (é um teste de propriedade do pacote). Para isso a linha acrescentada leva o
79
78
  `placement` da `DecidedOffer`: com o id em duas posições e sem ele, a regra acima pode atribuir a linha à outra
80
79
  posição.
81
80
  - **`visitorId` no `price`**: a ponte manda sempre o mesmo `visitorId` (e as mesmas `assignments`) do `decide`. Sem
82
81
  ele o `price` não sorteia teste nem confere o grupo de fora geral: cobra o documento sem variante, e quem estava no
83
82
  grupo de fora leva a oferta.
84
83
 
84
+ ## Um carrinho só e quantidade com teto (0.3.2)
85
+
86
+ - **O carrinho de antes da troca**, no `decide` e no `price`: a linha de troca válida volta a ser o item trocado
87
+ (`replacedItem`), com a quantidade da linha, como linha escolhida, para **tudo**: gatilho, `cart_has_none`, subtotal,
88
+ vaga extra, combo e contagem de vagas, da própria troca e das outras ofertas. A linha de troca em si ocupa a vaga da
89
+ posição dela. Assim o `price` vê exatamente o carrinho que o `decide` viu: a troca LP → 90 não fecha a vaga extra
90
+ presa à LP, o cross-sell com gatilho na Intense 30 segue valendo depois da troca 30 → 90, e a troca de R$ 199 não
91
+ destrava o brinde de R$ 250 (no subtotal entra o item trocado, não o novo). Substitui a regra da 0.3.1 ("a linha de
92
+ troca conta como o produto que a cliente leva").
93
+ - **Subtotal pelo preço do documento**: toda linha escolhida de item listado em `doc.items` entra no subtotal pelo
94
+ preço do documento; o `unitPrice` da linha só vale para item fora dele (0 sem nenhum dos dois). O item trocado já
95
+ saiu do carrinho e o pacote não sabe o preço da linha que sumiu: com o preço do documento, as duas chamadas somam
96
+ igual. Item trocado **fora** do documento conta 0 no subtotal depois da troca (a compilação deve listar os itens que
97
+ uma troca pode tirar). O `unitPrice` segue sendo teto do preço da linha de oferta.
98
+ - **Troca só na posição dela**: a linha é troca quando a oferta `replace` com aquele `offerId` é a da posição da linha
99
+ (o `placement` dela, ou a atribuída pela regra de posição abaixo), ligada, na validade, de tipo conhecido, do mesmo
100
+ item, e o `replacedItem` é do mesmo produto. O mesmo id como `add` em `cart` e `replace` em `product_page`: a linha
101
+ de `cart` com `replacedItem` forjado é `add` e não sustenta outra oferta.
102
+ - **Alvo da troca sem depender da ordem**: com mais de uma linha escolhida do mesmo produto, o `replaces` é a de menor
103
+ duração no catálogo e, no empate (ou sem duração), a de menor chave de item (`source:variantId`). Linha que já é
104
+ troca não é alvo de outra troca.
105
+ - **`maxQuantity`** (campo opcional da oferta, inteiro ≥ 1): quantas unidades da linha levam o preço da oferta.
106
+ Ausente: **1** quando o preço da oferta dá 0 (brinde), a linha toda nos outros casos. O `price` devolve
107
+ `discountedQuantity` por linha `ok`; a ponte aplica o desconto só nessas unidades (na Shopify, o desconto de linha
108
+ com quantidade limitada) e o resto vai a preço cheio. `maxQuantity` que não é inteiro ≥ 1 torna a oferta
109
+ `unsupported_offer`. Como a 0.3.2 cobra diferente da 0.3.1 com o mesmo documento (o brinde passa a valer uma
110
+ unidade mesmo sem o campo), o formato subiu para **`schemaVersion` 2**: uma ponte ainda na 0.3.1 recebe
111
+ `unsupported_schema` e não cobra, em vez de dar o desconto na linha toda.
112
+ - **Linha validada**: `quantity` inteiro ≥ 1 e `unitPrice` (se vier) centavos inteiros ≥ 0. Linha fora disso não é
113
+ gatilho nem entra no subtotal; a linha de oferta volta `invalid_line`. O pacote nunca devolve `NaN`, negativo ou
114
+ fração; preço de oferta com valor que não é inteiro também é `unsupported_offer`.
115
+ - **`decisionId`** = sha1 da versão, posição, visitante, carrinho (cada linha com item, quantidade, `unitPrice`,
116
+ `offerId`, `placement`, `replacedItem`, `storeProductId`, `gift`, sem a ordem) e da **resposta inteira** (cada oferta
117
+ com preço, alvo da troca, conteúdo e vaga; apresentação, combo, teste, `holdout`). Mesma pergunta e mesma resposta =
118
+ mesmo id; resposta diferente, outro id. Os ids mudam em relação à 0.3.1.
119
+
85
120
  Motivos de `ineligible`: `unknown_offer`, `inactive`, `item_mismatch`, `not_started`, `expired`, `trigger_missing`,
86
121
  `subtotal_out_of_range`, `unsupported_schema` e, desde a 0.3.1:
87
122
 
@@ -92,6 +127,7 @@ Motivos de `ineligible`: `unknown_offer`, `inactive`, `item_mismatch`, `not_star
92
127
  | `no_slot` | A posição já tem as vagas tomadas por ofertas de prioridade maior, ou a oferta/item já vale em outra linha |
93
128
  | `unsupported_offer` | Condição, preço ou ação de tipo que o pacote não conhece (o `decide` também não mostra) |
94
129
  | `invalid_instant` | Oferta com validade e `at` (ou a validade) ilegível |
130
+ | `invalid_line` (0.3.2) | Linha com `quantity` que não é inteiro ≥ 1 ou `unitPrice` que não é centavo inteiro ≥ 0 |
95
131
 
96
132
  Documento com `schemaVersion` maior que `SUPPORTED_SCHEMA_VERSION` não é decidido nem precificado: a resposta vem com
97
133
  `status: 'unsupported_schema'` (e toda linha do `price` inelegível com esse motivo). Atualize o pacote.
package/dist/assess.d.ts CHANGED
@@ -4,13 +4,15 @@ import type { SlotClaim } from './slots.js';
4
4
  import type { Offer, OfferDocument } from './types.js';
5
5
  /**
6
6
  * A avaliação das linhas de oferta do carrinho, a mesma no `decide` (as vagas já tomadas) e no `price` (a cobrança).
7
- * O `input` é só o que as duas chamadas têm em comum: carrinho, instante, visitante e atribuições.
7
+ * O `input` é só o que as duas chamadas têm em comum: carrinho, instante, visitante e atribuições. `placed` é a oferta
8
+ * do documento a que cada linha de oferta foi atribuída (a regra de posição), e `isSwap` sai dela.
8
9
  */
9
10
  export interface Assessor {
10
11
  doc: OfferDocument;
11
12
  items: ItemIndex;
12
13
  input: PriceInput;
13
14
  isSwap: SwapCheck;
15
+ placed: Map<number, Offer>;
14
16
  }
15
17
  /** Uma linha de oferta avaliada: a posição a que ela foi atribuída e, se passou, a oferta que disputa a vaga. */
16
18
  export interface Assessed {
@@ -22,6 +24,10 @@ export interface Assessed {
22
24
  export declare const refused: (index: number, offerId: string, reason: IneligibleReason) => PricedLine;
23
25
  /** O portão da oferta em si, sem carrinho: tipo que o pacote conhece, ligada e dentro da validade. */
24
26
  export declare function offerGate(offer: Offer, at: string): IneligibleReason | null;
27
+ /**
28
+ * Duas passadas: primeiro as trocas que a própria linha define (o id numa posição só, ou a posição da linha); com elas,
29
+ * a posição de cada linha de oferta; daí as trocas de fato (`replace` na posição atribuída), que valem para tudo.
30
+ */
25
31
  export declare function assessor(doc: OfferDocument, items: ItemIndex, input: PriceInput): Assessor;
26
32
  /** Toda linha com `offerId`, brinde ou não, na ordem do carrinho: é linha de oferta, e disputa a vaga da posição dela. */
27
33
  export declare function assessOfferLines(ctx: Assessor): Assessed[];
package/dist/assess.js CHANGED
@@ -1,8 +1,8 @@
1
- import { isSameProductSwap, itemKey, replacedLine, viewCart } from './cart.js';
2
- import { conditionsFailure, isSupportedOffer, validityFailure } from './conditions.js';
1
+ import { isSameProductSwap, isValidLine, itemKey, viewCart } from './cart.js';
2
+ import { conditionsFailure, isSellable, isSupportedOffer, validityFailure } from './conditions.js';
3
3
  import { applyVariant } from './effective.js';
4
4
  import { assignExperiment } from './experiments.js';
5
- import { compareAtPrice, offerUnitPrice } from './money.js';
5
+ import { compareAtPrice, discountedQuantity, offerUnitPrice } from './money.js';
6
6
  export const refused = (index, offerId, reason) => ({ index, offerId, status: 'ineligible', reason });
7
7
  /** A oferta do documento como o visitante a vê: com a variante do teste da posição dela. */
8
8
  function liveOffer(doc, found, input) {
@@ -17,48 +17,60 @@ export function offerGate(offer, at) {
17
17
  return validityFailure(offer, at);
18
18
  }
19
19
  /**
20
- * Linha de troca: uma oferta `replace` do documento com aquele id e aquele item, que passa no portão (com a variante
21
- * do visitante e o `at`), e o item trocado do mesmo produto. Forjar os campos só rende o que o caminho legítimo daria;
22
- * a troca desligada, vencida ou de tipo desconhecido é linha de oferta comum, e não sustenta outra oferta.
20
+ * Troca viva: a oferta (a da posição da linha) é `replace` daquele item, passa no portão com a variante do visitante e
21
+ * o `at`, e a linha é válida e diz que trocou um item do mesmo produto. Forjar os campos só rende o que o caminho
22
+ * legítimo daria; a troca desligada, vencida, de tipo desconhecido ou de outra posição é linha de oferta comum.
23
23
  */
24
- function swapCheck(doc, items, input) {
25
- const isLiveReplace = (offer, line) => offer.id === line.offerId && offer.action === 'replace' && itemKey(offer.item) === itemKey(line.item) && offerGate(liveOffer(doc, offer, input), input.at) === null;
26
- return (line) => line.offerId !== undefined && isSameProductSwap(items, line) && doc.offers.some((offer) => isLiveReplace(offer, line));
24
+ function isLiveSwap(ctx, offer, line) {
25
+ if (offer.action !== 'replace' || itemKey(offer.item) !== itemKey(line.item))
26
+ return false;
27
+ if (!isValidLine(line) || !isSameProductSwap(ctx.items, line))
28
+ return false;
29
+ return offerGate(liveOffer(ctx.doc, offer, ctx.input), ctx.input.at) === null;
27
30
  }
28
- export function assessor(doc, items, input) {
29
- return { doc, items, input, isSwap: swapCheck(doc, items, input) };
31
+ /** As ofertas que podem valer para a linha: a da posição dela, quando o id está lá; senão todas com o id. */
32
+ function lineOptions(doc, line) {
33
+ const same = doc.offers.filter((offer) => offer.id === line.offerId);
34
+ const declared = same.filter((offer) => offer.placement === line.placement);
35
+ return declared.length > 0 ? declared : same;
36
+ }
37
+ /** As posições do carrinho que são troca, calculadas uma vez (o sorteio do teste não roda a cada linha). */
38
+ function swapSet(cart, offerOf, isSwap) {
39
+ const swaps = new Set(cart.flatMap((line, index) => {
40
+ const offer = line.offerId ? offerOf(line, index) : undefined;
41
+ return offer && isSwap(offer, line) ? [index] : [];
42
+ }));
43
+ return (index) => swaps.has(index);
30
44
  }
31
45
  /**
32
- * O carrinho como no instante em que a oferta apareceu: só o que a cliente escolheu (com as linhas de troca), sem a
33
- * própria linha e, no `replace`, com o item trocado de volta no lugar. Nulo quando o `replace` não diz o que trocou,
34
- * ou diz um item que não é o mesmo produto.
46
+ * O carrinho como no instante em que a oferta apareceu, o de antes da troca: só o que a cliente escolheu (as trocas
47
+ * de volta ao item trocado), sem a própria linha no `add` e, no `replace`, com ela de volta ao item trocado. Nulo
48
+ * quando o `replace` não diz o que trocou, ou diz um item que não é o mesmo produto.
35
49
  */
36
50
  function recheckView(ctx, offer, index) {
37
51
  const line = ctx.input.cart[index];
38
- const others = ctx.input.cart.filter((_, position) => position !== index);
39
52
  if (offer.action === 'add')
40
- return viewCart(ctx.items, others, ctx.isSwap);
41
- const replaced = replacedLine(line);
42
- if (!replaced || !isSameProductSwap(ctx.items, line))
53
+ return viewCart(ctx.items, ctx.input.cart, ctx.isSwap, index);
54
+ if (!isSameProductSwap(ctx.items, line))
43
55
  return null;
44
- return viewCart(ctx.items, [...others, replaced], ctx.isSwap);
56
+ return viewCart(ctx.items, ctx.input.cart, (position) => position === index || ctx.isSwap(position));
45
57
  }
46
58
  function lineFailure(ctx, offer, index) {
47
59
  const line = ctx.input.cart[index];
48
- if (!isSupportedOffer(offer))
49
- return 'unsupported_offer';
50
- if (!offer.active)
51
- return 'inactive';
60
+ if (!isValidLine(line))
61
+ return 'invalid_line';
62
+ const gate = offerGate(offer, ctx.input.at);
63
+ if (gate === 'unsupported_offer' || gate === 'inactive')
64
+ return gate;
52
65
  const item = ctx.items.get(itemKey(offer.item));
53
66
  if (!item)
54
67
  return 'unknown_offer';
55
68
  if (itemKey(line.item) !== itemKey(offer.item))
56
69
  return 'item_mismatch';
57
- if (!item.available || item.price <= 0)
70
+ if (!isSellable(item))
58
71
  return 'unavailable';
59
- const validity = validityFailure(offer, ctx.input.at);
60
- if (validity)
61
- return validity;
72
+ if (gate)
73
+ return gate;
62
74
  const view = recheckView(ctx, offer, index);
63
75
  if (!view)
64
76
  return 'trigger_missing';
@@ -74,26 +86,56 @@ function assessOffer(ctx, found, index) {
74
86
  if (reason)
75
87
  return { priced: refused(index, offerId, reason), claim: null, placement: found.placement };
76
88
  const storePrice = ctx.items.get(itemKey(offer.item)).price;
77
- const unitPrice = Math.min(offerUnitPrice(offer.price, storePrice), line.unitPrice ?? storePrice);
78
- const priced = { index, offerId, status: 'ok', unitPrice, compareAtPrice: compareAtPrice(storePrice, unitPrice) };
89
+ const offerPrice = offerUnitPrice(offer.price, storePrice);
90
+ const unitPrice = Math.min(offerPrice, line.unitPrice ?? storePrice);
91
+ const priced = {
92
+ index,
93
+ offerId,
94
+ status: 'ok',
95
+ unitPrice,
96
+ compareAtPrice: compareAtPrice(storePrice, unitPrice),
97
+ discountedQuantity: discountedQuantity(line.quantity, offer.maxQuantity, offerPrice),
98
+ };
79
99
  return { priced, claim: { index, offer, order: ctx.doc.offers.indexOf(found) }, placement: found.placement };
80
100
  }
81
101
  /**
82
102
  * A atribuição de posição de uma linha de oferta, a única do pacote. O id repetido em duas posições (oferta migrada do
83
103
  * app) desempata pela posição da linha. Sem ela (ou com uma que não tem o id), vale a primeira posição, na ordem do
84
104
  * documento, em que a oferta é elegível; nenhuma elegível, a primeira. Id que o documento não tem não fica em posição.
105
+ * A elegibilidade desse desempate é medida com as trocas cuja posição a linha já define (`declared`).
85
106
  */
86
- function assessLine(ctx, line, index) {
87
- const offerId = line.offerId;
88
- const same = ctx.doc.offers.filter((offer) => offer.id === offerId);
89
- const declared = same.filter((offer) => offer.placement === line.placement);
90
- const options = declared.length > 0 ? declared : same;
91
- if (options.length === 0)
92
- return { priced: refused(index, offerId, 'unknown_offer'), claim: null, placement: null };
93
- const results = options.map((offer) => assessOffer(ctx, offer, index));
94
- return results.find((result) => result.claim !== null) ?? results[0];
107
+ function placeLines(declared) {
108
+ const placed = new Map();
109
+ declared.input.cart.forEach((line, index) => {
110
+ const options = line.offerId ? lineOptions(declared.doc, line) : [];
111
+ const pick = options.length > 1 ? options.find((offer) => assessOffer(declared, offer, index).claim !== null) : undefined;
112
+ const offer = pick ?? options[0];
113
+ if (offer)
114
+ placed.set(index, offer);
115
+ });
116
+ return placed;
117
+ }
118
+ /**
119
+ * Duas passadas: primeiro as trocas que a própria linha define (o id numa posição só, ou a posição da linha); com elas,
120
+ * a posição de cada linha de oferta; daí as trocas de fato (`replace` na posição atribuída), que valem para tudo.
121
+ */
122
+ export function assessor(doc, items, input) {
123
+ const base = { doc, items, input };
124
+ const declaredOffer = (line) => {
125
+ const options = lineOptions(doc, line);
126
+ return options.length === 1 ? options[0] : undefined;
127
+ };
128
+ const declaredSwap = swapSet(input.cart, declaredOffer, (offer, line) => isLiveSwap(base, offer, line));
129
+ const placed = placeLines({ ...base, isSwap: declaredSwap, placed: new Map() });
130
+ const isSwap = swapSet(input.cart, (_, index) => placed.get(index), (offer, line) => isLiveSwap(base, offer, line));
131
+ return { ...base, isSwap, placed };
95
132
  }
96
133
  /** Toda linha com `offerId`, brinde ou não, na ordem do carrinho: é linha de oferta, e disputa a vaga da posição dela. */
97
134
  export function assessOfferLines(ctx) {
98
- return ctx.input.cart.flatMap((line, index) => (line.offerId ? [assessLine(ctx, line, index)] : []));
135
+ return ctx.input.cart.flatMap((line, index) => {
136
+ if (!line.offerId)
137
+ return [];
138
+ const offer = ctx.placed.get(index);
139
+ return [offer ? assessOffer(ctx, offer, index) : { priced: refused(index, line.offerId, 'unknown_offer'), claim: null, placement: null }];
140
+ });
99
141
  }
package/dist/cart.d.ts CHANGED
@@ -8,13 +8,16 @@ export interface ResolvedLine {
8
8
  storeProductId: string | null;
9
9
  catalogProductIds: string[];
10
10
  durationDays: number | null;
11
+ /** Linha de troca vista como o item que ela trocou (o carrinho de antes da troca): não é alvo de outra troca. */
12
+ swapped: boolean;
11
13
  }
12
14
  /**
13
- * O carrinho do ponto de vista do motor. O gatilho e o subtotal são só o que a cliente escolheu: brinde e linha de
14
- * oferta aceita ficam fora, senão a oferta aceita vira gatilho da próxima ("a fila que não acaba", app ADR 0006) ou
15
- * sustenta o subtotal da outra. A exceção é a **linha de troca** (`replace`): ela é o produto que a cliente leva no
16
- * lugar do que escolheu, e conta como escolhida para as outras ofertas. `keys` é o carrinho inteiro: não se oferece o
17
- * que ela já tem, venha de onde vier.
15
+ * O carrinho do ponto de vista do motor, **o de antes da troca**, o mesmo no `decide` e no `price` (pacote 0.3.2).
16
+ * O gatilho, o subtotal, a vaga extra e o combo são só o que a cliente escolheu: brinde e linha de oferta aceita ficam
17
+ * fora, senão a oferta aceita vira gatilho da próxima ("a fila que não acaba", app ADR 0006) ou sustenta o subtotal da
18
+ * outra. A **linha de troca** (`replace`) volta a ser o item trocado, com a quantidade dela: a vitrine decidiu com ele
19
+ * no carrinho, e a cobrança confere o mesmo carrinho. Linha com quantidade ou preço inválido não conta. `keys` é o
20
+ * carrinho inteiro, com o item trocado: não se oferece o que ela tem (ou acabou de trocar), venha de onde vier.
18
21
  */
19
22
  export interface CartView {
20
23
  chosen: ResolvedLine[];
@@ -24,14 +27,17 @@ export interface CartView {
24
27
  }
25
28
  export type ItemIndex = Map<string, OfferItem>;
26
29
  export declare function indexItems(doc: OfferDocument): ItemIndex;
27
- export declare function resolveLine(items: ItemIndex, line: CartLine): ResolvedLine;
30
+ export declare function resolveLine(items: ItemIndex, line: CartLine, swapped?: boolean): ResolvedLine;
31
+ /** Quantidade inteira ≥ 1 e, se vier, preço em centavos inteiros ≥ 0. A linha fora disso não é gatilho nem é cobrada. */
32
+ export declare function isValidLine(line: CartLine): boolean;
28
33
  /** O item que a troca tirou, como linha; fora do documento, ele é do produto da loja da linha (a troca é no mesmo produto). */
29
34
  export declare function replacedLine(line: CartLine): CartLine | null;
30
35
  /** A linha diz que trocou um item do mesmo produto pelo ofertado. Só a forma: se a troca ainda vale é o `price` que diz. */
31
36
  export declare function isSameProductSwap(items: ItemIndex, line: CartLine): boolean;
32
- /** A linha é de troca (`assess.ts`): conta como escolhida. */
33
- export type SwapCheck = (line: CartLine) => boolean;
34
- export declare function viewCart(items: ItemIndex, lines: CartLine[], isSwap: SwapCheck): CartView;
37
+ /** A linha nesta posição do carrinho é uma troca válida (`assess.ts`): no carrinho de antes, ela é o item trocado. */
38
+ export type SwapCheck = (index: number) => boolean;
39
+ /** `omit`: a posição da linha que fica de fora (a própria linha de oferta, na reconferência dela). */
40
+ export declare function viewCart(items: ItemIndex, cart: CartLine[], isSwap: SwapCheck, omit?: number): CartView;
35
41
  export declare function lineMatches(match: ProductMatch, line: ResolvedLine): boolean;
36
42
  export declare function anyLineMatches(match: ProductMatch, lines: ResolvedLine[]): boolean;
37
43
  /**
package/dist/cart.js CHANGED
@@ -2,7 +2,7 @@ export const itemKey = (ref) => `${ref.source}:${ref.variantId}`;
2
2
  export function indexItems(doc) {
3
3
  return new Map(doc.items.map((item) => [itemKey(item), item]));
4
4
  }
5
- export function resolveLine(items, line) {
5
+ export function resolveLine(items, line, swapped = false) {
6
6
  const key = itemKey(line.item);
7
7
  const item = items.get(key);
8
8
  return {
@@ -11,10 +11,21 @@ export function resolveLine(items, line) {
11
11
  storeProductId: item?.storeProductId ?? line.storeProductId ?? null,
12
12
  catalogProductIds: item?.catalogProductIds ?? [],
13
13
  durationDays: item?.durationDays ?? null,
14
+ swapped,
14
15
  };
15
16
  }
17
+ const isCents = (value) => Number.isSafeInteger(value) && value >= 0;
18
+ /** Quantidade inteira ≥ 1 e, se vier, preço em centavos inteiros ≥ 0. A linha fora disso não é gatilho nem é cobrada. */
19
+ export function isValidLine(line) {
20
+ return Number.isSafeInteger(line.quantity) && line.quantity >= 1 && (line.unitPrice === undefined || isCents(line.unitPrice));
21
+ }
22
+ /**
23
+ * Valor da linha no subtotal: o preço do **documento** quando o item está nele, o `unitPrice` da linha só quando não
24
+ * está (0 sem nenhum dos dois). O item trocado não tem linha no carrinho de depois; com o preço do documento para todo
25
+ * item listado, o `decide` (antes) e o `price` (depois) somam o mesmo.
26
+ */
16
27
  function lineTotal(items, line) {
17
- const unit = line.unitPrice ?? items.get(itemKey(line.item))?.price ?? 0;
28
+ const unit = items.get(itemKey(line.item))?.price ?? line.unitPrice ?? 0;
18
29
  return unit * line.quantity;
19
30
  }
20
31
  /** O item que a troca tirou, como linha; fora do documento, ele é do produto da loja da linha (a troca é no mesmo produto). */
@@ -29,14 +40,28 @@ export function isSameProductSwap(items, line) {
29
40
  const replaced = replacedLine(line);
30
41
  return replaced !== null && sameProduct(resolveLine(items, replaced), resolveLine(items, line));
31
42
  }
32
- export function viewCart(items, lines, isSwap) {
33
- const resolved = lines.map((line) => resolveLine(items, line));
34
- const chosen = resolved.filter(({ line }) => !line.gift && (!line.offerId || isSwap(line)));
35
- return {
36
- chosen,
37
- keys: new Set(resolved.map(({ key }) => key)),
38
- subtotal: chosen.reduce((sum, { line }) => sum + lineTotal(items, line), 0),
39
- };
43
+ /** A linha como o carrinho de antes da troca a vê: escolhida, trocada (o item que saiu) ou fora (oferta, brinde, inválida). */
44
+ function chosenAs(items, line, swap) {
45
+ if (line.gift || !isValidLine(line))
46
+ return null;
47
+ const replaced = swap ? replacedLine(line) : null;
48
+ if (replaced)
49
+ return resolveLine(items, replaced, true);
50
+ return line.offerId ? null : resolveLine(items, line);
51
+ }
52
+ /** `omit`: a posição da linha que fica de fora (a própria linha de oferta, na reconferência dela). */
53
+ export function viewCart(items, cart, isSwap, omit = -1) {
54
+ const chosen = [];
55
+ const keys = new Set();
56
+ cart.forEach((line, index) => {
57
+ keys.add(itemKey(line.item));
58
+ const seen = index === omit ? null : chosenAs(items, line, isSwap(index));
59
+ if (seen)
60
+ chosen.push(seen);
61
+ if (seen?.swapped)
62
+ keys.add(seen.key);
63
+ });
64
+ return { chosen, keys, subtotal: chosen.reduce((sum, { line }) => sum + lineTotal(items, line), 0) };
40
65
  }
41
66
  export function lineMatches(match, line) {
42
67
  if (match.items?.some((ref) => itemKey(ref) === line.key))
@@ -1,6 +1,6 @@
1
1
  import { type CartView } from './cart.js';
2
2
  import type { IneligibleReason } from './io.js';
3
- import type { Offer, OfferCondition } from './types.js';
3
+ import type { Offer, OfferCondition, OfferItem } from './types.js';
4
4
  /** `excluded` só existe no `decide`: o "produto que esconde" esconde a oferta, mas não desfaz a já aceita (app ADR 0025). */
5
5
  export type GateFailure = IneligibleReason | 'excluded';
6
6
  /**
@@ -8,6 +8,8 @@ export type GateFailure = IneligibleReason | 'excluded';
8
8
  * pode trazer um tipo que este não sabe avaliar: a oferta não vale, em vez de passar ou cobrar `NaN`.
9
9
  */
10
10
  export declare function isSupportedOffer(offer: Offer): boolean;
11
+ /** Item vendável pela oferta: disponível e com preço da loja em centavos > 0 (sem ele a compilação grava 0, e a oferta sairia de graça). */
12
+ export declare const isSellable: (item: OfferItem | undefined) => item is OfferItem;
11
13
  /** Validade: início inclusivo, fim exclusivo. Com validade, um instante ilegível (o `at` ou a própria validade) não vale. */
12
14
  export declare function validityFailure(offer: Offer, at: string): 'not_started' | 'expired' | 'invalid_instant' | null;
13
15
  /**
@@ -7,8 +7,11 @@ const ACTIONS = new Set(['add', 'replace']);
7
7
  * pode trazer um tipo que este não sabe avaliar: a oferta não vale, em vez de passar ou cobrar `NaN`.
8
8
  */
9
9
  export function isSupportedOffer(offer) {
10
- return isKnownPrice(offer.price) && ACTIONS.has(offer.action) && offer.conditions.every((condition) => CONDITION_TYPES.has(condition.type));
10
+ const quantity = offer.maxQuantity === undefined || (Number.isSafeInteger(offer.maxQuantity) && offer.maxQuantity >= 1);
11
+ return quantity && isKnownPrice(offer.price) && ACTIONS.has(offer.action) && offer.conditions.every((condition) => CONDITION_TYPES.has(condition.type));
11
12
  }
13
+ /** Item vendável pela oferta: disponível e com preço da loja em centavos > 0 (sem ele a compilação grava 0, e a oferta sairia de graça). */
14
+ export const isSellable = (item) => item !== undefined && item.available && Number.isSafeInteger(item.price) && item.price > 0;
12
15
  /** Validade: início inclusivo, fim exclusivo. Com validade, um instante ilegível (o `at` ou a própria validade) não vale. */
13
16
  export function validityFailure(offer, at) {
14
17
  if (offer.validFrom === undefined && offer.validUntil === undefined)
package/dist/decide.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { assessOfferLines, assessor, offerGate } from './assess.js';
2
2
  import { anyLineMatches, indexItems, itemKey, resolveLine, viewCart } from './cart.js';
3
- import { conditionsFailure } from './conditions.js';
3
+ import { conditionsFailure, isSellable } from './conditions.js';
4
4
  import { placementOffers } from './effective.js';
5
5
  import { assignExperiment, inHoldout } from './experiments.js';
6
6
  import { classify } from './kind.js';
@@ -17,17 +17,23 @@ function openSlots(ctx) {
17
17
  const taken = ctx.accepted.filter((assessed) => assessed.placement === ctx.placement.key).length;
18
18
  return Math.max(0, slotsFor(ctx.placement, ctx.view) - taken);
19
19
  }
20
- /** Item vendável pela oferta: disponível e com preço da loja (sem ele a compilação grava 0, e a oferta sairia de graça). */
21
- const sellable = (item) => item !== undefined && item.available && item.price > 0;
20
+ /**
21
+ * O tipo pela linha escolhida do mesmo produto. A troca só mira linha escolhida de fato: a que já é troca (vista como o
22
+ * item que saiu) não tem linha no carrinho para a ponte trocar de novo.
23
+ */
24
+ function classifyOffer(ctx, offer) {
25
+ const pool = offer.action === 'replace' ? ctx.view.chosen.filter((line) => !line.swapped) : ctx.view.chosen;
26
+ return classify(resolveLine(ctx.items, { item: offer.item, quantity: 1 }), pool);
27
+ }
22
28
  function candidate(ctx, offer) {
23
29
  if (offerGate(offer, ctx.at))
24
30
  return null;
25
31
  const item = ctx.items.get(itemKey(offer.item));
26
- if (!sellable(item) || ctx.view.keys.has(itemKey(offer.item)))
32
+ if (!isSellable(item) || ctx.view.keys.has(itemKey(offer.item)))
27
33
  return null;
28
34
  if (conditionsFailure(offer.conditions, ctx.view, 'decide'))
29
35
  return null;
30
- const classification = classify(resolveLine(ctx.items, { item: offer.item, quantity: 1 }), ctx.view.chosen);
36
+ const classification = classifyOffer(ctx, offer);
31
37
  if (offer.action === 'replace' && !classification.target)
32
38
  return null;
33
39
  return { offer, item, classification };
@@ -54,23 +60,26 @@ function groupingFor(ctx) {
54
60
  const groupings = ctx.variant?.groupings ?? ctx.placement.groupings ?? [];
55
61
  return groupings.find((grouping) => anyLineMatches(grouping.when, ctx.view.chosen))?.presentation ?? null;
56
62
  }
63
+ /** A linha com tudo o que muda a decisão (troca, posição, produto, preço), sem depender da ordem do carrinho. */
64
+ function lineKey(line) {
65
+ const replaced = line.replacedItem ? itemKey(line.replacedItem) : '';
66
+ return JSON.stringify([itemKey(line.item), line.quantity, line.unitPrice ?? null, line.offerId ?? '', line.placement ?? '', replaced, line.storeProductId ?? '', line.gift ? 1 : 0]);
67
+ }
57
68
  /**
58
- * Mesmo documento, carrinho, visitante e resposta = mesmo id (ADR-0083 §1, princípio 3). O instante não entra: duas
69
+ * Mesma pergunta e mesma resposta = mesmo id (ADR-0083 §1, princípio 3); resposta diferente, outro (pacote 0.3.2: o
70
+ * hash leva a resposta inteira, com preço, alvo da troca e conteúdo de cada oferta). O instante não entra: duas
59
71
  * perguntas iguais seguidas são a mesma decisão. A ordem das linhas também não.
60
72
  */
61
- function decisionIdOf(input, version, offerIds, experiments) {
62
- const cart = input.cart
63
- .map((line) => [itemKey(line.item), line.quantity, line.offerId ?? '', line.gift ? 1 : 0].join('*'))
64
- .sort();
65
- const canonical = JSON.stringify([version, input.placement, input.visitorId ?? '', cart, offerIds, experiments]);
66
- return `dec_${sha1Hex(canonical).slice(0, 24)}`;
73
+ function withDecisionId(input, version, answer) {
74
+ const cart = input.cart.map(lineKey).sort();
75
+ const decisionId = `dec_${sha1Hex(JSON.stringify([version, input.placement, input.visitorId ?? '', cart, answer])).slice(0, 24)}`;
76
+ return { ...answer, decisionId, offers: answer.offers.map((offer) => ({ ...offer, decisionId })) };
67
77
  }
68
- function toDecided(ctx, pick, slot, decisionId) {
78
+ function toDecided(ctx, pick, slot) {
69
79
  const { offer, item, classification } = pick;
70
80
  const price = offerUnitPrice(offer.price, item.price);
71
81
  return {
72
82
  offerId: offer.id,
73
- decisionId,
74
83
  placement: ctx.placement.key,
75
84
  slot,
76
85
  kind: classification.kind,
@@ -95,8 +104,7 @@ function placementPresentation(ctx) {
95
104
  * grupo de fora geral responde vazio com `holdout: true`.
96
105
  */
97
106
  function emptyDecision(doc, input, status, holdout = false) {
98
- const decisionId = decisionIdOf(input, doc.version, [], []);
99
- return { status, version: doc.version, placement: input.placement, decisionId, offers: [], presentation: null, grouping: null, experiments: [], holdout };
107
+ return withDecisionId(input, doc.version, { status, version: doc.version, placement: input.placement, offers: [], presentation: null, grouping: null, experiments: [], holdout });
100
108
  }
101
109
  /**
102
110
  * As ofertas que valem para esta posição e este carrinho, com preço, tipo calculado e o teste sorteado. Respeita
@@ -118,16 +126,14 @@ export function decide(doc, input) {
118
126
  const ctx = { doc, items, view, accepted: assessOfferLines(assess), placement, variant: assignment?.variant ?? null, at: input.at };
119
127
  const picks = pickOffers(ctx);
120
128
  const experiments = assignment ? [{ key: assignment.experiment.key, variant: assignment.variant.key }] : [];
121
- const decisionId = decisionIdOf(input, doc.version, picks.map((pick) => pick.offer.id), experiments);
122
- return {
129
+ return withDecisionId(input, doc.version, {
123
130
  status: 'ok',
124
131
  version: doc.version,
125
132
  placement: placement.key,
126
- decisionId,
127
- offers: picks.map((pick, index) => toDecided(ctx, pick, index + 1, decisionId)),
133
+ offers: picks.map((pick, index) => toDecided(ctx, pick, index + 1)),
128
134
  presentation: placementPresentation(ctx),
129
135
  grouping: groupingFor(ctx),
130
136
  experiments,
131
137
  holdout: false,
132
- };
138
+ });
133
139
  }
package/dist/io.d.ts CHANGED
@@ -2,8 +2,13 @@ import type { Cents, ItemRef, OfferAction, OfferContent } from './types.js';
2
2
  /** Uma linha do carrinho como a ponte a vê. */
3
3
  export interface CartLine {
4
4
  item: ItemRef;
5
+ /** Inteiro ≥ 1; fora disso a linha não é gatilho nem entra no subtotal, e a linha de oferta volta `invalid_line`. */
5
6
  quantity: number;
6
- /** O que a loja cobra por unidade agora; sem ele vale o preço do item no documento (0 se ele não estiver lá). */
7
+ /**
8
+ * O que a loja cobra por unidade agora (centavos inteiros ≥ 0; fora disso, como `quantity`). Teto do preço da linha
9
+ * de oferta. No subtotal só vale para item **fora** do documento: o item listado conta pelo preço do documento, o
10
+ * mesmo que o item trocado (que já saiu do carrinho) tem, para o `decide` e o `price` somarem igual.
11
+ */
7
12
  unitPrice?: Cents;
8
13
  /**
9
14
  * Produto da loja da linha, para o item que o documento não lista (o produto que a cliente escolheu). Na linha de
@@ -15,9 +20,10 @@ export interface CartLine {
15
20
  /** A posição de onde a oferta aceita veio (o `placement` da `DecidedOffer`); desempata o id repetido em duas posições. */
16
21
  placement?: string;
17
22
  /**
18
- * Na ação `replace`: o item que a oferta trocou. `price()` confere a condição da própria troca como se ele ainda
19
- * estivesse lá; para as outras ofertas, a linha de troca conta como o produto que a cliente leva (gatilho, subtotal,
20
- * vaga extra, combo).
23
+ * Na ação `replace`: o item que a oferta trocou. O motor avalia o **carrinho de antes da troca** (pacote 0.3.2): para
24
+ * todas as ofertas, a própria e as outras, a linha de troca volta a ser este item, com a quantidade dela (gatilho,
25
+ * `cart_has_none`, subtotal pelo preço do documento, vaga extra, combo). A linha em si ocupa a vaga da posição dela.
26
+ * Só é troca quando a oferta `replace` com o `offerId` está na posição da linha (a dela, ou a da regra de posição).
21
27
  */
22
28
  replacedItem?: ItemRef;
23
29
  /** Brinde: não é escolha da cliente nem entra no subtotal. Com `offerId`, segue linha de oferta e ocupa a vaga. */
@@ -57,7 +63,10 @@ export interface DecidedOffer {
57
63
  pricePerDay: Cents | null;
58
64
  currency: string;
59
65
  action: OfferAction;
60
- /** O item que a cliente leva e a oferta troca, na ação `replace`. */
66
+ /**
67
+ * O item que a cliente leva e a oferta troca, na ação `replace`: a linha escolhida do mesmo produto (nunca uma que já
68
+ * é troca) de menor duração, e no empate a de menor chave de item, sem depender da ordem das linhas.
69
+ */
61
70
  replaces: ItemRef | null;
62
71
  content: OfferContent;
63
72
  presentation: string | null;
@@ -68,7 +77,11 @@ export interface Decision {
68
77
  status: ResultStatus;
69
78
  version: number;
70
79
  placement: string;
71
- /** Mesmo documento, carrinho, visitante e resposta = mesmo id. Sai também sem oferta (para o `exposed`). */
80
+ /**
81
+ * sha1 da versão, posição, visitante, carrinho (sem ordem) e da **resposta inteira** (ofertas com preço, alvo da
82
+ * troca, conteúdo; apresentação, combo, teste): mesma pergunta e mesma resposta = mesmo id; resposta diferente, outro.
83
+ * Sai também sem oferta (para o `exposed`).
84
+ */
72
85
  decisionId: string;
73
86
  offers: DecidedOffer[];
74
87
  /** Apresentação padrão da posição (já com a variante do teste). */
@@ -105,13 +118,17 @@ export type IneligibleReason = 'unknown_offer' | 'inactive' | 'item_mismatch' |
105
118
  /** O `at` (ou a validade da oferta) não é um instante legível, e a oferta tem validade. */
106
119
  | 'invalid_instant'
107
120
  /** O documento é de um formato mais novo que o pacote: toda linha volta com este motivo. */
108
- | 'unsupported_schema';
121
+ | 'unsupported_schema'
122
+ /** A linha tem `quantity` que não é inteiro ≥ 1, ou `unitPrice` que não é centavo inteiro ≥ 0 (pacote 0.3.2). */
123
+ | 'invalid_line';
109
124
  export type PricedLine = {
110
125
  index: number;
111
126
  offerId: string;
112
127
  status: 'ok';
113
128
  unitPrice: Cents;
114
129
  compareAtPrice: Cents | null;
130
+ /** Quantas unidades da linha levam o `unitPrice` (o `maxQuantity` da oferta); o resto vai a preço cheio. */
131
+ discountedQuantity: number;
115
132
  } | {
116
133
  index: number;
117
134
  offerId: string;
package/dist/kind.d.ts CHANGED
@@ -9,7 +9,8 @@ export interface Classification {
9
9
  }
10
10
  /**
11
11
  * Tipo calculado, nunca cadastrado (ADR-0083 §3; a mesma regra do `offerKindExpr` da análise): upsell quando o carrinho
12
- * escolhido já tem o mesmo produto, cross-sell quando é outro. A direção sai da duração no catálogo.
12
+ * escolhido já tem o mesmo produto, cross-sell quando é outro. A direção sai da duração no catálogo. Com mais de uma
13
+ * linha do mesmo produto, o alvo (o que a `replace` troca) é a de menor duração, e no empate a de menor chave de item.
13
14
  */
14
15
  export declare function classify(offered: ResolvedLine, chosen: ResolvedLine[]): Classification;
15
16
  /**
package/dist/kind.js CHANGED
@@ -6,12 +6,20 @@ function directionOf(offered, target) {
6
6
  return null;
7
7
  return offered.durationDays > target.durationDays ? 'up' : 'down';
8
8
  }
9
+ /** Menor duração primeiro (sem duração por último), depois a chave do item: o alvo não depende da ordem das linhas. */
10
+ function byTarget(a, b) {
11
+ const byDuration = (a.durationDays ?? Number.MAX_SAFE_INTEGER) - (b.durationDays ?? Number.MAX_SAFE_INTEGER);
12
+ if (byDuration !== 0)
13
+ return byDuration;
14
+ return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
15
+ }
9
16
  /**
10
17
  * Tipo calculado, nunca cadastrado (ADR-0083 §3; a mesma regra do `offerKindExpr` da análise): upsell quando o carrinho
11
- * escolhido já tem o mesmo produto, cross-sell quando é outro. A direção sai da duração no catálogo.
18
+ * escolhido já tem o mesmo produto, cross-sell quando é outro. A direção sai da duração no catálogo. Com mais de uma
19
+ * linha do mesmo produto, o alvo (o que a `replace` troca) é a de menor duração, e no empate a de menor chave de item.
12
20
  */
13
21
  export function classify(offered, chosen) {
14
- const target = chosen.find((line) => sameProduct(offered, line)) ?? null;
22
+ const target = chosen.filter((line) => sameProduct(offered, line)).sort(byTarget)[0] ?? null;
15
23
  if (!target)
16
24
  return { kind: 'cross_sell', direction: null, target: null };
17
25
  return { kind: 'upsell', direction: directionOf(offered, target), target };
@@ -22,8 +30,8 @@ function triggerLines(items, offer) {
22
30
  const matches = offer.conditions.flatMap((condition) => (condition.type === 'cart_has_any' ? [condition.match] : []));
23
31
  return matches.flatMap((match) => [
24
32
  ...(match.items ?? []).map((item) => resolveLine(items, { item, quantity: 1 })),
25
- ...(match.storeProductIds ?? []).map((id) => ({ line: BLANK_LINE, key: '', storeProductId: id, catalogProductIds: [], durationDays: null })),
26
- ...(match.catalogProductIds ?? []).map((id) => ({ line: BLANK_LINE, key: '', storeProductId: null, catalogProductIds: [id], durationDays: null })),
33
+ ...(match.storeProductIds ?? []).map((id) => ({ line: BLANK_LINE, key: '', storeProductId: id, catalogProductIds: [], durationDays: null, swapped: false })),
34
+ ...(match.catalogProductIds ?? []).map((id) => ({ line: BLANK_LINE, key: '', storeProductId: null, catalogProductIds: [id], durationDays: null, swapped: false })),
27
35
  ]);
28
36
  }
29
37
  /**
package/dist/money.d.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import type { Cents, OfferPrice } from './types.js';
2
- /** O preço é de um tipo que o pacote sabe calcular, com número no campo do valor. */
2
+ /** O preço é de um tipo que o pacote sabe calcular, com inteiro no campo do valor (centavos; pontos-base até 100%). */
3
3
  export declare function isKnownPrice(price: OfferPrice): boolean;
4
+ /**
5
+ * Quantas unidades da linha levam o preço da oferta: o `maxQuantity` da oferta; sem ele, **1** quando a oferta sai de
6
+ * graça (brinde) e a linha toda nos outros casos. O resto da linha vai a preço cheio.
7
+ */
8
+ export declare function discountedQuantity(quantity: number, maxQuantity: number | undefined, offerPrice: Cents): number;
4
9
  /**
5
10
  * Preço da oferta por unidade, a partir do preço da loja. O desconto percentual é arredondado meio para cima (15% de
6
11
  * 79,90 = 11,985 → 11,99, cobra 67,91). A oferta é **teto**, nunca acréscimo: o resultado fica entre 0 e o preço da
package/dist/money.js CHANGED
@@ -2,14 +2,23 @@
2
2
  function divideHalfUp(numerator, denominator) {
3
3
  return Math.floor((2 * numerator + denominator) / (2 * denominator));
4
4
  }
5
- /** O preço é de um tipo que o pacote sabe calcular, com número no campo do valor. */
5
+ const isWhole = (value, max = Number.MAX_SAFE_INTEGER) => Number.isSafeInteger(value) && value >= 0 && value <= max;
6
+ /** O preço é de um tipo que o pacote sabe calcular, com inteiro no campo do valor (centavos; pontos-base até 100%). */
6
7
  export function isKnownPrice(price) {
7
8
  if (price.type === 'percent_off')
8
- return Number.isFinite(price.basisPoints);
9
+ return isWhole(price.basisPoints, 10_000);
9
10
  if (price.type === 'fixed' || price.type === 'amount_off')
10
- return Number.isFinite(price.amount);
11
+ return isWhole(price.amount);
11
12
  return false;
12
13
  }
14
+ /**
15
+ * Quantas unidades da linha levam o preço da oferta: o `maxQuantity` da oferta; sem ele, **1** quando a oferta sai de
16
+ * graça (brinde) e a linha toda nos outros casos. O resto da linha vai a preço cheio.
17
+ */
18
+ export function discountedQuantity(quantity, maxQuantity, offerPrice) {
19
+ const cap = maxQuantity ?? (offerPrice === 0 ? 1 : quantity);
20
+ return Math.min(quantity, cap);
21
+ }
13
22
  /**
14
23
  * Preço da oferta por unidade, a partir do preço da loja. O desconto percentual é arredondado meio para cima (15% de
15
24
  * 79,90 = 11,985 → 11,99, cobra 67,91). A oferta é **teto**, nunca acréscimo: o resultado fica entre 0 e o preço da
package/dist/price.d.ts CHANGED
@@ -3,8 +3,9 @@ import type { OfferDocument } from './types.js';
3
3
  /**
4
4
  * O preço de cada linha de oferta aceita, **conferindo as condições de novo** com o carrinho real (ADR-0083 §1,
5
5
  * princípio 2, e §6). Linha que perdeu a condição volta `ineligible` com o motivo, e a ponte escolhe remover ou cobrar
6
- * o preço cheio. A reconferência usa só o que a cliente escolheu (linha de oferta não sustenta outra; a de troca conta
7
- * como o produto que ela leva) e as vagas da posição, na ordem de prioridade. Documento de formato mais novo que o
6
+ * o preço cheio. A reconferência usa o carrinho de antes da troca, o mesmo do `decide` (linha de oferta não sustenta
7
+ * outra; a de troca volta a ser o item trocado) e as vagas da posição, na ordem de prioridade. Cada linha `ok` diz em
8
+ * `discountedQuantity` quantas unidades levam o preço da oferta (`maxQuantity`); o resto vai a preço cheio. Documento de formato mais novo que o
8
9
  * pacote não é precificado: toda linha volta `unsupported_schema`; visitante do grupo de fora geral, `holdout`. A
9
10
  * variante do teste é sorteada de novo pelo visitante (com as mesmas `assignments`), a mesma que o `decide` deu. O
10
11
  * "produto que esconde" (`cart_has_none`) não é conferido aqui: esconder não desfaz a oferta já aceita (app ADR 0025).
package/dist/price.js CHANGED
@@ -16,8 +16,9 @@ function refuseAll(cart, reason) {
16
16
  /**
17
17
  * O preço de cada linha de oferta aceita, **conferindo as condições de novo** com o carrinho real (ADR-0083 §1,
18
18
  * princípio 2, e §6). Linha que perdeu a condição volta `ineligible` com o motivo, e a ponte escolhe remover ou cobrar
19
- * o preço cheio. A reconferência usa só o que a cliente escolheu (linha de oferta não sustenta outra; a de troca conta
20
- * como o produto que ela leva) e as vagas da posição, na ordem de prioridade. Documento de formato mais novo que o
19
+ * o preço cheio. A reconferência usa o carrinho de antes da troca, o mesmo do `decide` (linha de oferta não sustenta
20
+ * outra; a de troca volta a ser o item trocado) e as vagas da posição, na ordem de prioridade. Cada linha `ok` diz em
21
+ * `discountedQuantity` quantas unidades levam o preço da oferta (`maxQuantity`); o resto vai a preço cheio. Documento de formato mais novo que o
21
22
  * pacote não é precificado: toda linha volta `unsupported_schema`; visitante do grupo de fora geral, `holdout`. A
22
23
  * variante do teste é sorteada de novo pelo visitante (com as mesmas `assignments`), a mesma que o `decide` deu. O
23
24
  * "produto que esconde" (`cart_has_none`) não é conferido aqui: esconder não desfaz a oferta já aceita (app ADR 0025).
@@ -4,5 +4,6 @@
4
4
  * precificado: a resposta diz `unsupported_schema` e a ponte sabe que precisa atualizar o pacote, em vez de cobrar
5
5
  * com uma regra que não conhece.
6
6
  */
7
- export declare const SUPPORTED_SCHEMA_VERSION = 1;
7
+ /** 2 = pacote 0.3.2: brinde vale 1 unidade por padrão e `maxQuantity` limita as unidades com desconto. */
8
+ export declare const SUPPORTED_SCHEMA_VERSION = 2;
8
9
  export declare function isSupportedSchema(schemaVersion: number): boolean;
@@ -4,7 +4,8 @@
4
4
  * precificado: a resposta diz `unsupported_schema` e a ponte sabe que precisa atualizar o pacote, em vez de cobrar
5
5
  * com uma regra que não conhece.
6
6
  */
7
- export const SUPPORTED_SCHEMA_VERSION = 1;
7
+ /** 2 = pacote 0.3.2: brinde vale 1 unidade por padrão e `maxQuantity` limita as unidades com desconto. */
8
+ export const SUPPORTED_SCHEMA_VERSION = 2;
8
9
  export function isSupportedSchema(schemaVersion) {
9
10
  return Number.isInteger(schemaVersion) && schemaVersion >= 1 && schemaVersion <= SUPPORTED_SCHEMA_VERSION;
10
11
  }
package/dist/types.d.ts CHANGED
@@ -82,6 +82,12 @@ export interface Offer {
82
82
  /** Nome do visual que a ponte implementa; sem ele vale o da posição. */
83
83
  presentation?: string;
84
84
  conditions: OfferCondition[];
85
+ /**
86
+ * Quantas unidades da linha levam o preço da oferta (inteiro ≥ 1, pacote 0.3.2); o resto vai a preço cheio. Ausente:
87
+ * **1** quando o preço da oferta dá 0 (brinde), a linha toda nos outros casos. O `price` devolve a conta em
88
+ * `discountedQuantity`.
89
+ */
90
+ maxQuantity?: number;
85
91
  /** Instantes ISO com offset; início inclusivo, fim exclusivo. */
86
92
  validFrom?: string;
87
93
  validUntil?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zolve/offers",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Motor de ofertas headless (ADR-0083): tipos do documento publicado, decide() e price(), sorteio A/B do PostHog. Sem dependências de runtime, para caber numa Shopify Function",
5
5
  "type": "module",
6
6
  "sideEffects": false,