@zolve/offers 0.3.0 → 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,9 +16,12 @@ 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 } | { 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 `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`).
24
+
22
25
  `offerKind(doc, offer)` (0.2.0) dá o tipo e a direção de uma oferta cadastrada sem carrinho, com os gatilhos de
23
26
  `cart_has_any` no papel do carrinho escolhido: é o que o editor do Commerce OS mostra.
24
27
 
@@ -49,6 +52,83 @@ price(doc, { cart, visitorId, assignments, at });
49
52
  por posição (sorteio do PostHog, em duas etapas) e o grupo de fora geral. `price` devolve a mesma variante de teste que
50
53
  o `decide` deu ao visitante.
51
54
 
55
+ ## Regras de cobrança (0.3.1)
56
+
57
+ - **Grupo de fora geral** (ADR-0047): ligado, quem cai nele não vê oferta. O `decide` devolve `offers: []` e
58
+ `holdout: true` (a ponte manda o `exposed` do controle geral); o `price` recusa as linhas dele com `holdout`. Sem
59
+ `visitorId`, `holdout` é sempre `false`.
60
+ - **Só o que a cliente escolheu** é gatilho, subtotal, vaga extra e combo, no `decide` e na reconferência do `price`:
61
+ linha de oferta aceita não sustenta outra. A exceção é a **linha de troca** (`offerId` + `replacedItem` de uma oferta
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
64
+ mesmo produto da loja). Só conta como escolhida a troca cuja oferta `replace` está ligada (com a variante do
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
66
+ sustenta outra oferta.
67
+ - **Vagas na cobrança**: o `price` não cobra mais ofertas por posição do que as vagas (com a extra), na ordem de
68
+ prioridade; o excesso, e a mesma oferta ou o mesmo item numa segunda linha, volta `no_slot`.
69
+ - **Linha de oferta é toda linha com `offerId`, brinde (`gift: true`) ou não**: ocupa a vaga da posição dela no
70
+ `decide` e disputa a vaga no `price`. O `gift` só a tira do gatilho e do subtotal.
71
+ - **Posição de cada linha de oferta**, uma regra só para o `decide` (vagas já tomadas) e o `price` (cobrança): vale o
72
+ `placement` da linha quando a oferta tem aquele id naquela posição; sem ele (ou com outro), a primeira posição, na
73
+ ordem do documento, em que a oferta é elegível; nenhuma elegível, a primeira. Linha recusada ainda ocupa a vaga no
74
+ `decide` (a ponte pode cobrá-la cheia); id que o documento não tem não ocupa vaga.
75
+ - **Vitrine e cobrança concordam**: para o mesmo documento e carrinho, toda oferta que o `decide` mostra e a cliente
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
78
+ `placement` da `DecidedOffer`: com o id em duas posições e sem ele, a regra acima pode atribuir a linha à outra
79
+ posição.
80
+ - **`visitorId` no `price`**: a ponte manda sempre o mesmo `visitorId` (e as mesmas `assignments`) do `decide`. Sem
81
+ ele o `price` não sorteia teste nem confere o grupo de fora geral: cobra o documento sem variante, e quem estava no
82
+ grupo de fora leva a oferta.
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
+
120
+ Motivos de `ineligible`: `unknown_offer`, `inactive`, `item_mismatch`, `not_started`, `expired`, `trigger_missing`,
121
+ `subtotal_out_of_range`, `unsupported_schema` e, desde a 0.3.1:
122
+
123
+ | Motivo | Quando |
124
+ |---|---|
125
+ | `unavailable` | Item oferecido indisponível ou com preço da loja 0 no documento (nunca sai de graça por falta de preço; brinde a R$ 0 é `fixed` 0 com preço da loja) |
126
+ | `holdout` | Visitante no grupo de fora geral |
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 |
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) |
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 |
131
+
52
132
  Documento com `schemaVersion` maior que `SUPPORTED_SCHEMA_VERSION` não é decidido nem precificado: a resposta vem com
53
133
  `status: 'unsupported_schema'` (e toda linha do `price` inelegível com esse motivo). Atualize o pacote.
54
134
 
