@zolve/offers 0.1.0 → 0.3.1

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,13 +16,83 @@ 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 } | { 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`).
25
+
26
+ `offerKind(doc, offer)` (0.2.0) dá o tipo e a direção de uma oferta cadastrada sem carrinho, com os gatilhos de
27
+ `cart_has_any` no papel do carrinho escolhido: é o que o editor do Commerce OS mostra.
28
+
29
+ `applyVariant(offer, variant)` (0.3.0) devolve a oferta como uma variante de teste a mostra (campo ausente na mudança
30
+ fica como no documento; o conteúdo mescla). É a regra do `decide`/`price`, e a do "aplicar vencedor" no Commerce OS.
31
+
32
+ ## Testes A/B e entrada congelada (0.3.0)
33
+
34
+ O `decide` devolve em `experiments` o teste e a variante em que o visitante caiu (`[{ key, variant }]`). A ponte guarda
35
+ isso (atributo `_cos_exp` do carrinho/cookie) e manda de volta em `assignments`, no `decide` e no `price`:
36
+
37
+ ```ts
38
+ decide(doc, { placement: 'cart', cart, visitorId, assignments: [{ key: 'trimag-preco', variant: 'barato' }], at });
39
+ price(doc, { cart, visitorId, assignments, at });
40
+ ```
41
+
42
+ - Teste sem congelar: o sorteio de sempre, pelo `visitorId`. As `assignments` não mudam nada (o sorteio é
43
+ determinístico e dá a mesma variante).
44
+ - Teste com `entryFrozen: true`: o sorteio é o mesmo, mas só vale para quem traz a atribuição que ele dá. Quem não
45
+ traz (gente nova) fica fora do teste: vê o documento sem mudança e não é contado. A atribuição não escolhe a variante:
46
+ uma que o sorteio não confirma (outra variante, outro teste, visitante fora do rollout) é ignorada, então um cookie
47
+ forjado não leva ninguém à variante mais barata.
48
+ - Pacote anterior à 0.3.0 ignora `entryFrozen` e segue pondo gente nova nas variantes. Não cobra errado (o preço da
49
+ variante é um que já aceitamos vender), por isso o campo **não** subiu o `schemaVersion`; só a medição perde o
50
+ congelamento até a ponte atualizar.
51
+
22
52
  `decide` respeita condições, item disponível e fora do carrinho, vagas (e a vaga condicional), prioridade, um teste A/B
23
53
  por posição (sorteio do PostHog, em duas etapas) e o grupo de fora geral. `price` devolve a mesma variante de teste que
24
54
  o `decide` deu ao visitante.
25
55
 
56
+ ## Regras de cobrança (0.3.1)
57
+
58
+ - **Grupo de fora geral** (ADR-0047): ligado, quem cai nele não vê oferta. O `decide` devolve `offers: []` e
59
+ `holdout: true` (a ponte manda o `exposed` do controle geral); o `price` recusa as linhas dele com `holdout`. Sem
60
+ `visitorId`, `holdout` é sempre `false`.
61
+ - **Só o que a cliente escolheu** é gatilho, subtotal, vaga extra e combo, no `decide` e na reconferência do `price`:
62
+ 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
66
+ mesmo produto da loja). Só conta como escolhida a troca cuja oferta `replace` está ligada (com a variante do
67
+ 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
+ sustenta outra oferta.
69
+ - **Vagas na cobrança**: o `price` não cobra mais ofertas por posição do que as vagas (com a extra), na ordem de
70
+ prioridade; o excesso, e a mesma oferta ou o mesmo item numa segunda linha, volta `no_slot`.
71
+ - **Linha de oferta é toda linha com `offerId`, brinde (`gift: true`) ou não**: ocupa a vaga da posição dela no
72
+ `decide` e disputa a vaga no `price`. O `gift` só a tira do gatilho e do subtotal.
73
+ - **Posição de cada linha de oferta**, uma regra só para o `decide` (vagas já tomadas) e o `price` (cobrança): vale o
74
+ `placement` da linha quando a oferta tem aquele id naquela posição; sem ele (ou com outro), a primeira posição, na
75
+ ordem do documento, em que a oferta é elegível; nenhuma elegível, a primeira. Linha recusada ainda ocupa a vaga no
76
+ `decide` (a ponte pode cobrá-la cheia); id que o documento não tem não ocupa vaga.
77
+ - **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
79
+ `placement` da `DecidedOffer`: com o id em duas posições e sem ele, a regra acima pode atribuir a linha à outra
80
+ posição.
81
+ - **`visitorId` no `price`**: a ponte manda sempre o mesmo `visitorId` (e as mesmas `assignments`) do `decide`. Sem
82
+ ele o `price` não sorteia teste nem confere o grupo de fora geral: cobra o documento sem variante, e quem estava no
83
+ grupo de fora leva a oferta.
84
+
85
+ Motivos de `ineligible`: `unknown_offer`, `inactive`, `item_mismatch`, `not_started`, `expired`, `trigger_missing`,
86
+ `subtotal_out_of_range`, `unsupported_schema` e, desde a 0.3.1:
87
+
88
+ | Motivo | Quando |
89
+ |---|---|
90
+ | `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) |
91
+ | `holdout` | Visitante no grupo de fora geral |
92
+ | `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
+ | `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
+ | `invalid_instant` | Oferta com validade e `at` (ou a validade) ilegível |
95
+
26
96
  Documento com `schemaVersion` maior que `SUPPORTED_SCHEMA_VERSION` não é decidido nem precificado: a resposta vem com
