@zolve/offers 0.0.0-stage → 0.1.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
@@ -1,3 +1,29 @@
1
- # Temporary Holding Version
1
+ # @zolve/offers
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Motor de ofertas headless do Commerce OS (ADR-0083). O Commerce OS publica um **documento de ofertas**; a API e a ponte
4
+ de cada loja rodam o mesmo código com ele, e a resposta é a mesma (avaliação remota ou local).
5
+
6
+ - **Sem dependências de runtime**, ESM, sem `node:crypto` nem `TextEncoder`: cabe numa Shopify Function.
7
+ - **Determinístico**: o pacote não lê relógio nem sorteia ao acaso. O instante entra pela chamada.
8
+ - **Dinheiro em centavos inteiros** na moeda do documento.
9
+
10
+ ```ts
11
+ import { decide, price, SUPPORTED_SCHEMA_VERSION } from '@zolve/offers';
12
+
13
+ // Vitrine: que ofertas mostrar nesta posição, a que preço.
14
+ const decision = decide(doc, { placement: 'cart', cart, visitorId, at: new Date().toISOString() });
15
+ // decision.status: 'ok' | 'unsupported_schema'; decision.offers[i]: offerId, decisionId, kind, price, action, …
16
+
17
+ // Cobrança: o preço de cada linha aceita (com offerId), conferindo as condições de novo com o carrinho real.
18
+ const priced = price(doc, { cart, visitorId, at });
19
+ // priced.lines[i]: { status: 'ok', unitPrice } | { status: 'ineligible', reason }
20
+ ```
21
+
22
+ `decide` respeita condições, item disponível e fora do carrinho, vagas (e a vaga condicional), prioridade, um teste A/B
23
+ por posição (sorteio do PostHog, em duas etapas) e o grupo de fora geral. `price` devolve a mesma variante de teste que
24
+ o `decide` deu ao visitante.
25
+
26
+ Documento com `schemaVersion` maior que `SUPPORTED_SCHEMA_VERSION` não é decidido nem precificado: a resposta vem com
27
+ `status: 'unsupported_schema'` (e toda linha do `price` inelegível com esse motivo). Atualize o pacote.
28
+
29
+ O schema Zod que valida o documento na publicação fica no Commerce OS (`@commerce-os/contracts`), fora deste pacote.
package/dist/cart.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { CartLine } from './io.js';
2
+ import type { Cents, ItemRef, OfferDocument, OfferItem, ProductMatch } from './types.js';
3
+ export declare const itemKey: (ref: ItemRef) => string;
4
+ /** Linha do carrinho com o que o documento sabe do item dela. */
5
+ export interface ResolvedLine {
6
+ line: CartLine;
7
+ key: string;
8
+ storeProductId: string | null;
9
+ catalogProductIds: string[];
10
+ durationDays: number | null;
11
+ }
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.
16
+ */
17
+ export interface CartView {
18
+ chosen: ResolvedLine[];
19
+ accepted: ResolvedLine[];
20
+ keys: Set<string>;
21
+ /** Mercadoria sem brinde, linhas de oferta inclusive. */
22
+ subtotal: Cents;
23
+ }
24
+ export type ItemIndex = Map<string, OfferItem>;
25
+ 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;
28
+ export declare function lineMatches(match: ProductMatch, line: ResolvedLine): boolean;
29
+ export declare function anyLineMatches(match: ProductMatch, lines: ResolvedLine[]): boolean;
30
+ /**
31
+ * Mesmo produto (ADR-0083 §3): mesmo produto da loja **ou** algum produto do catálogo em comum (a escada 30/90/180, o
32
+ * kit que contém o produto, o produto próprio de uma LP). Sem catálogo vale só a primeira parte.
33
+ */
34
+ export declare function sameProduct(a: ResolvedLine, b: ResolvedLine): boolean;
package/dist/cart.js ADDED
@@ -0,0 +1,47 @@
1
+ export const itemKey = (ref) => `${ref.source}:${ref.variantId}`;
2
+ export function indexItems(doc) {
3
+ return new Map(doc.items.map((item) => [itemKey(item), item]));
4
+ }
5
+ export function resolveLine(items, line) {
6
+ const key = itemKey(line.item);
7
+ const item = items.get(key);
8
+ return {
9
+ line,
10
+ key,
11
+ storeProductId: item?.storeProductId ?? line.storeProductId ?? null,
12
+ catalogProductIds: item?.catalogProductIds ?? [],
13
+ durationDays: item?.durationDays ?? null,
14
+ };
15
+ }
16
+ function lineTotal(items, line) {
17
+ const unit = line.unitPrice ?? items.get(itemKey(line.item))?.price ?? 0;
18
+ return unit * line.quantity;
19
+ }
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
+ };
28
+ }
29
+ export function lineMatches(match, line) {
30
+ if (match.items?.some((ref) => itemKey(ref) === line.key))
31
+ return true;
32
+ if (line.storeProductId !== null && match.storeProductIds?.includes(line.storeProductId))
33
+ return true;
34
+ return line.catalogProductIds.some((id) => match.catalogProductIds?.includes(id) ?? false);
35
+ }
36
+ export function anyLineMatches(match, lines) {
37
+ return lines.some((line) => lineMatches(match, line));
38
+ }
39
+ /**
40
+ * Mesmo produto (ADR-0083 §3): mesmo produto da loja **ou** algum produto do catálogo em comum (a escada 30/90/180, o
41
+ * kit que contém o produto, o produto próprio de uma LP). Sem catálogo vale só a primeira parte.
42
+ */
43
+ export function sameProduct(a, b) {
44
+ if (a.storeProductId !== null && a.storeProductId === b.storeProductId)
45
+ return true;
46
+ return a.catalogProductIds.some((id) => b.catalogProductIds.includes(id));
47
+ }
@@ -0,0 +1,11 @@
1
+ import { type CartView } from './cart.js';
2
+ import type { IneligibleReason } from './io.js';
3
+ import type { Offer, OfferCondition } from './types.js';
4
+ /** `excluded` só existe no `decide`: o "produto que esconde" esconde a oferta, mas não desfaz a já aceita (app ADR 0025). */
5
+ export type GateFailure = IneligibleReason | 'excluded';
6
+ export declare function validityFailure(offer: Offer, at: string): 'not_started' | 'expired' | null;
7
+ /**
8
+ * Toda oferta tem uma condição implícita: a cliente escolheu alguma coisa. A oferta "sempre" só aparece porque há
9
+ * carrinho; sozinha (ou só com brinde) ela é o promocional se sustentando em nada (app, `cart-bump-orphan.test.js`).
10
+ */
11
+ export declare function conditionsFailure(conditions: OfferCondition[], view: CartView, mode: 'decide' | 'price'): GateFailure | null;
@@ -0,0 +1,33 @@
1
+ import { anyLineMatches } from './cart.js';
2
+ export function validityFailure(offer, at) {
3
+ const now = Date.parse(at);
4
+ if (offer.validFrom !== undefined && now < Date.parse(offer.validFrom))
5
+ return 'not_started';
6
+ if (offer.validUntil !== undefined && now >= Date.parse(offer.validUntil))
7
+ return 'expired';
8
+ return null;
9
+ }
10
+ function conditionFailure(condition, view, mode) {
11
+ if (condition.type === 'cart_has_any')
12
+ return anyLineMatches(condition.match, view.chosen) ? null : 'trigger_missing';
13
+ if (condition.type === 'cart_has_none') {
14
+ return mode === 'price' || !anyLineMatches(condition.match, view.chosen) ? null : 'excluded';
15
+ }
16
+ const { min, max } = condition;
17
+ const inRange = (min === undefined || view.subtotal >= min) && (max === undefined || view.subtotal <= max);
18
+ return inRange ? null : 'subtotal_out_of_range';
19
+ }
20
+ /**
21
+ * Toda oferta tem uma condição implícita: a cliente escolheu alguma coisa. A oferta "sempre" só aparece porque há
22
+ * carrinho; sozinha (ou só com brinde) ela é o promocional se sustentando em nada (app, `cart-bump-orphan.test.js`).
23
+ */
24
+ export function conditionsFailure(conditions, view, mode) {
25
+ if (view.chosen.length === 0)
26
+ return 'trigger_missing';
27
+ for (const condition of conditions) {
28
+ const failure = conditionFailure(condition, view, mode);
29
+ if (failure)
30
+ return failure;
31
+ }
32
+ return null;
33
+ }
@@ -0,0 +1,8 @@
1
+ import type { Decision, DecideInput } from './io.js';
2
+ import type { OfferDocument } from './types.js';
3
+ /**
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.
7
+ */
8
+ export declare function decide(doc: OfferDocument, input: DecideInput): Decision;
package/dist/decide.js ADDED
@@ -0,0 +1,131 @@
1
+ import { anyLineMatches, indexItems, itemKey, resolveLine, viewCart } from './cart.js';
2
+ import { conditionsFailure, validityFailure } from './conditions.js';
3
+ import { placementOffers } from './effective.js';
4
+ import { assignExperiment } from './experiments.js';
5
+ import { classify } from './kind.js';
6
+ import { compareAtPrice, offerUnitPrice, pricePerDay } from './money.js';
7
+ import { isSupportedSchema } from './schema-version.js';
8
+ 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
+ }
18
+ /**
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.
21
+ */
22
+ 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);
25
+ }
26
+ function candidate(ctx, offer) {
27
+ if (!offer.active || validityFailure(offer, ctx.at))
28
+ return null;
29
+ const item = ctx.items.get(itemKey(offer.item));
30
+ if (!item?.available || ctx.view.keys.has(itemKey(offer.item)))
31
+ return null;
32
+ if (conditionsFailure(offer.conditions, ctx.view, 'decide'))
33
+ return null;
34
+ const classification = classify(resolveLine(ctx.items, { item: offer.item, quantity: 1 }), ctx.view.chosen);
35
+ if (offer.action === 'replace' && !classification.target)
36
+ return null;
37
+ return { offer, item, classification };
38
+ }
39
+ /** A fila da posição: quem passa nas condições, sem repetir item, na ordem de prioridade, até fechar as vagas. */
40
+ function pickOffers(ctx) {
41
+ const picks = [];
42
+ const seen = new Set();
43
+ const open = openSlots(ctx);
44
+ for (const offer of placementOffers(ctx.doc, ctx.placement.key, ctx.variant)) {
45
+ if (picks.length >= open)
46
+ break;
47
+ const pick = candidate(ctx, offer);
48
+ if (!pick || seen.has(itemKey(pick.offer.item)))
49
+ continue;
50
+ seen.add(itemKey(pick.offer.item));
51
+ picks.push(pick);
52
+ }
53
+ return picks;
54
+ }
55
+ function groupingFor(ctx) {
56
+ if (slotsFor(ctx.placement, ctx.view) < 2)
57
+ return null;
58
+ const groupings = ctx.variant?.groupings ?? ctx.placement.groupings ?? [];
59
+ return groupings.find((grouping) => anyLineMatches(grouping.when, ctx.view.chosen))?.presentation ?? null;
60
+ }
61
+ /**
62
+ * Mesmo documento, carrinho, visitante e resposta = mesmo id (ADR-0083 §1, princípio 3). O instante não entra: duas
63
+ * perguntas iguais seguidas são a mesma decisão. A ordem das linhas também não.
64
+ */
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)}`;
71
+ }
72
+ function toDecided(ctx, pick, slot, decisionId) {
73
+ const { offer, item, classification } = pick;
74
+ const price = offerUnitPrice(offer.price, item.price);
75
+ return {
76
+ offerId: offer.id,
77
+ decisionId,
78
+ placement: ctx.placement.key,
79
+ slot,
80
+ kind: classification.kind,
81
+ direction: classification.direction,
82
+ item: { source: item.source, variantId: item.variantId },
83
+ price,
84
+ compareAtPrice: compareAtPrice(item.price, price),
85
+ pricePerDay: pricePerDay(price, item.durationDays),
86
+ currency: ctx.doc.currency,
87
+ action: offer.action,
88
+ replaces: offer.action === 'replace' ? (classification.target?.line.item ?? null) : null,
89
+ content: offer.content,
90
+ presentation: offer.presentation ?? placementPresentation(ctx),
91
+ };
92
+ }
93
+ function placementPresentation(ctx) {
94
+ return ctx.variant?.presentation ?? ctx.placement.presentation ?? null;
95
+ }
96
+ /**
97
+ * 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.
99
+ */
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: [] };
103
+ }
104
+ /**
105
+ * 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.
108
+ */
109
+ export function decide(doc, input) {
110
+ if (!isSupportedSchema(doc.schemaVersion))
111
+ return emptyDecision(doc, input, 'unsupported_schema');
112
+ const placement = doc.placements.find((candidatePlacement) => candidatePlacement.key === input.placement);
113
+ if (!placement)
114
+ return emptyDecision(doc, input, 'ok');
115
+ const assignment = assignExperiment(doc, placement.key, input.visitorId);
116
+ const items = indexItems(doc);
117
+ const ctx = { doc, items, view: viewCart(items, input.cart), placement, variant: assignment?.variant ?? null, at: input.at };
118
+ const picks = pickOffers(ctx);
119
+ 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 {
122
+ status: 'ok',
123
+ version: doc.version,
124
+ placement: placement.key,
125
+ decisionId,
126
+ offers: picks.map((pick, index) => toDecided(ctx, pick, index + 1, decisionId)),
127
+ presentation: placementPresentation(ctx),
128
+ grouping: groupingFor(ctx),
129
+ experiments,
130
+ };
131
+ }
@@ -0,0 +1,5 @@
1
+ import type { ExperimentVariant, Offer, OfferDocument } from './types.js';
2
+ /** A oferta como esta variante a mostra: campo ausente na mudança fica como no documento; o conteúdo mescla. */
3
+ export declare function applyVariant(offer: Offer, variant: ExperimentVariant | null): Offer;
4
+ /** As ofertas da posição, com a variante aplicada, na ordem de prioridade (empate: ordem do documento). */
5
+ export declare function placementOffers(doc: OfferDocument, placement: string, variant: ExperimentVariant | null): Offer[];
@@ -0,0 +1,21 @@
1
+ /** A oferta como esta variante a mostra: campo ausente na mudança fica como no documento; o conteúdo mescla. */
2
+ export function applyVariant(offer, variant) {
3
+ const change = variant?.offers.find((override) => override.offerId === offer.id);
4
+ if (!change)
5
+ return offer;
6
+ return {
7
+ ...offer,
8
+ active: change.active ?? offer.active,
9
+ price: change.price ?? offer.price,
10
+ content: { ...offer.content, ...change.content },
11
+ presentation: change.presentation ?? offer.presentation,
12
+ };
13
+ }
14
+ /** As ofertas da posição, com a variante aplicada, na ordem de prioridade (empate: ordem do documento). */
15
+ export function placementOffers(doc, placement, variant) {
16
+ return doc.offers
17
+ .map((offer, order) => ({ offer, order }))
18
+ .filter(({ offer }) => offer.placement === placement)
19
+ .sort((a, b) => a.offer.priority - b.offer.priority || a.order - b.order)
20
+ .map(({ offer }) => applyVariant(offer, variant));
21
+ }
@@ -0,0 +1,24 @@
1
+ import type { Experiment, ExperimentVariant, Holdout, OfferDocument } from './types.js';
2
+ /**
3
+ * O hash do PostHog (`calculate_hash` do `feature-flags` em Rust, `_hash` do Python): `sha1(prefixo + id + sal)`, os 60
4
+ * primeiros bits divididos pela escala, num número entre 0 e 1. Os 60 bits saem das duas primeiras palavras sem BigInt
5
+ * (`hi · 2²⁸ + lo >>> 4`), arredondados uma vez só, como o `u64 as f64` deles.
6
+ */
7
+ export declare function hashBucket(prefix: string, identifier: string, salt: string): number;
8
+ /** Entra quem tem hash ≤ porcentagem (PostHog `is_in_rollout`). */
9
+ export declare function inRollout(percentage: number, hash: number): boolean;
10
+ /** A variante pela soma acumulada das porcentagens, na ordem cadastrada (PostHog `select_variant`). */
11
+ export declare function selectVariant(hash: number, variants: ExperimentVariant[]): ExperimentVariant | null;
12
+ /** Grupo de fora geral: prefixo `holdout-` do PostHog, sem chave de teste (vale para todos). */
13
+ export declare function inHoldout(holdout: Holdout, visitorId: string): boolean;
14
+ export interface Assignment {
15
+ experiment: Experiment;
16
+ variant: ExperimentVariant;
17
+ }
18
+ /**
19
+ * Sorteio em duas etapas (ADR-0047, emenda de testes A/B): primeiro se o visitante entra no teste, depois em qual
20
+ * variante, as duas com `sha1("<teste>.<visitante><sal>")` e sais diferentes (o do teste, e o do teste + `variant`,
21
+ * como o PostHog) para as decisões não ficarem ligadas. Um teste por posição: vale o primeiro ativo. Visitante sem id
22
+ * nunca entra; quem está no grupo de fora geral também não.
23
+ */
24
+ export declare function assignExperiment(doc: OfferDocument, placement: string, visitorId: string | undefined): Assignment | null;
@@ -0,0 +1,51 @@
1
+ import { sha1Words } from './sha1.js';
2
+ /**
3
+ * 15 dígitos hexa (60 bits), como o PostHog: `int(sha1[:15], 16) / 0xfffffffffffffff`. Em double, 2⁶⁰ − 1 arredonda
4
+ * para 2⁶⁰ — que é o `LONG_SCALE as f64` do Rust deles; escrito assim para não fingir um inteiro que o double não guarda.
5
+ */
6
+ const LONG_SCALE = 2 ** 60;
7
+ /**
8
+ * O hash do PostHog (`calculate_hash` do `feature-flags` em Rust, `_hash` do Python): `sha1(prefixo + id + sal)`, os 60
9
+ * primeiros bits divididos pela escala, num número entre 0 e 1. Os 60 bits saem das duas primeiras palavras sem BigInt
10
+ * (`hi · 2²⁸ + lo >>> 4`), arredondados uma vez só, como o `u64 as f64` deles.
11
+ */
12
+ export function hashBucket(prefix, identifier, salt) {
13
+ const [hi = 0, lo = 0] = sha1Words(`${prefix}${identifier}${salt}`);
14
+ return (hi * 2 ** 28 + (lo >>> 4)) / LONG_SCALE;
15
+ }
16
+ /** Entra quem tem hash ≤ porcentagem (PostHog `is_in_rollout`). */
17
+ export function inRollout(percentage, hash) {
18
+ return percentage >= 100 || hash <= percentage / 100;
19
+ }
20
+ /** A variante pela soma acumulada das porcentagens, na ordem cadastrada (PostHog `select_variant`). */
21
+ export function selectVariant(hash, variants) {
22
+ let cumulative = 0;
23
+ for (const variant of variants) {
24
+ cumulative += variant.percentage / 100;
25
+ if (hash < cumulative)
26
+ return variant;
27
+ }
28
+ return null;
29
+ }
30
+ /** Grupo de fora geral: prefixo `holdout-` do PostHog, sem chave de teste (vale para todos). */
31
+ export function inHoldout(holdout, visitorId) {
32
+ return holdout.enabled && inRollout(holdout.percentage, hashBucket('holdout-', visitorId, holdout.salt ?? ''));
33
+ }
34
+ /**
35
+ * Sorteio em duas etapas (ADR-0047, emenda de testes A/B): primeiro se o visitante entra no teste, depois em qual
36
+ * variante, as duas com `sha1("<teste>.<visitante><sal>")` e sais diferentes (o do teste, e o do teste + `variant`,
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
+ * nunca entra; quem está no grupo de fora geral também não.
39
+ */
40
+ export function assignExperiment(doc, placement, visitorId) {
41
+ if (!visitorId)
42
+ return null;
43
+ const experiment = doc.experiments.find((candidate) => candidate.active && candidate.placement === placement);
44
+ if (!experiment || inHoldout(doc.holdout, visitorId))
45
+ return null;
46
+ const salt = experiment.salt ?? '';
47
+ if (!inRollout(experiment.percentage, hashBucket(`${experiment.key}.`, visitorId, salt)))
48
+ return null;
49
+ const variant = selectVariant(hashBucket(`${experiment.key}.`, visitorId, `${salt}variant`), experiment.variants);
50
+ return variant ? { experiment, variant } : null;
51
+ }
@@ -0,0 +1,7 @@
1
+ export type * from './types.js';
2
+ export type * from './io.js';
3
+ export { decide } from './decide.js';
4
+ export { price } from './price.js';
5
+ export { hashBucket, inRollout, selectVariant } from './experiments.js';
6
+ export { compareAtPrice, offerUnitPrice, pricePerDay } from './money.js';
7
+ export { SUPPORTED_SCHEMA_VERSION } from './schema-version.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { decide } from './decide.js';
2
+ export { price } from './price.js';
3
+ export { hashBucket, inRollout, selectVariant } from './experiments.js';
4
+ export { compareAtPrice, offerUnitPrice, pricePerDay } from './money.js';
5
+ export { SUPPORTED_SCHEMA_VERSION } from './schema-version.js';
package/dist/io.d.ts ADDED
@@ -0,0 +1,97 @@
1
+ import type { Cents, ItemRef, OfferAction, OfferContent } from './types.js';
2
+ /** Uma linha do carrinho como a ponte a vê. */
3
+ export interface CartLine {
4
+ item: ItemRef;
5
+ 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
+ unitPrice?: Cents;
8
+ /** Produto da loja da linha, para o item que o documento não lista (o produto que a cliente escolheu). */
9
+ storeProductId?: string;
10
+ /** Linha de oferta aceita: leva o `offerId` até o pedido (ADR-0083 §1, princípio 4). Não é gatilho de outra oferta. */
11
+ offerId?: string;
12
+ /** A posição de onde a oferta aceita veio; desempata o id repetido em duas posições. */
13
+ placement?: string;
14
+ /** Na ação `replace`: o item que a oferta trocou. `price()` confere a condição como se ele ainda estivesse lá. */
15
+ replacedItem?: ItemRef;
16
+ /** Brinde: não é escolha da cliente nem entra no subtotal. */
17
+ gift?: boolean;
18
+ }
19
+ export interface DecideInput {
20
+ placement: string;
21
+ cart: CartLine[];
22
+ /** Sem visitante não há sorteio: ele nunca entra em teste. */
23
+ visitorId?: string;
24
+ /** Instante ISO com offset. O pacote nunca lê o relógio: a decisão é determinística. */
25
+ at: string;
26
+ }
27
+ export interface ExperimentAssignment {
28
+ key: string;
29
+ variant: string;
30
+ }
31
+ /** Calculado, nunca cadastrado (ADR-0083 §3): mesmo produto é upsell, outro é cross-sell. */
32
+ export type DecidedOfferKind = 'upsell' | 'cross_sell';
33
+ export interface DecidedOffer {
34
+ offerId: string;
35
+ decisionId: string;
36
+ placement: string;
37
+ /** Posição na vaga, a partir de 1 (o `slot` do evento `viewed`). */
38
+ slot: number;
39
+ kind: DecidedOfferKind;
40
+ /** Só no upsell com duração no catálogo: 30 → 90 sobe, 90 → 30 desce. */
41
+ direction: 'up' | 'down' | null;
42
+ item: ItemRef;
43
+ price: Cents;
44
+ /** O preço da loja, quando maior que o da oferta. */
45
+ compareAtPrice: Cents | null;
46
+ /** Com duração no catálogo. */
47
+ pricePerDay: Cents | null;
48
+ currency: string;
49
+ action: OfferAction;
50
+ /** O item que a cliente leva e a oferta troca, na ação `replace`. */
51
+ replaces: ItemRef | null;
52
+ content: OfferContent;
53
+ presentation: string | null;
54
+ }
55
+ /** `unsupported_schema`: documento num formato mais novo que o pacote; nada é decidido (sem ofertas, sem teste). */
56
+ export type ResultStatus = 'ok' | 'unsupported_schema';
57
+ export interface Decision {
58
+ status: ResultStatus;
59
+ version: number;
60
+ placement: string;
61
+ /** Mesmo documento, carrinho, visitante e resposta = mesmo id. Sai também sem oferta (para o `exposed`). */
62
+ decisionId: string;
63
+ offers: DecidedOffer[];
64
+ /** Apresentação padrão da posição (já com a variante do teste). */
65
+ presentation: string | null;
66
+ /** Apresentação de grupo (combo) quando vale para este carrinho. */
67
+ grouping: string | null;
68
+ experiments: ExperimentAssignment[];
69
+ }
70
+ export interface PriceInput {
71
+ cart: CartLine[];
72
+ /** O mesmo do `decide`: a variante do teste é sorteada de novo por ele. */
73
+ visitorId?: string;
74
+ at: string;
75
+ }
76
+ export type IneligibleReason = 'unknown_offer' | 'inactive' | 'item_mismatch' | 'not_started' | 'expired' | 'trigger_missing' | 'subtotal_out_of_range'
77
+ /** O documento é de um formato mais novo que o pacote: toda linha volta com este motivo. */
78
+ | 'unsupported_schema';
79
+ export type PricedLine = {
80
+ index: number;
81
+ offerId: string;
82
+ status: 'ok';
83
+ unitPrice: Cents;
84
+ compareAtPrice: Cents | null;
85
+ } | {
86
+ index: number;
87
+ offerId: string;
88
+ status: 'ineligible';
89
+ reason: IneligibleReason;
90
+ };
91
+ export interface PriceResult {
92
+ status: ResultStatus;
93
+ version: number;
94
+ currency: string;
95
+ /** Uma por linha com `offerId`, na ordem do carrinho; `index` é a posição da linha no carrinho de entrada. */
96
+ lines: PricedLine[];
97
+ }
package/dist/io.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/kind.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { type ResolvedLine } from './cart.js';
2
+ import type { DecidedOfferKind } from './io.js';
3
+ export interface Classification {
4
+ kind: DecidedOfferKind;
5
+ direction: 'up' | 'down' | null;
6
+ /** A linha escolhida do mesmo produto: a que a ação `replace` troca. */
7
+ target: ResolvedLine | null;
8
+ }
9
+ /**
10
+ * 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.
12
+ */
13
+ export declare function classify(offered: ResolvedLine, chosen: ResolvedLine[]): Classification;
package/dist/kind.js ADDED
@@ -0,0 +1,18 @@
1
+ import { sameProduct } from './cart.js';
2
+ function directionOf(offered, target) {
3
+ if (offered.durationDays === null || target.durationDays === null)
4
+ return null;
5
+ if (offered.durationDays === target.durationDays)
6
+ return null;
7
+ return offered.durationDays > target.durationDays ? 'up' : 'down';
8
+ }
9
+ /**
10
+ * 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.
12
+ */
13
+ export function classify(offered, chosen) {
14
+ const target = chosen.find((line) => sameProduct(offered, line)) ?? null;
15
+ if (!target)
16
+ return { kind: 'cross_sell', direction: null, target: null };
17
+ return { kind: 'upsell', direction: directionOf(offered, target), target };
18
+ }
@@ -0,0 +1,11 @@
1
+ import type { Cents, OfferPrice } from './types.js';
2
+ /**
3
+ * Preço da oferta por unidade, a partir do preço da loja. O desconto percentual é arredondado meio para cima (15% de
4
+ * 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).
6
+ */
7
+ export declare function offerUnitPrice(price: OfferPrice, storePrice: Cents): Cents;
8
+ /** O preço "de": o da loja, só quando há o que riscar. */
9
+ export declare function compareAtPrice(storePrice: Cents, price: Cents): Cents | null;
10
+ /** Preço por dia pela duração da variante no catálogo, arredondado meio para cima. */
11
+ export declare function pricePerDay(price: Cents, durationDays: number | undefined): Cents | null;
package/dist/money.js ADDED
@@ -0,0 +1,27 @@
1
+ /** Divisão inteira arredondada meio para cima (a regra de centavo de sempre: 0,5 sobe). Só para valores ≥ 0. */
2
+ function divideHalfUp(numerator, denominator) {
3
+ return Math.floor((2 * numerator + denominator) / (2 * denominator));
4
+ }
5
+ /**
6
+ * Preço da oferta por unidade, a partir do preço da loja. O desconto percentual é arredondado meio para cima (15% de
7
+ * 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).
9
+ */
10
+ export function offerUnitPrice(price, storePrice) {
11
+ const raw = price.type === 'fixed'
12
+ ? price.amount
13
+ : price.type === 'amount_off'
14
+ ? storePrice - price.amount
15
+ : storePrice - divideHalfUp(storePrice * price.basisPoints, 10_000);
16
+ return Math.min(storePrice, Math.max(0, raw));
17
+ }
18
+ /** O preço "de": o da loja, só quando há o que riscar. */
19
+ export function compareAtPrice(storePrice, price) {
20
+ return storePrice > price ? storePrice : null;
21
+ }
22
+ /** Preço por dia pela duração da variante no catálogo, arredondado meio para cima. */
23
+ export function pricePerDay(price, durationDays) {
24
+ if (!durationDays || durationDays <= 0)
25
+ return null;
26
+ return divideHalfUp(price, durationDays);
27
+ }
@@ -0,0 +1,10 @@
1
+ import type { PriceInput, PriceResult } from './io.js';
2
+ import type { OfferDocument } from './types.js';
3
+ /**
4
+ * O preço de cada linha de oferta aceita, **conferindo as condições de novo** com o carrinho real (ADR-0083 §1,
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).
9
+ */
10
+ export declare function price(doc: OfferDocument, input: PriceInput): PriceResult;
package/dist/price.js ADDED
@@ -0,0 +1,77 @@
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';
6
+ 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];
11
+ }
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) };
60
+ }
61
+ /**
62
+ * O preço de cada linha de oferta aceita, **conferindo as condições de novo** com o carrinho real (ADR-0083 §1,
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
+ * 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).
67
+ */
68
+ export function price(doc, input) {
69
+ 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 };
77
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Versão do formato do documento que este pacote entende. Sobe quando o formato muda de um jeito que um pacote antigo
3
+ * leria errado (campo novo que muda a decisão ou o preço). Documento com `schemaVersion` maior não é decidido nem
4
+ * precificado: a resposta diz `unsupported_schema` e a ponte sabe que precisa atualizar o pacote, em vez de cobrar
5
+ * com uma regra que não conhece.
6
+ */
7
+ export declare const SUPPORTED_SCHEMA_VERSION = 1;
8
+ export declare function isSupportedSchema(schemaVersion: number): boolean;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Versão do formato do documento que este pacote entende. Sobe quando o formato muda de um jeito que um pacote antigo
3
+ * leria errado (campo novo que muda a decisão ou o preço). Documento com `schemaVersion` maior não é decidido nem
4
+ * precificado: a resposta diz `unsupported_schema` e a ponte sabe que precisa atualizar o pacote, em vez de cobrar
5
+ * com uma regra que não conhece.
6
+ */
7
+ export const SUPPORTED_SCHEMA_VERSION = 1;
8
+ export function isSupportedSchema(schemaVersion) {
9
+ return Number.isInteger(schemaVersion) && schemaVersion >= 1 && schemaVersion <= SUPPORTED_SCHEMA_VERSION;
10
+ }
package/dist/sha1.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * SHA-1 em TypeScript puro. A Shopify Function (Javy/QuickJS) não tem `node:crypto` nem Web Crypto síncrono, e o
3
+ * sorteio A/B precisa do mesmo hash do PostHog (ADR-0047, emenda de testes A/B). Não é uso criptográfico: é só a
4
+ * função de espalhamento que deixa o sorteio igual ao do PostHog.
5
+ */
6
+ /** Texto → bytes UTF-8, sem `TextEncoder` (nem todo runtime da ponte tem). */
7
+ export declare function utf8Bytes(text: string): number[];
8
+ /** Os 20 bytes do SHA-1 como cinco palavras de 32 bits sem sinal. */
9
+ export declare function sha1Words(text: string): number[];
10
+ export declare function sha1Hex(text: string): string;
package/dist/sha1.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * SHA-1 em TypeScript puro. A Shopify Function (Javy/QuickJS) não tem `node:crypto` nem Web Crypto síncrono, e o
3
+ * sorteio A/B precisa do mesmo hash do PostHog (ADR-0047, emenda de testes A/B). Não é uso criptográfico: é só a
4
+ * função de espalhamento que deixa o sorteio igual ao do PostHog.
5
+ */
6
+ /** Texto → bytes UTF-8, sem `TextEncoder` (nem todo runtime da ponte tem). */
7
+ export function utf8Bytes(text) {
8
+ const bytes = [];
9
+ for (const char of text) {
10
+ const code = char.codePointAt(0) ?? 0;
11
+ if (code < 0x80)
12
+ bytes.push(code);
13
+ else if (code < 0x800)
14
+ bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
15
+ else if (code < 0x10000)
16
+ bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
17
+ else
18
+ bytes.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
19
+ }
20
+ return bytes;
21
+ }
22
+ /** Mensagem com o enchimento do SHA-1: 0x80, zeros e o tamanho em bits (64 bits, big-endian). */
23
+ function padded(bytes) {
24
+ const bitLength = bytes.length * 8;
25
+ const out = [...bytes, 0x80];
26
+ while (out.length % 64 !== 56)
27
+ out.push(0);
28
+ const high = Math.floor(bitLength / 0x100000000);
29
+ for (const word of [high, bitLength >>> 0])
30
+ out.push((word >>> 24) & 0xff, (word >>> 16) & 0xff, (word >>> 8) & 0xff, word & 0xff);
31
+ return out;
32
+ }
33
+ const rotl = (value, bits) => (value << bits) | (value >>> (32 - bits));
34
+ function roundMix(t, b, c, d) {
35
+ if (t < 20)
36
+ return ((b & c) | (~b & d)) + 0x5a827999;
37
+ if (t < 40)
38
+ return (b ^ c ^ d) + 0x6ed9eba1;
39
+ if (t < 60)
40
+ return ((b & c) | (b & d) | (c & d)) + 0x8f1bbcdc;
41
+ return (b ^ c ^ d) + 0xca62c1d6;
42
+ }
43
+ function compress(state, block, offset) {
44
+ const w = [];
45
+ for (let t = 0; t < 16; t++) {
46
+ const i = offset + t * 4;
47
+ w[t] = ((block[i] ?? 0) << 24) | ((block[i + 1] ?? 0) << 16) | ((block[i + 2] ?? 0) << 8) | (block[i + 3] ?? 0);
48
+ }
49
+ for (let t = 16; t < 80; t++)
50
+ w[t] = rotl((w[t - 3] ?? 0) ^ (w[t - 8] ?? 0) ^ (w[t - 14] ?? 0) ^ (w[t - 16] ?? 0), 1);
51
+ let [a, b, c, d, e] = state;
52
+ for (let t = 0; t < 80; t++) {
53
+ const temp = (rotl(a, 5) + roundMix(t, b, c, d) + e + (w[t] ?? 0)) | 0;
54
+ e = d;
55
+ d = c;
56
+ c = rotl(b, 30);
57
+ b = a;
58
+ a = temp;
59
+ }
60
+ [a, b, c, d, e].forEach((value, i) => {
61
+ state[i] = ((state[i] ?? 0) + value) | 0;
62
+ });
63
+ }
64
+ /** Os 20 bytes do SHA-1 como cinco palavras de 32 bits sem sinal. */
65
+ export function sha1Words(text) {
66
+ const message = padded(utf8Bytes(text));
67
+ const state = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0];
68
+ for (let offset = 0; offset < message.length; offset += 64)
69
+ compress(state, message, offset);
70
+ return state.map((word) => word >>> 0);
71
+ }
72
+ export function sha1Hex(text) {
73
+ return sha1Words(text)
74
+ .map((word) => word.toString(16).padStart(8, '0'))
75
+ .join('');
76
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Tipos do motor de ofertas (ADR-0083). O documento publicado é a única entrada de `decide()` e `price()`: a API do
3
+ * Commerce OS e a ponte de cada loja (avaliação local, Shopify Function) rodam o mesmo código com o mesmo documento.
4
+ *
5
+ * Dinheiro: **centavos inteiros** (unidade menor da moeda do documento) em todo lugar do pacote. Nada de float: a
6
+ * Function cobra o que o pacote calcula, e 79,90 em float vira 79,899999. O arredondamento é explícito em `money.ts`.
7
+ */
8
+ /** Valor em centavos (inteiro ≥ 0), na moeda do documento. */
9
+ export type Cents = number;
10
+ /** Item: uma variante vendável da loja, por origem + id da variante (`shopify` + `5167…`), como em `store_item`. */
11
+ export interface ItemRef {
12
+ source: string;
13
+ variantId: string;
14
+ }
15
+ /** Item referenciado pelo documento, com o que o motor precisa saber dele na hora de decidir. */
16
+ export interface OfferItem extends ItemRef {
17
+ /** Preço da loja: base do preço da oferta e o preço "de". */
18
+ price: Cents;
19
+ available: boolean;
20
+ /** Produto da loja: "mesmo produto" pela primeira regra (ADR-0083 §3). */
21
+ storeProductId: string;
22
+ /** Produtos do catálogo curado (ADR-0036): um para a variante, os que ele contém para o kit. */
23
+ catalogProductIds?: string[];
24
+ /** Duração da variante no catálogo (30, 90, 180): dá a direção do upsell e o preço por dia. */
25
+ durationDays?: number;
26
+ }
27
+ /** Conjunto de itens, produtos da loja ou produtos do catálogo. Casa se qualquer um casar. */
28
+ export interface ProductMatch {
29
+ items?: ItemRef[];
30
+ storeProductIds?: string[];
31
+ catalogProductIds?: string[];
32
+ }
33
+ /** Condições: lista fixa e tipada, sem linguagem de regras (ADR-0003). Todas precisam valer. */
34
+ export type OfferCondition =
35
+ /** O carrinho que a cliente escolheu tem algum destes. */
36
+ {
37
+ type: 'cart_has_any';
38
+ match: ProductMatch;
39
+ }
40
+ /** O carrinho que a cliente escolheu não tem nenhum destes ("produtos que escondem"). Só esconde: não desfaz aceita. */
41
+ | {
42
+ type: 'cart_has_none';
43
+ match: ProductMatch;
44
+ }
45
+ /** Subtotal da mercadoria (sem brinde) dentro da faixa, limites inclusivos. */
46
+ | {
47
+ type: 'subtotal_between';
48
+ min?: Cents;
49
+ max?: Cents;
50
+ };
51
+ /** Preço da oferta, sempre a partir do preço do item na loja. `basisPoints`: 1500 = 15%. */
52
+ export type OfferPrice = {
53
+ type: 'fixed';
54
+ amount: Cents;
55
+ } | {
56
+ type: 'percent_off';
57
+ basisPoints: number;
58
+ } | {
59
+ type: 'amount_off';
60
+ amount: Cents;
61
+ };
62
+ /** `add` soma o item; `replace` troca o item que disparou a oferta (30 → 90 dias). */
63
+ export type OfferAction = 'add' | 'replace';
64
+ export interface OfferContent {
65
+ title?: string;
66
+ body?: string;
67
+ cta?: string;
68
+ imageUrl?: string;
69
+ }
70
+ export interface Offer {
71
+ /** Único por posição; o mesmo id pode viver em duas posições (ofertas migradas do app, ADR 0028 de lá). */
72
+ id: string;
73
+ placement: string;
74
+ /** Desligada não aparece; um teste pode ligá-la numa variante. */
75
+ active: boolean;
76
+ /** Menor primeiro; empate fica na ordem do documento. */
77
+ priority: number;
78
+ item: ItemRef;
79
+ price: OfferPrice;
80
+ action: OfferAction;
81
+ content: OfferContent;
82
+ /** Nome do visual que a ponte implementa; sem ele vale o da posição. */
83
+ presentation?: string;
84
+ conditions: OfferCondition[];
85
+ /** Instantes ISO com offset; início inclusivo, fim exclusivo. */
86
+ validFrom?: string;
87
+ validUntil?: string;
88
+ }
89
+ /** Apresentação de grupo: com um destes escolhido e todas as vagas no carrinho, as ofertas viram um card só (combo). */
90
+ export interface PlacementGrouping {
91
+ presentation: string;
92
+ when: ProductMatch;
93
+ }
94
+ export interface Placement {
95
+ /** Chave livre da org: `cart`, `checkout`, `product_page`, `add_popup`. */
96
+ key: string;
97
+ slots: number;
98
+ /** Vaga extra condicional: abre só quando o carrinho escolhido casa (a 2ª vaga da Meno). */
99
+ extraSlot?: {
100
+ when: ProductMatch;
101
+ };
102
+ /** As apresentações que a ponte implementa nesta posição. */
103
+ presentations: string[];
104
+ /** Apresentação padrão das ofertas desta posição. */
105
+ presentation?: string;
106
+ groupings?: PlacementGrouping[];
107
+ }
108
+ /** O que uma variante de teste muda numa oferta. Campo ausente = igual ao documento. */
109
+ export interface OfferOverride {
110
+ offerId: string;
111
+ active?: boolean;
112
+ price?: OfferPrice;
113
+ content?: OfferContent;
114
+ presentation?: string;
115
+ }
116
+ export interface ExperimentVariant {
117
+ key: string;
118
+ /** Somam 100 no teste; a ordem não muda com o teste rodando. */
119
+ percentage: number;
120
+ offers: OfferOverride[];
121
+ /** Troca a apresentação padrão da posição. */
122
+ presentation?: string;
123
+ /** Troca as apresentações de grupo da posição (`[]` desliga o combo). */
124
+ groupings?: PlacementGrouping[];
125
+ }
126
+ export interface Experiment {
127
+ key: string;
128
+ placement: string;
129
+ active: boolean;
130
+ /** Quanto do tráfego entra no teste (0–100); o resto vê o documento sem mudança e não é contado. */
131
+ percentage: number;
132
+ /** Sal do sorteio; vazio = igual ao PostHog. */
133
+ salt?: string;
134
+ variants: ExperimentVariant[];
135
+ }
136
+ /** Grupo de fora geral: quem cai nele não entra em teste nenhum. Nasce desligado. */
137
+ export interface Holdout {
138
+ enabled: boolean;
139
+ percentage: number;
140
+ salt?: string;
141
+ }
142
+ export interface OfferDocument {
143
+ /** Formato do documento (começa em 1); o pacote recusa formato mais novo que `SUPPORTED_SCHEMA_VERSION`. */
144
+ schemaVersion: number;
145
+ /** Número da versão publicada; a ponte confirma qual está no ar (ADR-0083 §6). */
146
+ version: number;
147
+ /** ISO 4217; todo valor em centavos do documento é nesta moeda. */
148
+ currency: string;
149
+ items: OfferItem[];
150
+ placements: Placement[];
151
+ offers: Offer[];
152
+ experiments: Experiment[];
153
+ holdout: Holdout;
154
+ }
package/dist/types.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Tipos do motor de ofertas (ADR-0083). O documento publicado é a única entrada de `decide()` e `price()`: a API do
3
+ * Commerce OS e a ponte de cada loja (avaliação local, Shopify Function) rodam o mesmo código com o mesmo documento.
4
+ *
5
+ * Dinheiro: **centavos inteiros** (unidade menor da moeda do documento) em todo lugar do pacote. Nada de float: a
6
+ * Function cobra o que o pacote calcula, e 79,90 em float vira 79,899999. O arredondamento é explícito em `money.ts`.
7
+ */
8
+ export {};
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@zolve/offers",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc -p tsconfig.build.json",
19
+ "typecheck": "tsc --noEmit",
20
+ "lint": "oxlint -c ../../oxlint.json src/",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest"
23
+ },
24
+ "devDependencies": {
25
+ "oxlint": "^1.58.0",
26
+ "typescript": "^6.0.2",
27
+ "vitest": "^4.1.2"
28
+ },
29
+ "license": "UNLICENSED",
30
+ "publishConfig": {
31
+ "access": "public"
32
+ }
33
+ }