@@ -0,0 +1,33 @@
1
+ import { type ItemIndex, type SwapCheck } from './cart.js';
2
+ import type { IneligibleReason, PricedLine, PriceInput } from './io.js';
3
+ import type { SlotClaim } from './slots.js';
4
+ import type { Offer, OfferDocument } from './types.js';
5
+ /**
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. `placed` é a oferta
8
+ * do documento a que cada linha de oferta foi atribuída (a regra de posição), e `isSwap` sai dela.
9
+ */
10
+ export interface Assessor {
11
+ doc: OfferDocument;
12
+ items: ItemIndex;
13
+ input: PriceInput;
14
+ isSwap: SwapCheck;
15
+ placed: Map<number, Offer>;
16
+ }
17
+ /** Uma linha de oferta avaliada: a posição a que ela foi atribuída e, se passou, a oferta que disputa a vaga. */
18
+ export interface Assessed {
19
+ priced: PricedLine;
20
+ claim: SlotClaim | null;
21
+ /** Nula só para o id que o documento não tem. */
22
+ placement: string | null;
23
+ }
24
+ export declare const refused: (index: number, offerId: string, reason: IneligibleReason) => PricedLine;
25
+ /** O portão da oferta em si, sem carrinho: tipo que o pacote conhece, ligada e dentro da validade. */
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
+ */
31
+ export declare function assessor(doc: OfferDocument, items: ItemIndex, input: PriceInput): Assessor;
32
+ /** Toda linha com `offerId`, brinde ou não, na ordem do carrinho: é linha de oferta, e disputa a vaga da posição dela. */
33
+ export declare function assessOfferLines(ctx: Assessor): Assessed[];
package/dist/assess.js ADDED
@@ -0,0 +1,141 @@
1
+ import { isSameProductSwap, isValidLine, itemKey, viewCart } from './cart.js';
2
+ import { conditionsFailure, isSellable, isSupportedOffer, validityFailure } from './conditions.js';
3
+ import { applyVariant } from './effective.js';
4
+ import { assignExperiment } from './experiments.js';
5
+ import { compareAtPrice, discountedQuantity, offerUnitPrice } from './money.js';
6
+ export const refused = (index, offerId, reason) => ({ index, offerId, status: 'ineligible', reason });
7
+ /** A oferta do documento como o visitante a vê: com a variante do teste da posição dela. */
8
+ function liveOffer(doc, found, input) {
9
+ return applyVariant(found, assignExperiment(doc, found.placement, input)?.variant ?? null);
10
+ }
11
+ /** O portão da oferta em si, sem carrinho: tipo que o pacote conhece, ligada e dentro da validade. */
12
+ export function offerGate(offer, at) {
13
+ if (!isSupportedOffer(offer))
14
+ return 'unsupported_offer';
15
+ if (!offer.active)
16
+ return 'inactive';
17
+ return validityFailure(offer, at);
18
+ }
19
+ /**
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
+ */
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;
30
+ }
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);
44
+ }
45
+ /**
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.
49
+ */
50
+ function recheckView(ctx, offer, index) {
51
+ const line = ctx.input.cart[index];
52
+ if (offer.action === 'add')
53
+ return viewCart(ctx.items, ctx.input.cart, ctx.isSwap, index);
54
+ if (!isSameProductSwap(ctx.items, line))
55
+ return null;
56
+ return viewCart(ctx.items, ctx.input.cart, (position) => position === index || ctx.isSwap(position));
57
+ }
58
+ function lineFailure(ctx, offer, index) {
59
+ const line = ctx.input.cart[index];
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;
65
+ const item = ctx.items.get(itemKey(offer.item));
66
+ if (!item)
67
+ return 'unknown_offer';
68
+ if (itemKey(line.item) !== itemKey(offer.item))
69
+ return 'item_mismatch';
70
+ if (!isSellable(item))
71
+ return 'unavailable';
72
+ if (gate)
73
+ return gate;
74
+ const view = recheckView(ctx, offer, index);
75
+ if (!view)
76
+ return 'trigger_missing';
77
+ const failure = conditionsFailure(offer.conditions, view, 'price');
78
+ return failure === 'excluded' ? null : failure;
79
+ }
80
+ /** A oferta do documento nesta posição, com a variante do teste do visitante, contra esta linha. */
81
+ function assessOffer(ctx, found, index) {
82
+ const line = ctx.input.cart[index];
83
+ const offerId = found.id;
84
+ const offer = liveOffer(ctx.doc, found, ctx.input);
85
+ const reason = lineFailure(ctx, offer, index);
86
+ if (reason)
87
+ return { priced: refused(index, offerId, reason), claim: null, placement: found.placement };
88
+ const storePrice = ctx.items.get(itemKey(offer.item)).price;
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
+ };
99
+ return { priced, claim: { index, offer, order: ctx.doc.offers.indexOf(found) }, placement: found.placement };
100
+ }
101
+ /**
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
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
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`).
106
+ */
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 };
132
+ }
133
+ /** Toda linha com `offerId`, brinde ou não, na ordem do carrinho: é linha de oferta, e disputa a vaga da posição dela. */
134
+ export function assessOfferLines(ctx) {
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
+ });
141
+ }
package/dist/cart.d.ts CHANGED
@@ -8,23 +8,36 @@ 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 é só o que a cliente escolheu: brinde e linha de oferta aceita ficam
14
- * fora, senão a oferta aceita vira gatilho da próxima ("a fila que não acaba", app ADR 0006). `keys` é o carrinho
15
- * inteiro: não se oferece o 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.
16
21
  */
17
22
  export interface CartView {
18
23
  chosen: ResolvedLine[];
19
- accepted: ResolvedLine[];
20
24
  keys: Set<string>;
21
- /** Mercadoria sem brinde, linhas de oferta inclusive. */
25
+ /** Mercadoria escolhida (as linhas de `chosen`), sem brinde nem linha de oferta. */
22
26
  subtotal: Cents;
23
27
  }
24
28
  export type ItemIndex = Map<string, OfferItem>;
25
29
  export declare function indexItems(doc: OfferDocument): ItemIndex;
26
- export declare function resolveLine(items: ItemIndex, line: CartLine): ResolvedLine;
27
- export declare function viewCart(items: ItemIndex, lines: CartLine[]): CartView;
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;
33
+ /** O item que a troca tirou, como linha; fora do documento, ele é do produto da loja da linha (a troca é no mesmo produto). */
34
+ export declare function replacedLine(line: CartLine): CartLine | null;
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. */
36
+ export declare function isSameProductSwap(items: ItemIndex, line: CartLine): boolean;
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;
28
41
  export declare function lineMatches(match: ProductMatch, line: ResolvedLine): boolean;