27
97
  `status: 'unsupported_schema'` (e toda linha do `price` inelegível com esse motivo). Atualize o pacote.
28
98
 
@@ -0,0 +1,27 @@
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.
8
+ */
9
+ export interface Assessor {
10
+ doc: OfferDocument;
11
+ items: ItemIndex;
12
+ input: PriceInput;
13
+ isSwap: SwapCheck;
14
+ }
15
+ /** Uma linha de oferta avaliada: a posição a que ela foi atribuída e, se passou, a oferta que disputa a vaga. */
16
+ export interface Assessed {
17
+ priced: PricedLine;
18
+ claim: SlotClaim | null;
19
+ /** Nula só para o id que o documento não tem. */
20
+ placement: string | null;
21
+ }
22
+ export declare const refused: (index: number, offerId: string, reason: IneligibleReason) => PricedLine;
23
+ /** O portão da oferta em si, sem carrinho: tipo que o pacote conhece, ligada e dentro da validade. */
24
+ export declare function offerGate(offer: Offer, at: string): IneligibleReason | null;
25
+ export declare function assessor(doc: OfferDocument, items: ItemIndex, input: PriceInput): Assessor;
26
+ /** Toda linha com `offerId`, brinde ou não, na ordem do carrinho: é linha de oferta, e disputa a vaga da posição dela. */
27
+ export declare function assessOfferLines(ctx: Assessor): Assessed[];
package/dist/assess.js ADDED
@@ -0,0 +1,99 @@
1
+ import { isSameProductSwap, itemKey, replacedLine, viewCart } from './cart.js';
2
+ import { conditionsFailure, isSupportedOffer, validityFailure } from './conditions.js';
3
+ import { applyVariant } from './effective.js';
4
+ import { assignExperiment } from './experiments.js';
5
+ import { compareAtPrice, 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
+ * 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.
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));
27
+ }
28
+ export function assessor(doc, items, input) {
29
+ return { doc, items, input, isSwap: swapCheck(doc, items, input) };
30
+ }
31
+ /**
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.
35
+ */
36
+ function recheckView(ctx, offer, index) {
37
+ const line = ctx.input.cart[index];
38
+ const others = ctx.input.cart.filter((_, position) => position !== index);
39
+ if (offer.action === 'add')
40
+ return viewCart(ctx.items, others, ctx.isSwap);
41
+ const replaced = replacedLine(line);
42
+ if (!replaced || !isSameProductSwap(ctx.items, line))
43
+ return null;
44
+ return viewCart(ctx.items, [...others, replaced], ctx.isSwap);
45
+ }
46
+ function lineFailure(ctx, offer, index) {
47
+ const line = ctx.input.cart[index];
48
+ if (!isSupportedOffer(offer))
49
+ return 'unsupported_offer';
50
+ if (!offer.active)
51
+ return 'inactive';
52
+ const item = ctx.items.get(itemKey(offer.item));
53
+ if (!item)
54
+ return 'unknown_offer';
55
+ if (itemKey(line.item) !== itemKey(offer.item))
56
+ return 'item_mismatch';
57
+ if (!item.available || item.price <= 0)
58
+ return 'unavailable';
59
+ const validity = validityFailure(offer, ctx.input.at);
60
+ if (validity)
61
+ return validity;
62
+ const view = recheckView(ctx, offer, index);
63
+ if (!view)
64
+ return 'trigger_missing';
65
+ const failure = conditionsFailure(offer.conditions, view, 'price');
66
+ return failure === 'excluded' ? null : failure;
67
+ }
68
+ /** A oferta do documento nesta posição, com a variante do teste do visitante, contra esta linha. */
69
+ function assessOffer(ctx, found, index) {
70
+ const line = ctx.input.cart[index];
71
+ const offerId = found.id;
72
+ const offer = liveOffer(ctx.doc, found, ctx.input);
73
+ const reason = lineFailure(ctx, offer, index);
74
+ if (reason)
75
+ return { priced: refused(index, offerId, reason), claim: null, placement: found.placement };
76
+ 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) };
79
+ return { priced, claim: { index, offer, order: ctx.doc.offers.indexOf(found) }, placement: found.placement };
80
+ }
81
+ /**
82
+ * 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
+ * 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
+ * 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.
85
+ */
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];
95
+ }
96
+ /** Toda linha com `offerId`, brinde ou não, na ordem do carrinho: é linha de oferta, e disputa a vaga da posição dela. */
97
+ export function assessOfferLines(ctx) {
98
+ return ctx.input.cart.flatMap((line, index) => (line.offerId ? [assessLine(ctx, line, index)] : []));
99
+ }
package/dist/cart.d.ts CHANGED
@@ -10,21 +10,28 @@ export interface ResolvedLine {
10
10
  durationDays: number | null;
11
11
  }
