@zolve/offers 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,6 +19,32 @@ const priced = price(doc, { cart, visitorId, at });
19
19
  // priced.lines[i]: { status: 'ok', unitPrice } | { status: 'ineligible', reason }
20
20
  ```
21
21
 
22
+ `offerKind(doc, offer)` (0.2.0) dá o tipo e a direção de uma oferta cadastrada sem carrinho, com os gatilhos de
23
+ `cart_has_any` no papel do carrinho escolhido: é o que o editor do Commerce OS mostra.
24
+
25
+ `applyVariant(offer, variant)` (0.3.0) devolve a oferta como uma variante de teste a mostra (campo ausente na mudança
26
+ fica como no documento; o conteúdo mescla). É a regra do `decide`/`price`, e a do "aplicar vencedor" no Commerce OS.
27
+
28
+ ## Testes A/B e entrada congelada (0.3.0)
29
+
30
+ O `decide` devolve em `experiments` o teste e a variante em que o visitante caiu (`[{ key, variant }]`). A ponte guarda
31
+ isso (atributo `_cos_exp` do carrinho/cookie) e manda de volta em `assignments`, no `decide` e no `price`:
32
+
33
+ ```ts
34
+ decide(doc, { placement: 'cart', cart, visitorId, assignments: [{ key: 'trimag-preco', variant: 'barato' }], at });
35
+ price(doc, { cart, visitorId, assignments, at });
36
+ ```
37
+
38
+ - Teste sem congelar: o sorteio de sempre, pelo `visitorId`. As `assignments` não mudam nada (o sorteio é
39
+ determinístico e dá a mesma variante).
40
+ - Teste com `entryFrozen: true`: o sorteio é o mesmo, mas só vale para quem traz a atribuição que ele dá. Quem não
41
+ 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:
42
+ uma que o sorteio não confirma (outra variante, outro teste, visitante fora do rollout) é ignorada, então um cookie
43
+ forjado não leva ninguém à variante mais barata.
44
+ - Pacote anterior à 0.3.0 ignora `entryFrozen` e segue pondo gente nova nas variantes. Não cobra errado (o preço da
45
+ variante é um que já aceitamos vender), por isso o campo **não** subiu o `schemaVersion`; só a medição perde o
46
+ congelamento até a ponte atualizar.
47
+
22
48
  `decide` respeita condições, item disponível e fora do carrinho, vagas (e a vaga condicional), prioridade, um teste A/B
23
49
  por posição (sorteio do PostHog, em duas etapas) e o grupo de fora geral. `price` devolve a mesma variante de teste que
24
50
  o `decide` deu ao visitante.
package/dist/decide.js CHANGED
@@ -112,7 +112,7 @@ export function decide(doc, input) {
112
112
  const placement = doc.placements.find((candidatePlacement) => candidatePlacement.key === input.placement);
113
113
  if (!placement)
114
114
  return emptyDecision(doc, input, 'ok');
115
- const assignment = assignExperiment(doc, placement.key, input.visitorId);
115
+ const assignment = assignExperiment(doc, placement.key, input);
116
116
  const items = indexItems(doc);
117
117
  const ctx = { doc, items, view: viewCart(items, input.cart), placement, variant: assignment?.variant ?? null, at: input.at };
118
118
  const picks = pickOffers(ctx);
@@ -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
@@ -21,9 +21,12 @@ export interface DecideInput {
21
21
  cart: CartLine[];
22
22
  /** Sem visitante não há sorteio: ele nunca entra em teste. */
23
23
  visitorId?: string;
24
+ /** As atribuições que a ponte guardou (o `experiments` de decisões anteriores): mantêm quem já entrou num teste congelado. */
25
+ assignments?: ExperimentAssignment[];
24
26
  /** Instante ISO com offset. O pacote nunca lê o relógio: a decisão é determinística. */
25
27
  at: string;
26
28
  }
29
+ /** Teste e variante em que o visitante caiu: sai no `decide` e volta da ponte em `assignments`. */
27
30
  export interface ExperimentAssignment {
28
31
  key: string;
29
32
  variant: string;
@@ -71,6 +74,8 @@ export interface PriceInput {
71
74
  cart: CartLine[];
72
75
  /** O mesmo do `decide`: a variante do teste é sorteada de novo por ele. */
73
76
  visitorId?: string;
77
+ /** As mesmas do `decide`: sem elas, o teste congelado cobra o documento sem mudança. */
78
+ assignments?: ExperimentAssignment[];
74
79
  at: string;
75
80
  }
76
81
  export type IneligibleReason = 'unknown_offer' | 'inactive' | 'item_mismatch' | 'not_started' | 'expired' | 'trigger_missing' | 'subtotal_out_of_range'
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/price.d.ts CHANGED
@@ -4,7 +4,7 @@ import type { OfferDocument } from './types.js';
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
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
7
+ * A variante do teste é sorteada de novo pelo visitante (com as mesmas `assignments`), a mesma que o `decide` deu. O "produto que
8
8
  * esconde" (`cart_has_none`) não é conferido aqui: esconder não desfaz a oferta já aceita (app ADR 0025).
9
9
  */
10
10
  export declare function price(doc: OfferDocument, input: PriceInput): PriceResult;
package/dist/price.js CHANGED
@@ -48,7 +48,7 @@ function priceLine(ctx, line, index) {
48
48
  const found = findOffer(ctx.doc, line);
49
49
  if (!found)
50
50
  return { index, offerId, status: 'ineligible', reason: 'unknown_offer' };
51
- const assignment = assignExperiment(ctx.doc, found.placement, ctx.input.visitorId);
51
+ const assignment = assignExperiment(ctx.doc, found.placement, ctx.input);
52
52
  const offer = applyVariant(found, assignment?.variant ?? null);
53
53
  const reason = lineFailure(ctx, offer, index);
54
54
  if (reason)
@@ -62,7 +62,7 @@ function priceLine(ctx, line, index) {
62
62
  * O preço de cada linha de oferta aceita, **conferindo as condições de novo** com o carrinho real (ADR-0083 §1,
63
63
  * princípio 2, e §6). Linha que perdeu a condição volta `ineligible` com o motivo, e a ponte escolhe remover ou cobrar
64
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
65
+ * A variante do teste é sorteada de novo pelo visitante (com as mesmas `assignments`), a mesma que o `decide` deu. O "produto que
66
66
  * esconde" (`cart_has_none`) não é conferido aqui: esconder não desfaz a oferta já aceita (app ADR 0025).
67
67
  */
68
68
  export function price(doc, input) {
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.0",
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,