29
42
  export declare function anyLineMatches(match: ProductMatch, lines: ResolvedLine[]): boolean;
30
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,20 +11,57 @@ 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
- export function viewCart(items, lines) {
21
- const resolved = lines.map((line) => resolveLine(items, line));
22
- return {
23
- chosen: resolved.filter(({ line }) => !line.gift && !line.offerId),
24
- accepted: resolved.filter(({ line }) => !line.gift && line.offerId),
25
- keys: new Set(resolved.map(({ key }) => key)),
26
- subtotal: lines.filter((line) => !line.gift).reduce((sum, line) => sum + lineTotal(items, line), 0),
27
- };
31
+ /** O item que a troca tirou, como linha; fora do documento, ele é do produto da loja da linha (a troca é no mesmo produto). */
32
+ export function replacedLine(line) {
33
+ if (!line.replacedItem)
34
+ return null;
35
+ const product = line.storeProductId === undefined ? {} : { storeProductId: line.storeProductId };
36
+ return { item: line.replacedItem, quantity: line.quantity, ...product };
37
+ }
38
+ /** A linha diz que trocou um item do mesmo produto pelo ofertado. Só a forma: se a troca ainda vale é o `price` que diz. */
39
+ export function isSameProductSwap(items, line) {
40
+ const replaced = replacedLine(line);
41
+ return replaced !== null && sameProduct(resolveLine(items, replaced), resolveLine(items, line));
42
+ }
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) };
28
65
  }
29
66
  export function lineMatches(match, line) {
30
67
  if (match.items?.some((ref) => itemKey(ref) === line.key))
@@ -1,9 +1,17 @@
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
- export declare function validityFailure(offer: Offer, at: string): 'not_started' | 'expired' | null;
6
+ /**
7
+ * O pacote conhece todos os tipos da oferta (condições, preço, ação). Documento publicado para um pacote mais novo
8
+ * pode trazer um tipo que este não sabe avaliar: a oferta não vale, em vez de passar ou cobrar `NaN`.
9
+ */
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;
13
+ /** Validade: início inclusivo, fim exclusivo. Com validade, um instante ilegível (o `at` ou a própria validade) não vale. */
14
+ export declare function validityFailure(offer: Offer, at: string): 'not_started' | 'expired' | 'invalid_instant' | null;
7
15
  /**
8
16
  * Toda oferta tem uma condição implícita: a cliente escolheu alguma coisa. A oferta "sempre" só aparece porque há
9
17
  * carrinho; sozinha (ou só com brinde) ela é o promocional se sustentando em nada (app, `cart-bump-orphan.test.js`).
@@ -1,11 +1,29 @@
1
1
  import { anyLineMatches } from './cart.js';
2
+ import { isKnownPrice } from './money.js';
3
+ const CONDITION_TYPES = new Set(['cart_has_any', 'cart_has_none', 'subtotal_between']);
4
+ const ACTIONS = new Set(['add', 'replace']);
5
+ /**
6
+ * O pacote conhece todos os tipos da oferta (condições, preço, ação). Documento publicado para um pacote mais novo
7
+ * pode trazer um tipo que este não sabe avaliar: a oferta não vale, em vez de passar ou cobrar `NaN`.
8
+ */
9
+ export function isSupportedOffer(offer) {
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));
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;
15
+ /** Validade: início inclusivo, fim exclusivo. Com validade, um instante ilegível (o `at` ou a própria validade) não vale. */
2
16
  export function validityFailure(offer, at) {
17
+ if (offer.validFrom === undefined && offer.validUntil === undefined)
18
+ return null;
3
19
  const now = Date.parse(at);
4
- if (offer.validFrom !== undefined && now < Date.parse(offer.validFrom))
20
+ const from = offer.validFrom === undefined ? -Infinity : Date.parse(offer.validFrom);
21
+ const until = offer.validUntil === undefined ? Infinity : Date.parse(offer.validUntil);
22
+ if ([now, from, until].some(Number.isNaN))
23
+ return 'invalid_instant';
24
+ if (now < from)
5
25
  return 'not_started';
6
- if (offer.validUntil !== undefined && now >= Date.parse(offer.validUntil))
7
- return 'expired';
8
- return null;
26
+ return now >= until ? 'expired' : null;
9
27
  }