12
12
  /**
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.
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.
16
18
  */
17
19
  export interface CartView {
18
20
  chosen: ResolvedLine[];
19
- accepted: ResolvedLine[];
20
21
  keys: Set<string>;
21
- /** Mercadoria sem brinde, linhas de oferta inclusive. */
22
+ /** Mercadoria escolhida (as linhas de `chosen`), sem brinde nem linha de oferta. */
22
23
  subtotal: Cents;
23
24
  }
24
25
  export type ItemIndex = Map<string, OfferItem>;
25
26
  export declare function indexItems(doc: OfferDocument): ItemIndex;
26
27
  export declare function resolveLine(items: ItemIndex, line: CartLine): ResolvedLine;
27
- export declare function viewCart(items: ItemIndex, lines: CartLine[]): CartView;
28
+ /** O item que a troca tirou, como linha; fora do documento, ele é do produto da loja da linha (a troca é no mesmo produto). */
29
+ export declare function replacedLine(line: CartLine): CartLine | null;
30
+ /** 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
+ 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;
28
35
  export declare function lineMatches(match: ProductMatch, line: ResolvedLine): boolean;
29
36
  export declare function anyLineMatches(match: ProductMatch, lines: ResolvedLine[]): boolean;
30
37
  /**
package/dist/cart.js CHANGED
@@ -17,13 +17,25 @@ function lineTotal(items, line) {
17
17
  const unit = line.unitPrice ?? items.get(itemKey(line.item))?.price ?? 0;
18
18
  return unit * line.quantity;
19
19
  }
20
- export function viewCart(items, lines) {
20
+ /** O item que a troca tirou, como linha; fora do documento, ele é do produto da loja da linha (a troca é no mesmo produto). */
21
+ export function replacedLine(line) {
22
+ if (!line.replacedItem)
23
+ return null;
24
+ const product = line.storeProductId === undefined ? {} : { storeProductId: line.storeProductId };
25
+ return { item: line.replacedItem, quantity: line.quantity, ...product };
26
+ }
27
+ /** A linha diz que trocou um item do mesmo produto pelo ofertado. Só a forma: se a troca ainda vale é o `price` que diz. */
28
+ export function isSameProductSwap(items, line) {
29
+ const replaced = replacedLine(line);
30
+ return replaced !== null && sameProduct(resolveLine(items, replaced), resolveLine(items, line));
31
+ }
32
+ export function viewCart(items, lines, isSwap) {
21
33
  const resolved = lines.map((line) => resolveLine(items, line));
34
+ const chosen = resolved.filter(({ line }) => !line.gift && (!line.offerId || isSwap(line)));
22
35
  return {
23
- chosen: resolved.filter(({ line }) => !line.gift && !line.offerId),
24
- accepted: resolved.filter(({ line }) => !line.gift && line.offerId),
36
+ chosen,
25
37
  keys: new Set(resolved.map(({ key }) => key)),
26
- subtotal: lines.filter((line) => !line.gift).reduce((sum, line) => sum + lineTotal(items, line), 0),
38
+ subtotal: chosen.reduce((sum, { line }) => sum + lineTotal(items, line), 0),
27
39
  };
28
40
  }
