@zolve/offers
v0.3.3
Published
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
Readme
@zolve/offers
Motor de ofertas headless do Commerce OS (ADR-0083). O Commerce OS publica um documento de ofertas; a API e a ponte de cada loja rodam o mesmo código com ele, e a resposta é a mesma (avaliação remota ou local).
- Sem dependências de runtime, ESM, sem
node:cryptonemTextEncoder: cabe numa Shopify Function. - Determinístico: o pacote não lê relógio nem sorteia ao acaso. O instante entra pela chamada.
- Dinheiro em centavos inteiros na moeda do documento.
import { decide, price, SUPPORTED_SCHEMA_VERSION } from '@zolve/offers';
// Vitrine: que ofertas mostrar nesta posição, a que preço.
const decision = decide(doc, { placement: 'cart', cart, visitorId, at: new Date().toISOString() });
// decision.status: 'ok' | 'unsupported_schema'; decision.offers[i]: offerId, decisionId, kind, price, action, …
// Cobrança: o preço de cada linha aceita (com offerId), conferindo as condições de novo com o carrinho real.
const priced = price(doc, { cart, visitorId, at });
// priced.lines[i]: { status: 'ok', unitPrice, compareAtPrice, discountedQuantity } | { status: 'ineligible', reason }unitPrice é por unidade e vale para discountedQuantity unidades da linha (0.3.2); o resto da linha vai a preço
cheio. compareAtPrice é o preço da loja no documento, quando maior que o da oferta (o mesmo do decide).
offerKind(doc, offer) (0.2.0) dá o tipo e a direção de uma oferta cadastrada sem carrinho, com os gatilhos de
cart_has_any no papel do carrinho escolhido: é o que o editor do Commerce OS mostra.
applyVariant(offer, variant) (0.3.0) devolve a oferta como uma variante de teste a mostra (campo ausente na mudança
fica como no documento; o conteúdo mescla). É a regra do decide/price, e a do "aplicar vencedor" no Commerce OS.
Testes A/B e entrada congelada (0.3.0)
O decide devolve em experiments o teste e a variante em que o visitante caiu ([{ key, variant }]). A ponte guarda
isso (atributo _cos_exp do carrinho/cookie) e manda de volta em assignments, no decide e no price:
decide(doc, { placement: 'cart', cart, visitorId, assignments: [{ key: 'trimag-preco', variant: 'barato' }], at });
price(doc, { cart, visitorId, assignments, at });- Teste sem congelar: o sorteio de sempre, pelo
visitorId. Asassignmentsnão mudam nada (o sorteio é determinístico e dá a mesma variante). - Teste com
entryFrozen: true: o sorteio é o mesmo, mas só vale para quem traz a atribuição que ele dá. Quem não 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: uma que o sorteio não confirma (outra variante, outro teste, visitante fora do rollout) é ignorada, então um cookie forjado não leva ninguém à variante mais barata. - Pacote anterior à 0.3.0 ignora
entryFrozene segue pondo gente nova nas variantes. Não cobra errado (o preço da variante é um que já aceitamos vender), por isso o campo não subiu oschemaVersion; só a medição perde o congelamento até a ponte atualizar.
decide respeita condições, item disponível e fora do carrinho, vagas (e a vaga condicional), prioridade, um teste A/B
por posição (sorteio do PostHog, em duas etapas) e o grupo de fora geral. price devolve a mesma variante de teste que
o decide deu ao visitante.
Regras de cobrança (0.3.1)
- Grupo de fora geral (ADR-0047): ligado, quem cai nele não vê oferta. O
decidedevolveoffers: []eholdout: true(a ponte manda oexposeddo controle geral); opricerecusa as linhas dele comholdout. SemvisitorId,holdouté semprefalse. - Só o que a cliente escolheu é gatilho, subtotal, vaga extra e combo, no
decidee na reconferência doprice: linha de oferta aceita não sustenta outra. A exceção é a linha de troca (offerId+replacedItemde uma ofertareplacedo documento, item trocado do mesmo produto), que desde a 0.3.2 conta como o item trocado para todas as ofertas (ver abaixo). OreplacedItemfora dedoc.itemsé reconhecido pelostoreProductIdda linha (a troca é no mesmo produto da loja). Só conta como escolhida a troca cuja ofertareplaceestá ligada (com a variante do teste do visitante), dentro da validade noate de tipo que o pacote conhece; senão é linha de oferta comum e não sustenta outra oferta. - Vagas na cobrança: o
pricenão cobra mais ofertas por posição do que as vagas (com a extra), na ordem de prioridade; o excesso, e a mesma oferta numa segunda linha da posição ou o mesmo item numa segunda linha (de qualquer posição, desde a 0.3.3), voltano_slot. - Linha de oferta é toda linha com
offerId, brinde (gift: true) ou não: ocupa a vaga da posição dela nodecidee disputa a vaga noprice. Ogiftsó a tira do gatilho e do subtotal. - Posição de cada linha de oferta, uma regra só para o
decide(vagas já tomadas) e oprice(cobrança): vale oplacementda linha quando a oferta tem aquele id naquela posição; sem ele (ou com outro), a primeira posição, na ordem do documento, em que a oferta é elegível; nenhuma elegível, a primeira. Linha recusada ainda ocupa a vaga nodecide(a ponte pode cobrá-la cheia); id que o documento não tem não ocupa vaga. - Vitrine e cobrança concordam: para o mesmo documento e carrinho, toda oferta que o
decidemostra e a cliente aceita (e, desde a 0.3.2, toda combinação delas, com troca, vaga extra e combo) saioknoprice, ao preço mostrado (é um teste de propriedade do pacote). Para isso a linha acrescentada leva oplacementdaDecidedOffer: com o id em duas posições e sem ele, a regra acima pode atribuir a linha à outra posição. visitorIdnoprice: a ponte manda sempre o mesmovisitorId(e as mesmasassignments) dodecide. Sem ele opricenão sorteia teste nem confere o grupo de fora geral: cobra o documento sem variante, e quem estava no grupo de fora leva a oferta.
Um carrinho só e quantidade com teto (0.3.2)
- O carrinho de antes da troca, no
decidee noprice: a linha de troca válida volta a ser o item trocado (replacedItem), com a quantidade da linha, como linha escolhida, para tudo: gatilho,cart_has_none, subtotal, 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 posição dela. Assim opricevê exatamente o carrinho que odecideviu: a troca LP → 90 não fecha a vaga extra 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 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 troca conta como o produto que a cliente leva"). - Subtotal pelo preço do documento: toda linha escolhida de item listado em
doc.itemsentra no subtotal pelo preço do documento; ounitPriceda linha só vale para item fora dele (0 sem nenhum dos dois). O item trocado já 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 igual. Item trocado fora do documento conta 0 no subtotal depois da troca (a compilação deve listar os itens que uma troca pode tirar). OunitPricesegue sendo teto do preço da linha de oferta. - Troca só na posição dela: a linha é troca quando a oferta
replacecom aqueleofferIdé a da posição da linha (oplacementdela, ou a atribuída pela regra de posição abaixo), ligada, na validade, de tipo conhecido, do mesmo item, e oreplacedItemé do mesmo produto. O mesmo id comoaddemcartereplaceemproduct_page: a linha decartcomreplacedItemforjado éadde não sustenta outra oferta. - Alvo da troca sem depender da ordem: com mais de uma linha escolhida do mesmo produto, o
replacesé a de menor duração no catálogo e, no empate (ou sem duração), a de menor chave de item (source:variantId). Linha que já é troca não é alvo de outra troca. maxQuantity(campo opcional da oferta, inteiro ≥ 1): quantas unidades da linha levam o preço da oferta. Ausente: 1 quando o preço da oferta dá 0 (brinde), a linha toda nos outros casos. OpricedevolvediscountedQuantitypor linhaok; a ponte aplica o desconto só nessas unidades (na Shopify, o desconto de linha com quantidade limitada) e o resto vai a preço cheio.maxQuantityque não é inteiro ≥ 1 torna a ofertaunsupported_offer. Como a 0.3.2 cobra diferente da 0.3.1 com o mesmo documento (o brinde passa a valer uma unidade mesmo sem o campo), o formato subiu paraschemaVersion2: uma ponte ainda na 0.3.1 recebeunsupported_schemae não cobra, em vez de dar o desconto na linha toda.- Linha validada:
quantityinteiro ≥ 1 eunitPrice(se vier) centavos inteiros ≥ 0. Linha fora disso não é gatilho nem entra no subtotal; a linha de oferta voltainvalid_line. O pacote nunca devolveNaN, negativo ou fração; preço de oferta com valor que não é inteiro também éunsupported_offer. decisionId= sha1 da versão, posição, visitante, carrinho (cada linha com item, quantidade,unitPrice,offerId,placement,replacedItem,storeProductId,gift, sem a ordem) e da resposta inteira (cada oferta com preço, alvo da troca, conteúdo e vaga; apresentação, combo, teste,holdout). Mesma pergunta e mesma resposta = mesmo id; resposta diferente, outro id. Os ids mudam em relação à 0.3.1.
Troca confirmada e item numa linha só (0.3.3)
Correções de cobrança do pacote; o formato do documento não muda (schemaVersion segue 2).
- Só conta como o item trocado a troca que o
pricecobraok. O documento traz todas as variantes do produto e oreplacedItemvem da cliente: antes, a linhareplacecontava como o item trocado para as outras ofertas mesmo recusada, e umreplacedItemcom a variante mais cara (Intense 90 dizendo que trocou a 180) destravava o brinde por subtotal. Agora a troca precisa passar nas próprias condições no carrinho de antes dela, ganhar a vaga e oreplacedItemtem de ser o alvo que odecidedaria (a linha escolhida do mesmo produto de menor duração; no empate, a de menor chave de item). Fora disso a troca voltatrigger_missing(alvo errado) ou o motivo dela, e a linha é linha de oferta comum: não sustenta gatilho, subtotal, vaga extra nem combo de outra oferta. Efeito na ponte: uma troca recusada agora pode levar junto as ofertas que dependiam do item trocado (o brinde por valor, o cross-sell com gatilho nele); antes elas seguiamok. - O mesmo item vale uma linha só no carrinho, em qualquer posição. O
decidejá não mostrava item que o carrinho tem; opricedava vaga ao mesmo brinde emcarte emcheckout. A segunda linha do item, na ordem de prioridade (depois a do documento e a do carrinho), voltano_slot.
Motivos de ineligible: unknown_offer, inactive, item_mismatch, not_started, expired, trigger_missing,
subtotal_out_of_range, unsupported_schema e, desde a 0.3.1:
| Motivo | Quando |
|---|---|
| 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) |
| holdout | Visitante no grupo de fora geral |
| no_slot | A posição já tem as vagas tomadas por ofertas de prioridade maior, ou a oferta/item já vale em outra linha |
| unsupported_offer | Condição, preço ou ação de tipo que o pacote não conhece (o decide também não mostra) |
| invalid_instant | Oferta com validade e at (ou a validade) ilegível |
| invalid_line (0.3.2) | Linha com quantity que não é inteiro ≥ 1 ou unitPrice que não é centavo inteiro ≥ 0 |
Documento com schemaVersion maior que SUPPORTED_SCHEMA_VERSION não é decidido nem precificado: a resposta vem com
status: 'unsupported_schema' (e toda linha do price inelegível com esse motivo). Atualize o pacote.
O schema Zod que valida o documento na publicação fica no Commerce OS (@commerce-os/contracts), fora deste pacote.