10
28
  function conditionFailure(condition, view, mode) {
11
29
  if (condition.type === 'cart_has_any')
@@ -13,6 +31,8 @@ function conditionFailure(condition, view, mode) {
13
31
  if (condition.type === 'cart_has_none') {
14
32
  return mode === 'price' || !anyLineMatches(condition.match, view.chosen) ? null : 'excluded';
15
33
  }
34
+ if (condition.type !== 'subtotal_between')
35
+ return 'unsupported_offer';
16
36
  const { min, max } = condition;
17
37
  const inRange = (min === undefined || view.subtotal >= min) && (max === undefined || view.subtotal <= max);
18
38
  return inRange ? null : 'subtotal_out_of_range';
package/dist/decide.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { Decision, DecideInput } from './io.js';
2
2
  import type { OfferDocument } from './types.js';
3
3
  /**
4
4
  * As ofertas que valem para esta posição e este carrinho, com preço, tipo calculado e o teste sorteado. Respeita
5
- * condições, item disponível e fora do carrinho, vagas (e a vaga condicional), prioridade, um teste por posição e o
6
- * grupo de fora geral. Determinística: não lê relógio nem sorteia ao acaso.
5
+ * condições, item disponível e fora do carrinho, vagas (e a vaga condicional), prioridade e um teste por posição.
6
+ * Quem cai no grupo de fora geral (ADR-0047) não vê oferta nenhuma. Determinística: não lê relógio nem sorteia ao acaso.
7
7
  */
8
8
  export declare function decide(doc: OfferDocument, input: DecideInput): Decision;
package/dist/decide.js CHANGED
@@ -1,37 +1,39 @@
1
+ import { assessOfferLines, assessor, offerGate } from './assess.js';
1
2
  import { anyLineMatches, indexItems, itemKey, resolveLine, viewCart } from './cart.js';
2
- import { conditionsFailure, validityFailure } from './conditions.js';
3
+ import { conditionsFailure, isSellable } from './conditions.js';
3
4
  import { placementOffers } from './effective.js';
4
- import { assignExperiment } from './experiments.js';
5
+ import { assignExperiment, inHoldout } from './experiments.js';
5
6
  import { classify } from './kind.js';
6
7
  import { compareAtPrice, offerUnitPrice, pricePerDay } from './money.js';
7
8
  import { isSupportedSchema } from './schema-version.js';
8
9
  import { sha1Hex } from './sha1.js';
9
- /** Vagas deste carrinho: as da posição, mais a extra quando o carrinho escolhido casa (a 2ª vaga presa a produtos). */
10
- function slotsFor(placement, view) {
11
- const extra = placement.extraSlot && anyLineMatches(placement.extraSlot.when, view.chosen) ? 1 : 0;
12
- return placement.slots + extra;
13
- }
14
- /** A posição de onde veio uma linha aceita: a que ela declara, ou a da oferta com aquele id. */
15
- function acceptedPlacement(doc, line) {
16
- return line.placement ?? doc.offers.find((offer) => offer.id === line.offerId)?.placement;
17
- }
10
+ import { slotsFor } from './slots.js';
18
11
  /**
19
- * Vagas abertas: as do carrinho menos as ofertas já aceitas nesta posição (app ADR 0017). Aceitar uma e ver outra
20
- * nascer no lugar é a fila que não acaba; com duas vagas e uma aceita, fica a que já estava ao lado.
12
+ * Vagas abertas: as do carrinho menos as linhas de oferta atribuídas a esta posição (app ADR 0017), pela mesma regra
13
+ * do `price` (`assess.ts`). Aceitar uma e ver outra nascer no lugar é a fila que não acaba; com duas vagas e uma aceita,
14
+ * fica a que já estava ao lado. A linha que o `price` recusa ainda ocupa a vaga: a ponte pode cobrá-la cheia.
21
15
  */
22
16
  function openSlots(ctx) {
23
- const accepted = ctx.view.accepted.filter(({ line }) => acceptedPlacement(ctx.doc, line) === ctx.placement.key).length;
24
- return Math.max(0, slotsFor(ctx.placement, ctx.view) - accepted);
17
+ const taken = ctx.accepted.filter((assessed) => assessed.placement === ctx.placement.key).length;
18
+ return Math.max(0, slotsFor(ctx.placement, ctx.view) - taken);
19
+ }
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);
25
27
  }
26
28
  function candidate(ctx, offer) {
27
- if (!offer.active || validityFailure(offer, ctx.at))
29
+ if (offerGate(offer, ctx.at))
28
30
  return null;
29
31
  const item = ctx.items.get(itemKey(offer.item));
30
- if (!item?.available || ctx.view.keys.has(itemKey(offer.item)))
32
+ if (!isSellable(item) || ctx.view.keys.has(itemKey(offer.item)))
31
33
  return null;
32
34
  if (conditionsFailure(offer.conditions, ctx.view, 'decide'))
33
35
  return null;
34
- const classification = classify(resolveLine(ctx.items, { item: offer.item, quantity: 1 }), ctx.view.chosen);
36
+ const classification = classifyOffer(ctx, offer);
35
37
  if (offer.action === 'replace' && !classification.target)
36
38
  return null;
37
39
  return { offer, item, classification };
@@ -58,23 +60,26 @@ function groupingFor(ctx) {
58
60
  const groupings = ctx.variant?.groupings ?? ctx.placement.groupings ?? [];
59
61
  return groupings.find((grouping) => anyLineMatches(grouping.when, ctx.view.chosen))?.presentation ?? null;
60
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
+ }
61
68
  /**
62
- * 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
63
71
  * perguntas iguais seguidas são a mesma decisão. A ordem das linhas também não.
64
72
  */
65
- function decisionIdOf(input, version, offerIds, experiments) {
66
- const cart = input.cart
67
- .map((line) => [itemKey(line.item), line.quantity, line.offerId ?? '', line.gift ? 1 : 0].join('*'))
68
- .sort();
69
- const canonical = JSON.stringify([version, input.placement, input.visitorId ?? '', cart, offerIds, experiments]);
70
- 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 })) };
71
77
  }