29
41
  export function lineMatches(match, line) {
@@ -3,7 +3,13 @@ import type { IneligibleReason } from './io.js';
3
3
  import type { Offer, OfferCondition } 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
+ /** Validade: início inclusivo, fim exclusivo. Com validade, um instante ilegível (o `at` ou a própria validade) não vale. */
12
+ export declare function validityFailure(offer: Offer, at: string): 'not_started' | 'expired' | 'invalid_instant' | null;
7
13
  /**
8
14
  * Toda oferta tem uma condição implícita: a cliente escolheu alguma coisa. A oferta "sempre" só aparece porque há
9
15
  * carrinho; sozinha (ou só com brinde) ela é o promocional se sustentando em nada (app, `cart-bump-orphan.test.js`).
@@ -1,11 +1,26 @@
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
+ return isKnownPrice(offer.price) && ACTIONS.has(offer.action) && offer.conditions.every((condition) => CONDITION_TYPES.has(condition.type));
11
+ }
12
+ /** Validade: início inclusivo, fim exclusivo. Com validade, um instante ilegível (o `at` ou a própria validade) não vale. */
2
13
  export function validityFailure(offer, at) {
14
+ if (offer.validFrom === undefined && offer.validUntil === undefined)
15
+ return null;
3
16
  const now = Date.parse(at);
4
- if (offer.validFrom !== undefined && now < Date.parse(offer.validFrom))
17
+ const from = offer.validFrom === undefined ? -Infinity : Date.parse(offer.validFrom);
18
+ const until = offer.validUntil === undefined ? Infinity : Date.parse(offer.validUntil);
19
+ if ([now, from, until].some(Number.isNaN))
20
+ return 'invalid_instant';
21
+ if (now < from)
5
22
  return 'not_started';
6
- if (offer.validUntil !== undefined && now >= Date.parse(offer.validUntil))
7
- return 'expired';
8
- return null;
23
+ return now >= until ? 'expired' : null;
9
24
  }
10
25
  function conditionFailure(condition, view, mode) {
11
26
  if (condition.type === 'cart_has_any')
@@ -13,6 +28,8 @@ function conditionFailure(condition, view, mode) {
13
28
  if (condition.type === 'cart_has_none') {
14
29
  return mode === 'price' || !anyLineMatches(condition.match, view.chosen) ? null : 'excluded';
15
30
  }
31
+ if (condition.type !== 'subtotal_between')
32
+ return 'unsupported_offer';
16
33
  const { min, max } = condition;
17
34
  const inRange = (min === undefined || view.subtotal >= min) && (max === undefined || view.subtotal <= max);
18
35
  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,33 +1,29 @@
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 } 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);
25
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;
26
22
  function candidate(ctx, offer) {
27
- if (!offer.active || validityFailure(offer, ctx.at))
23
+ if (offerGate(offer, ctx.at))
28
24
  return null;
29
25
  const item = ctx.items.get(itemKey(offer.item));
30
- if (!item?.available || ctx.view.keys.has(itemKey(offer.item)))
26
+ if (!sellable(item) || ctx.view.keys.has(itemKey(offer.item)))
31
27
  return null;
32
28
  if (conditionsFailure(offer.conditions, ctx.view, 'decide'))
33
29
  return null;
@@ -95,26 +91,31 @@ function placementPresentation(ctx) {
95
91
  }
96
92
  /**
97
93
  * 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.
94
+ * também responde vazio, com `status: 'unsupported_schema'`, para a ponte saber que precisa atualizar o pacote. O
95
+ * grupo de fora geral responde vazio com `holdout: true`.
99
96
  */
100
- function emptyDecision(doc, input, status) {
97
+ function emptyDecision(doc, input, status, holdout = false) {
101
98
  const decisionId = decisionIdOf(input, doc.version, [], []);
102
- return { status, version: doc.version, placement: input.placement, decisionId, offers: [], presentation: null, grouping: null, experiments: [] };
99
+ return { status, version: doc.version, placement: input.placement, decisionId, offers: [], presentation: null, grouping: null, experiments: [], holdout };
103
100
  }
104
101
  /**
105
102
  * 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.
103
+ * condições, item disponível e fora do carrinho, vagas (e a vaga condicional), prioridade e um teste por posição.
104
+ * 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
105
  */
109
106
  export function decide(doc, input) {
110
107
  if (!isSupportedSchema(doc.schemaVersion))
111
108
  return emptyDecision(doc, input, 'unsupported_schema');
109
+ if (input.visitorId && inHoldout(doc.holdout, input.visitorId))
110
+ return emptyDecision(doc, input, 'ok', true);
112
111
  const placement = doc.placements.find((candidatePlacement) => candidatePlacement.key === input.placement);
113
112
  if (!placement)
114
113
  return emptyDecision(doc, input, 'ok');
115
- const assignment = assignExperiment(doc, placement.key, input.visitorId);
114
+ const assignment = assignExperiment(doc, placement.key, input);
116
115
  const items = indexItems(doc);
117
- const ctx = { doc, items, view: viewCart(items, input.cart), placement, variant: assignment?.variant ?? null, at: input.at };
116
+ const assess = assessor(doc, items, input);
117
+ const view = viewCart(items, input.cart, assess.isSwap);
118
+ const ctx = { doc, items, view, accepted: assessOfferLines(assess), placement, variant: assignment?.variant ?? null, at: input.at };
118
119
  const picks = pickOffers(ctx);
119
120
  const experiments = assignment ? [{ key: assignment.experiment.key, variant: assignment.variant.key }] : [];
120
121
  const decisionId = decisionIdOf(input, doc.version, picks.map((pick) => pick.offer.id), experiments);
@@ -127,5 +128,6 @@ export function decide(doc, input) {
127
128
  presentation: placementPresentation(ctx),
128
129
  grouping: groupingFor(ctx),
129
130
  experiments,
131
+ holdout: false,
130
132
  };
131
133
  }
@@ -1,3 +1,4 @@
1
+ import type { ExperimentAssignment } from './io.js';
1
2
  import type { Experiment, ExperimentVariant, Holdout, OfferDocument } from './types.js';
2
3
  /**
3
4
  * O hash do PostHog (`calculate_hash` do `feature-flags` em Rust, `_hash` do Python): `sha1(prefixo + id + sal)`, os 60
@@ -15,10 +16,20 @@ export interface Assignment {
15
16
  experiment: Experiment;
16
17
  variant: ExperimentVariant;
17
18
  }
19
+ /** Quem chega com o sorteio a fazer: o visitante e as atribuições que a ponte guardou dele. */
20
+ interface Visitor {
21
+ visitorId?: string | undefined;
22
+ assignments?: ExperimentAssignment[] | undefined;
23
+ }
18
24
  /**
19
25
  * Sorteio em duas etapas (ADR-0047, emenda de testes A/B): primeiro se o visitante entra no teste, depois em qual
20
26
  * variante, as duas com `sha1("<teste>.<visitante><sal>")` e sais diferentes (o do teste, e o do teste + `variant`,
21
27
  * como o PostHog) para as decisões não ficarem ligadas. Um teste por posição: vale o primeiro ativo. Visitante sem id
22
28
  * nunca entra; quem está no grupo de fora geral também não.
29
+ *
30
+ * Teste com a entrada congelada: o sorteio é o mesmo, mas só vale quando o visitante traz a atribuição que ele dá. A
31
+ * atribuição não escolhe a variante (um cookie forjado não leva ninguém à variante mais barata), só prova que o
32
+ * visitante já tinha entrado; quem não traz fica fora e vê o documento sem mudança.
23
33
  */
24
- export declare function assignExperiment(doc: OfferDocument, placement: string, visitorId: string | undefined): Assignment | null;
34
+ export declare function assignExperiment(doc: OfferDocument, placement: string, { visitorId, assignments }: Visitor): Assignment | null;
35
+ export {};
@@ -36,8 +36,12 @@ export function inHoldout(holdout, visitorId) {
36
36
  * variante, as duas com `sha1("<teste>.<visitante><sal>")` e sais diferentes (o do teste, e o do teste + `variant`,
37
37
  * como o PostHog) para as decisões não ficarem ligadas. Um teste por posição: vale o primeiro ativo. Visitante sem id
38
38
  * nunca entra; quem está no grupo de fora geral também não.
39
+ *
40
+ * Teste com a entrada congelada: o sorteio é o mesmo, mas só vale quando o visitante traz a atribuição que ele dá. A
41
+ * atribuição não escolhe a variante (um cookie forjado não leva ninguém à variante mais barata), só prova que o
42
+ * visitante já tinha entrado; quem não traz fica fora e vê o documento sem mudança.
39
43
  */
40
- export function assignExperiment(doc, placement, visitorId) {
44
+ export function assignExperiment(doc, placement, { visitorId, assignments = [] }) {
41
45
  if (!visitorId)
42
46
  return null;
43
47
  const experiment = doc.experiments.find((candidate) => candidate.active && candidate.placement === placement);
@@ -47,5 +51,8 @@ export function assignExperiment(doc, placement, visitorId) {
47
51
  if (!inRollout(experiment.percentage, hashBucket(`${experiment.key}.`, visitorId, salt)))
48
52
  return null;
49
53
  const variant = selectVariant(hashBucket(`${experiment.key}.`, visitorId, `${salt}variant`), experiment.variants);
50
- return variant ? { experiment, variant } : null;
54
+ if (!variant)
55
+ return null;
56
+ const entered = assignments.some((brought) => brought.key === experiment.key && brought.variant === variant.key);
57
+ return !experiment.entryFrozen || entered ? { experiment, variant } : null;
51
58
  }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,8 @@ export type * from './types.js';
2
2
  export type * from './io.js';
3
3
  export { decide } from './decide.js';
4
4
  export { price } from './price.js';
5
+ export { offerKind } from './kind.js';
6
+ export { applyVariant } from './effective.js';
5
7
  export { hashBucket, inRollout, selectVariant } from './experiments.js';
6
8
  export { compareAtPrice, offerUnitPrice, pricePerDay } from './money.js';
7
9
  export { SUPPORTED_SCHEMA_VERSION } from './schema-version.js';
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  export { decide } from './decide.js';
2
2
  export { price } from './price.js';
3
+ export { offerKind } from './kind.js';
4
+ export { applyVariant } from './effective.js';
3
5
  export { hashBucket, inRollout, selectVariant } from './experiments.js';
4
6
  export { compareAtPrice, offerUnitPrice, pricePerDay } from './money.js';
5
7
  export { SUPPORTED_SCHEMA_VERSION } from './schema-version.js';
package/dist/io.d.ts CHANGED
@@ -5,15 +5,22 @@ export interface CartLine {
5
5
  quantity: number;
6
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
7
  unitPrice?: Cents;
8
- /** Produto da loja da linha, para o item que o documento não lista (o produto que a cliente escolheu). */
8
+ /**
9
+ * Produto da loja da linha, para o item que o documento não lista (o produto que a cliente escolheu). Na linha de
10
+ * troca, vale também para o `replacedItem` fora do documento: a troca é dentro do mesmo produto.
11
+ */
9
12
  storeProductId?: string;
10
13
  /** Linha de oferta aceita: leva o `offerId` até o pedido (ADR-0083 §1, princípio 4). Não é gatilho de outra oferta. */
11
14
  offerId?: string;
12
- /** A posição de onde a oferta aceita veio; desempata o id repetido em duas posições. */
15
+ /** A posição de onde a oferta aceita veio (o `placement` da `DecidedOffer`); desempata o id repetido em duas posições. */
13
16
  placement?: string;
14
- /** Na ação `replace`: o item que a oferta trocou. `price()` confere a condição como se ele ainda estivesse lá. */
17
+ /**
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).
21
+ */
15
22
  replacedItem?: ItemRef;
16
- /** Brinde: não é escolha da cliente nem entra no subtotal. */
23
+ /** Brinde: não é escolha da cliente nem entra no subtotal. Com `offerId`, segue linha de oferta e ocupa a vaga. */
17
24
  gift?: boolean;
18
25
  }
19
26
  export interface DecideInput {
@@ -21,9 +28,12 @@ export interface DecideInput {
21
28
  cart: CartLine[];
22
29
  /** Sem visitante não há sorteio: ele nunca entra em teste. */
23
30
  visitorId?: string;
31
+ /** As atribuições que a ponte guardou (o `experiments` de decisões anteriores): mantêm quem já entrou num teste congelado. */
32
+ assignments?: ExperimentAssignment[];
24
33
  /** Instante ISO com offset. O pacote nunca lê o relógio: a decisão é determinística. */
25
34
  at: string;
26
35
  }
36
+ /** Teste e variante em que o visitante caiu: sai no `decide` e volta da ponte em `assignments`. */
27
37
  export interface ExperimentAssignment {
28
38
  key: string;
29
39
  variant: string;
@@ -66,14 +76,34 @@ export interface Decision {
66
76
  /** Apresentação de grupo (combo) quando vale para este carrinho. */
67
77
  grouping: string | null;
68
78
  experiments: ExperimentAssignment[];
79
+ /**
80
+ * O visitante caiu no grupo de fora geral (ADR-0047): nenhuma oferta, e a ponte manda o `exposed` do controle geral.
81
+ * Sem `visitorId` é sempre `false`.
82
+ */
83
+ holdout: boolean;
69
84
  }
70
85
  export interface PriceInput {
71
86
  cart: CartLine[];
72
- /** O mesmo do `decide`: a variante do teste é sorteada de novo por ele. */
87
+ /**
88
+ * Sempre o mesmo do `decide`: a variante do teste é sorteada de novo por ele, e o grupo de fora geral conferido. Sem
89
+ * ele, nem teste nem grupo de fora valem (cobra o documento sem variante).
90
+ */
73
91
  visitorId?: string;
92
+ /** As mesmas do `decide`: sem elas, o teste congelado cobra o documento sem mudança. */
93
+ assignments?: ExperimentAssignment[];
74
94
  at: string;
75
95
  }
76
96
  export type IneligibleReason = 'unknown_offer' | 'inactive' | 'item_mismatch' | 'not_started' | 'expired' | 'trigger_missing' | 'subtotal_out_of_range'
97
+ /** 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. */
98
+ | 'unavailable'
99
+ /** O visitante está no grupo de fora geral: não leva oferta nenhuma. */
100
+ | 'holdout'
101
+ /** A posição já tem as vagas tomadas por ofertas de prioridade maior, ou a mesma oferta (ou item) em outra linha. */
102
+ | 'no_slot'
103
+ /** Condição, preço ou ação de um tipo que este pacote não conhece: atualize o pacote. */
104
+ | 'unsupported_offer'
105
+ /** O `at` (ou a validade da oferta) não é um instante legível, e a oferta tem validade. */
106
+ | 'invalid_instant'
77
107
  /** O documento é de um formato mais novo que o pacote: toda linha volta com este motivo. */
78
108
  | 'unsupported_schema';
79
109
  export type PricedLine = {
package/dist/kind.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type ResolvedLine } from './cart.js';
2
2
  import type { DecidedOfferKind } from './io.js';
3
+ import type { Offer, OfferDocument } from './types.js';
3
4
  export interface Classification {
4
5
  kind: DecidedOfferKind;
5
6
  direction: 'up' | 'down' | null;
@@ -11,3 +12,11 @@ export interface Classification {
11
12
  * escolhido já tem o mesmo produto, cross-sell quando é outro. A direção sai da duração no catálogo.
12
13
  */
13
14
  export declare function classify(offered: ResolvedLine, chosen: ResolvedLine[]): Classification;
15
+ /**
16
+ * O tipo de uma oferta cadastrada, sem carrinho (o editor e a tela, ADR-0083 §3): a mesma regra do `decide()` aplicada
17
+ * aos gatilhos das condições `cart_has_any`, como se fossem o carrinho escolhido. Nulo sem gatilho para comparar.
18
+ */
19
+ export declare function offerKind(doc: OfferDocument, offer: Offer): {
20
+ kind: DecidedOfferKind;
21
+ direction: 'up' | 'down' | null;
22
+ } | null;
package/dist/kind.js CHANGED
@@ -1,4 +1,4 @@
1
- import { sameProduct } from './cart.js';
1
+ import { indexItems, resolveLine, sameProduct } from './cart.js';
2
2
  function directionOf(offered, target) {
3
3
  if (offered.durationDays === null || target.durationDays === null)
4
4
  return null;
@@ -16,3 +16,25 @@ export function classify(offered, chosen) {
16
16
  return { kind: 'cross_sell', direction: null, target: null };
17
17
  return { kind: 'upsell', direction: directionOf(offered, target), target };
18
18
  }
19
+ const BLANK_LINE = { item: { source: '', variantId: '' }, quantity: 1 };
20
+ /** Os gatilhos de `cart_has_any` como linhas: item do documento, produto da loja ou produto do catálogo. */
21
+ function triggerLines(items, offer) {
22
+ const matches = offer.conditions.flatMap((condition) => (condition.type === 'cart_has_any' ? [condition.match] : []));
23
+ return matches.flatMap((match) => [
24
+ ...(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 })),
27
+ ]);
28
+ }
29
+ /**
30
+ * O tipo de uma oferta cadastrada, sem carrinho (o editor e a tela, ADR-0083 §3): a mesma regra do `decide()` aplicada
31
+ * aos gatilhos das condições `cart_has_any`, como se fossem o carrinho escolhido. Nulo sem gatilho para comparar.
32
+ */
33
+ export function offerKind(doc, offer) {
34
+ const items = indexItems(doc);
35
+ const triggers = triggerLines(items, offer);
36
+ if (triggers.length === 0)
37
+ return null;
38
+ const { kind, direction } = classify(resolveLine(items, { item: offer.item, quantity: 1 }), triggers);
39
+ return { kind, direction };
40
+ }
package/dist/money.d.ts CHANGED
@@ -1,8 +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. */
3
+ export declare function isKnownPrice(price: OfferPrice): boolean;
2
4
  /**
3
5
  * Preço da oferta por unidade, a partir do preço da loja. O desconto percentual é arredondado meio para cima (15% de
4
6
  * 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).
7
+ * 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
8
+ * conhece não desconta: devolve o preço da loja, nunca `NaN` (o `decide`/`price` nem chegam a usar essa oferta).
6
9
  */
7
10
  export declare function offerUnitPrice(price: OfferPrice, storePrice: Cents): Cents;
8
11
  /** O preço "de": o da loja, só quando há o que riscar. */
package/dist/money.js CHANGED
@@ -2,12 +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. */
6
+ export function isKnownPrice(price) {
7
+ if (price.type === 'percent_off')
8
+ return Number.isFinite(price.basisPoints);
9
+ if (price.type === 'fixed' || price.type === 'amount_off')
10
+ return Number.isFinite(price.amount);
11
+ return false;
12
+ }
5
13
  /**
6
14
  * Preço da oferta por unidade, a partir do preço da loja. O desconto percentual é arredondado meio para cima (15% de
7
15
  * 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).
16
+ * 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
17
+ * conhece não desconta: devolve o preço da loja, nunca `NaN` (o `decide`/`price` nem chegam a usar essa oferta).
9
18
  */
10
19
  export function offerUnitPrice(price, storePrice) {
20
+ if (!isKnownPrice(price))
21
+ return storePrice;
11
22
  const raw = price.type === 'fixed'
12
23
  ? price.amount
13
24
  : price.type === 'amount_off'
package/dist/price.d.ts CHANGED
@@ -3,8 +3,10 @@ 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, 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 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
8
+ * pacote não é precificado: toda linha volta `unsupported_schema`; visitante do grupo de fora geral, `holdout`. A
9
+ * variante do teste é sorteada de novo pelo visitante (com as mesmas `assignments`), a mesma que o `decide` deu. O
10
+ * "produto que esconde" (`cart_has_none`) não é conferido aqui: esconder não desfaz a oferta já aceita (app ADR 0025).
9
11
  */
10
12
  export declare function price(doc: OfferDocument, input: PriceInput): PriceResult;
package/dist/price.js CHANGED
@@ -1,77 +1,33 @@
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.visitorId);
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, 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 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
21
+ * pacote não é precificado: toda linha volta `unsupported_schema`; visitante do grupo de fora geral, `holdout`. A
22
+ * variante do teste é sorteada de novo pelo visitante (com as mesmas `assignments`), a mesma que o `decide` deu. O
23
+ * "produto que esconde" (`cart_has_none`) não é conferido aqui: esconder não desfaz a oferta já aceita (app ADR 0025).
67
24
  */
68
25
  export function price(doc, input) {
69
26
  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 };
27
+ if (!isSupportedSchema(doc.schemaVersion))
28
+ return { status: 'unsupported_schema', ...head, lines: refuseAll(input.cart, 'unsupported_schema') };
29
+ if (input.visitorId && inHoldout(doc.holdout, input.visitorId))
30
+ return { status: 'ok', ...head, lines: refuseAll(input.cart, 'holdout') };
31
+ const ctx = assessor(doc, indexItems(doc), input);
32
+ return { status: 'ok', ...head, lines: withSlots(ctx, assessOfferLines(ctx)) };
77
33
  }
@@ -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
@@ -131,6 +131,11 @@ export interface Experiment {
131
131
  percentage: number;
132
132
  /** Sal do sorteio; vazio = igual ao PostHog. */
133
133
  salt?: string;
134
+ /**
135
+ * Entrada congelada: só segue no teste quem traz a atribuição dele (`assignments` do `decide`/`price`) e o sorteio
136
+ * confirma; gente nova vê o documento sem mudança e não é contada. Pacote anterior à 0.3.0 ignora e segue sorteando.
137
+ */
138
+ entryFrozen?: true;
134
139
  variants: ExperimentVariant[];
135
140
  }
136
141
  /** Grupo de fora geral: quem cai nele não entra em teste nenhum. Nasce desligado. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zolve/offers",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
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,