72
- function toDecided(ctx, pick, slot, decisionId) {
78
+ function toDecided(ctx, pick, slot) {
73
79
  const { offer, item, classification } = pick;
74
80
  const price = offerUnitPrice(offer.price, item.price);
75
81
  return {
76
82
  offerId: offer.id,
77
- decisionId,
78
83
  placement: ctx.placement.key,
79
84
  slot,
80
85
  kind: classification.kind,
@@ -95,37 +100,40 @@ function placementPresentation(ctx) {
95
100
  }
96
101
  /**
97
102
  * Posição desconhecida responde vazio (a ponte ainda manda o `exposed`), nunca erro. Documento de formato mais novo
98
- * também responde vazio, com `status: 'unsupported_schema'`, para a ponte saber que precisa atualizar o pacote.
103
+ * também responde vazio, com `status: 'unsupported_schema'`, para a ponte saber que precisa atualizar o pacote. O
104
+ * grupo de fora geral responde vazio com `holdout: true`.
99
105
  */
100
- function emptyDecision(doc, input, status) {
101
- const decisionId = decisionIdOf(input, doc.version, [], []);
102
- return { status, version: doc.version, placement: input.placement, decisionId, offers: [], presentation: null, grouping: null, experiments: [] };
106
+ function emptyDecision(doc, input, status, holdout = false) {
107
+ return withDecisionId(input, doc.version, { status, version: doc.version, placement: input.placement, offers: [], presentation: null, grouping: null, experiments: [], holdout });
103
108
  }
104
109
  /**
105
110
  * As ofertas que valem para esta posição e este carrinho, com preço, tipo calculado e o teste sorteado. Respeita
106
- * condições, item disponível e fora do carrinho, vagas (e a vaga condicional), prioridade, um teste por posição e o
107
- * grupo de fora geral. Determinística: não lê relógio nem sorteia ao acaso.
111
+ * condições, item disponível e fora do carrinho, vagas (e a vaga condicional), prioridade e um teste por posição.
112
+ * Quem cai no grupo de fora geral (ADR-0047) não vê oferta nenhuma. Determinística: não lê relógio nem sorteia ao acaso.
108
113
  */
109
114
  export function decide(doc, input) {
110
115
  if (!isSupportedSchema(doc.schemaVersion))
111
116
  return emptyDecision(doc, input, 'unsupported_schema');
117
+ if (input.visitorId && inHoldout(doc.holdout, input.visitorId))
118
+ return emptyDecision(doc, input, 'ok', true);
112
119
  const placement = doc.placements.find((candidatePlacement) => candidatePlacement.key === input.placement);
113
120
  if (!placement)
114
121
  return emptyDecision(doc, input, 'ok');
115
122
  const assignment = assignExperiment(doc, placement.key, input);
116
123
  const items = indexItems(doc);
117
- const ctx = { doc, items, view: viewCart(items, input.cart), placement, variant: assignment?.variant ?? null, at: input.at };
124
+ const assess = assessor(doc, items, input);
125
+ const view = viewCart(items, input.cart, assess.isSwap);
126
+ const ctx = { doc, items, view, accepted: assessOfferLines(assess), placement, variant: assignment?.variant ?? null, at: input.at };
118
127
  const picks = pickOffers(ctx);
119
128
  const experiments = assignment ? [{ key: assignment.experiment.key, variant: assignment.variant.key }] : [];
120
- const decisionId = decisionIdOf(input, doc.version, picks.map((pick) => pick.offer.id), experiments);
121
- return {
129
+ return withDecisionId(input, doc.version, {
122
130
  status: 'ok',
123
131
  version: doc.version,
124
132
  placement: placement.key,
125
- decisionId,
126
- offers: picks.map((pick, index) => toDecided(ctx, pick, index + 1, decisionId)),
133
+ offers: picks.map((pick, index) => toDecided(ctx, pick, index + 1)),
127
134
  presentation: placementPresentation(ctx),
128
135
  grouping: groupingFor(ctx),
129
136
  experiments,
130
- };
137
+ holdout: false,
138
+ });
131
139
  }
package/dist/io.d.ts CHANGED
@@ -2,18 +2,31 @@ 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
- /** Produto da loja da linha, para o item que o documento não lista (o produto que a cliente escolheu). */
13
+ /**
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
+ * troca, vale também para o `replacedItem` fora do documento: a troca é dentro do mesmo produto.
16
+ */
9
17
  storeProductId?: string;
10
18
  /** Linha de oferta aceita: leva o `offerId` até o pedido (ADR-0083 §1, princípio 4). Não é gatilho de outra oferta. */
11
19
  offerId?: string;
12
- /** A posição de onde a oferta aceita veio; desempata o id repetido em duas posições. */
20
+ /** A posição de onde a oferta aceita veio (o `placement` da `DecidedOffer`); desempata o id repetido em duas posições. */
13
21
  placement?: string;
14
- /** Na ação `replace`: o item que a oferta trocou. `price()` confere a condição como se ele ainda estivesse lá. */
22
+ /**
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).
27
+ */
15
28
  replacedItem?: ItemRef;
16
- /** Brinde: não é escolha da cliente nem entra no subtotal. */
29
+ /** Brinde: não é escolha da cliente nem entra no subtotal. Com `offerId`, segue linha de oferta e ocupa a vaga. */
17
30
  gift?: boolean;
18
31
  }
19
32
  export interface DecideInput {
@@ -50,7 +63,10 @@ export interface DecidedOffer {
50
63
  pricePerDay: Cents | null;
51
64
  currency: string;
52
65
  action: OfferAction;
53
- /** 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
+ */
54
70
  replaces: ItemRef | null;
55
71
  content: OfferContent;
56
72
  presentation: string | null;
@@ -61,7 +77,11 @@ export interface Decision {
61
77
  status: ResultStatus;
62
78
  version: number;
63
79
  placement: string;
64
- /** 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
+ */
65
85
  decisionId: string;
66
86
  offers: DecidedOffer[];
67
87
  /** Apresentação padrão da posição (já com a variante do teste). */
@@ -69,24 +89,46 @@ export interface Decision {
69
89
  /** Apresentação de grupo (combo) quando vale para este carrinho. */
70
90
  grouping: string | null;
71
91
  experiments: ExperimentAssignment[];
92
+ /**
93
+ * O visitante caiu no grupo de fora geral (ADR-0047): nenhuma oferta, e a ponte manda o `exposed` do controle geral.
94
+ * Sem `visitorId` é sempre `false`.
95
+ */
96
+ holdout: boolean;
72
97
  }
73
98
  export interface PriceInput {
74
99
  cart: CartLine[];
75
- /** O mesmo do `decide`: a variante do teste é sorteada de novo por ele. */
100
+ /**
101
+ * Sempre o mesmo do `decide`: a variante do teste é sorteada de novo por ele, e o grupo de fora geral conferido. Sem
102
+ * ele, nem teste nem grupo de fora valem (cobra o documento sem variante).
103
+ */
76
104
  visitorId?: string;
77
105
  /** As mesmas do `decide`: sem elas, o teste congelado cobra o documento sem mudança. */
78
106
  assignments?: ExperimentAssignment[];
79
107
  at: string;
80
108
  }
81
109
  export type IneligibleReason = 'unknown_offer' | 'inactive' | 'item_mismatch' | 'not_started' | 'expired' | 'trigger_missing' | 'subtotal_out_of_range'
110
+ /** O item oferecido está indisponível ou sem preço da loja (0) no documento: nunca sai de graça por falta de preço. */
111
+ | 'unavailable'
112
+ /** O visitante está no grupo de fora geral: não leva oferta nenhuma. */
113
+ | 'holdout'
114
+ /** A posição já tem as vagas tomadas por ofertas de prioridade maior, ou a mesma oferta (ou item) em outra linha. */
115
+ | 'no_slot'
116
+ /** Condição, preço ou ação de um tipo que este pacote não conhece: atualize o pacote. */
117
+ | 'unsupported_offer'
118
+ /** O `at` (ou a validade da oferta) não é um instante legível, e a oferta tem validade. */
119
+ | 'invalid_instant'
82
120
  /** O documento é de um formato mais novo que o pacote: toda linha volta com este motivo. */
83
- | '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';
84
124
  export type PricedLine = {
85
125
  index: number;
86
126
  offerId: string;
87
127
  status: 'ok';
88
128
  unitPrice: Cents;
89
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;
90
132
  } | {
91
133
  index: number;
92
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,8 +1,16 @@
1
1
  import type { Cents, OfferPrice } from './types.js';
2
+ /** O preço é de um tipo que o pacote sabe calcular, com inteiro no campo do valor (centavos; pontos-base até 100%). */
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;
2
9
  /**
3
10
  * Preço da oferta por unidade, a partir do preço da loja. O desconto percentual é arredondado meio para cima (15% de
4
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
5
- * loja (preço fixo acima da loja cobra o da loja, desconto maior que o preço vira grátis).
12
+ * loja (preço fixo acima da loja cobra o da loja, desconto maior que o preço vira grátis). Tipo que o pacote não
13
+ * conhece não desconta: devolve o preço da loja, nunca `NaN` (o `decide`/`price` nem chegam a usar essa oferta).
6
14
  */
7
15
  export declare function offerUnitPrice(price: OfferPrice, storePrice: Cents): Cents;
8
16
  /** O preço "de": o da loja, só quando há o que riscar. */
package/dist/money.js CHANGED
@@ -2,12 +2,32 @@
2
2
  function divideHalfUp(numerator, denominator) {
3
3
  return Math.floor((2 * numerator + denominator) / (2 * denominator));
4
4
  }
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%). */
7
+ export function isKnownPrice(price) {
8
+ if (price.type === 'percent_off')
9
+ return isWhole(price.basisPoints, 10_000);
10
+ if (price.type === 'fixed' || price.type === 'amount_off')
11
+ return isWhole(price.amount);
12
+ return false;
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
+ }
5
22
  /**
6
23
  * Preço da oferta por unidade, a partir do preço da loja. O desconto percentual é arredondado meio para cima (15% de
7
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
8
- * loja (preço fixo acima da loja cobra o da loja, desconto maior que o preço vira grátis).
25
+ * loja (preço fixo acima da loja cobra o da loja, desconto maior que o preço vira grátis). Tipo que o pacote não
26
+ * conhece não desconta: devolve o preço da loja, nunca `NaN` (o `decide`/`price` nem chegam a usar essa oferta).
9
27
  */
10
28
  export function offerUnitPrice(price, storePrice) {
29
+ if (!isKnownPrice(price))
30
+ return storePrice;
11
31
  const raw = price.type === 'fixed'
12
32
  ? price.amount
13
33
  : price.type === 'amount_off'
package/dist/price.d.ts CHANGED
@@ -3,8 +3,11 @@ 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. Documento de formato mais novo que o pacote não é precificado: toda linha volta `unsupported_schema`.
7
- * A variante do teste é sorteada de novo pelo visitante (com as mesmas `assignments`), a mesma que o `decide` deu. O "produto que
8
- * esconde" (`cart_has_none`) não é conferido aqui: esconder não desfaz a oferta já aceita (app ADR 0025).
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
9
+ * pacote não é precificado: toda linha volta `unsupported_schema`; visitante do grupo de fora geral, `holdout`. A
10
+ * variante do teste é sorteada de novo pelo visitante (com as mesmas `assignments`), a mesma que o `decide` deu. O
11
+ * "produto que esconde" (`cart_has_none`) não é conferido aqui: esconder não desfaz a oferta já aceita (app ADR 0025).
9
12
  */
10
13
  export declare function price(doc: OfferDocument, input: PriceInput): PriceResult;
package/dist/price.js CHANGED
@@ -1,77 +1,34 @@
1
- import { indexItems, itemKey, resolveLine, sameProduct, viewCart } from './cart.js';
2
- import { conditionsFailure, validityFailure } from './conditions.js';
3
- import { applyVariant } from './effective.js';
4
- import { assignExperiment } from './experiments.js';
5
- import { compareAtPrice, offerUnitPrice } from './money.js';
1
+ import { assessOfferLines, assessor, refused } from './assess.js';
2
+ import { indexItems, viewCart } from './cart.js';
3
+ import { inHoldout } from './experiments.js';
6
4
  import { isSupportedSchema } from './schema-version.js';
7
- /** O id repetido em duas posições (oferta migrada do app) desempata pela posição da linha; sem ela, vale a primeira. */
8
- function findOffer(doc, line) {
9
- const same = doc.offers.filter((offer) => offer.id === line.offerId);
10
- return same.find((offer) => offer.placement === line.placement) ?? same[0];
5
+ import { slotLosers } from './slots.js';
6
+ /** As linhas que passaram disputam as vagas da posição; quem fica de fora volta `no_slot`. */
7
+ function withSlots(ctx, assessed) {
8
+ const view = viewCart(ctx.items, ctx.input.cart, ctx.isSwap);
9
+ const claims = assessed.flatMap(({ claim }) => (claim ? [claim] : []));
10
+ const losers = slotLosers(ctx.doc, view, claims);
11
+ return assessed.map(({ priced }) => (losers.has(priced.index) ? refused(priced.index, priced.offerId, 'no_slot') : priced));
11
12
  }
12
- /**
13
- * O carrinho como no instante em que a oferta apareceu: sem a própria linha (senão uma oferta por valor mínimo se
14
- * sustenta sozinha, o preço dela empurra o subtotal que ela exige) e, no `replace`, com o item trocado de volta no
15
- * lugar. Nulo quando o `replace` não diz o que trocou, ou diz um item que não é o mesmo produto.
16
- */
17
- function recheckView(ctx, offer, index) {
18
- const line = ctx.input.cart[index];
19
- const others = ctx.input.cart.filter((_, position) => position !== index);
20
- if (offer.action === 'add')
21
- return viewCart(ctx.items, others);
22
- if (!line.replacedItem)
23
- return null;
24
- const replaced = { item: line.replacedItem, quantity: line.quantity };
25
- if (!sameProduct(resolveLine(ctx.items, replaced), resolveLine(ctx.items, line)))
26
- return null;
27
- return viewCart(ctx.items, [...others, replaced]);
28
- }
29
- function lineFailure(ctx, offer, index) {
30
- const line = ctx.input.cart[index];
31
- if (!offer.active)
32
- return 'inactive';
33
- if (!ctx.items.has(itemKey(offer.item)))
34
- return 'unknown_offer';
35
- if (itemKey(line.item) !== itemKey(offer.item))
36
- return 'item_mismatch';
37
- const validity = validityFailure(offer, ctx.input.at);
38
- if (validity)
39
- return validity;
40
- const view = recheckView(ctx, offer, index);
41
- if (!view)
42
- return 'trigger_missing';
43
- const failure = conditionsFailure(offer.conditions, view, 'price');
44
- return failure === 'excluded' ? null : failure;
45
- }
46
- function priceLine(ctx, line, index) {
47
- const offerId = line.offerId;
48
- const found = findOffer(ctx.doc, line);
49
- if (!found)
50
- return { index, offerId, status: 'ineligible', reason: 'unknown_offer' };
51
- const assignment = assignExperiment(ctx.doc, found.placement, ctx.input);
52
- const offer = applyVariant(found, assignment?.variant ?? null);
53
- const reason = lineFailure(ctx, offer, index);
54
- if (reason)
55
- return { index, offerId, status: 'ineligible', reason };
56
- const storePrice = ctx.items.get(itemKey(offer.item)).price;
57
- const charged = line.unitPrice ?? storePrice;
58
- const unitPrice = Math.min(offerUnitPrice(offer.price, storePrice), charged);
59
- return { index, offerId, status: 'ok', unitPrice, compareAtPrice: compareAtPrice(charged, unitPrice) };
13
+ function refuseAll(cart, reason) {
14
+ return cart.flatMap((line, index) => (line.offerId ? [refused(index, line.offerId, reason)] : []));
60
15
  }
61
16
  /**
62
17
  * O preço de cada linha de oferta aceita, **conferindo as condições de novo** com o carrinho real (ADR-0083 §1,
63
18
  * princípio 2, e §6). Linha que perdeu a condição volta `ineligible` com o motivo, e a ponte escolhe remover ou cobrar
64
- * o preço cheio. Documento de formato mais novo que o pacote não é precificado: toda linha volta `unsupported_schema`.
65
- * A variante do teste é sorteada de novo pelo visitante (com as mesmas `assignments`), a mesma que o `decide` deu. O "produto que
66
- * esconde" (`cart_has_none`) não é conferido aqui: esconder não desfaz a oferta já aceita (app ADR 0025).
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
22
+ * pacote não é precificado: toda linha volta `unsupported_schema`; visitante do grupo de fora geral, `holdout`. A
23
+ * variante do teste é sorteada de novo pelo visitante (com as mesmas `assignments`), a mesma que o `decide` deu. O
24
+ * "produto que esconde" (`cart_has_none`) não é conferido aqui: esconder não desfaz a oferta já aceita (app ADR 0025).
67
25
  */
68
26
  export function price(doc, input) {
69
27
  const head = { version: doc.version, currency: doc.currency };
70
- if (!isSupportedSchema(doc.schemaVersion)) {
71
- const lines = input.cart.flatMap((line, index) => line.offerId ? [{ index, offerId: line.offerId, status: 'ineligible', reason: 'unsupported_schema' }] : []);
72
- return { status: 'unsupported_schema', ...head, lines };
73
- }
74
- const ctx = { doc, items: indexItems(doc), input };
75
- const lines = input.cart.flatMap((line, index) => (line.offerId ? [priceLine(ctx, line, index)] : []));
76
- return { status: 'ok', ...head, lines };
28
+ if (!isSupportedSchema(doc.schemaVersion))
29
+ return { status: 'unsupported_schema', ...head, lines: refuseAll(input.cart, 'unsupported_schema') };
30
+ if (input.visitorId && inHoldout(doc.holdout, input.visitorId))
31
+ return { status: 'ok', ...head, lines: refuseAll(input.cart, 'holdout') };
32
+ const ctx = assessor(doc, indexItems(doc), input);
33
+ return { status: 'ok', ...head, lines: withSlots(ctx, assessOfferLines(ctx)) };
77
34
  }
@@ -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
  }
@@ -0,0 +1,15 @@
1
+ import { type CartView } from './cart.js';
2
+ import type { Offer, OfferDocument, Placement } from './types.js';
3
+ /** Vagas deste carrinho: as da posição, mais a extra quando o carrinho escolhido casa (a 2ª vaga presa a produtos). */
4
+ export declare function slotsFor(placement: Placement, view: CartView): number;
5
+ /** Uma linha de oferta que passou em tudo e pede vaga: a posição no carrinho, a oferta e a ordem dela no documento. */
6
+ export interface SlotClaim {
7
+ index: number;
8
+ offer: Offer;
9
+ order: number;
10
+ }
11
+ /**
12
+ * As linhas que ficam sem vaga, a regra do `decide` na cobrança: por posição, na ordem de prioridade, até as vagas do
13
+ * carrinho (com a extra); a mesma oferta ou o mesmo item numa segunda linha não ganha outra vaga.
14
+ */
15
+ export declare function slotLosers(doc: OfferDocument, view: CartView, claims: SlotClaim[]): Set<number>;
package/dist/slots.js ADDED
@@ -0,0 +1,29 @@
1
+ import { anyLineMatches, itemKey } from './cart.js';
2
+ /** Vagas deste carrinho: as da posição, mais a extra quando o carrinho escolhido casa (a 2ª vaga presa a produtos). */
3
+ export function slotsFor(placement, view) {
4
+ const extra = placement.extraSlot && anyLineMatches(placement.extraSlot.when, view.chosen) ? 1 : 0;
5
+ return placement.slots + extra;
6
+ }
7
+ const byPriority = (a, b) => a.offer.priority - b.offer.priority || a.order - b.order || a.index - b.index;
8
+ /**
9
+ * As linhas que ficam sem vaga, a regra do `decide` na cobrança: por posição, na ordem de prioridade, até as vagas do
10
+ * carrinho (com a extra); a mesma oferta ou o mesmo item numa segunda linha não ganha outra vaga.
11
+ */
12
+ export function slotLosers(doc, view, claims) {
13
+ const losers = new Set();
14
+ const used = new Map();
15
+ const seen = new Set();
16
+ for (const claim of [...claims].sort(byPriority)) {
17
+ const key = claim.offer.placement;
18
+ const placement = doc.placements.find((candidate) => candidate.key === key);
19
+ const marks = [`${key}|offer|${claim.offer.id}`, `${key}|item|${itemKey(claim.offer.item)}`];
20
+ const taken = used.get(key) ?? 0;
21
+ if (!placement || taken >= slotsFor(placement, view) || marks.some((mark) => seen.has(mark))) {
22
+ losers.add(claim.index);
23
+ continue;
24
+ }
25
+ used.set(key, taken + 1);
26
+ marks.forEach((mark) => seen.add(mark));
27
+ }
28
+ return losers;
29
+ }
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.0",
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,