scopuli-convex
v0.3.0
Published
Componente Convex com a integração completa de uma loja com o Scopuli (hub de marketplaces da Amage): hub:* functions, outbox de estoque com retry e idempotência de pedidos.
Downloads
519
Maintainers
Readme
scopuli-convex
Componente Convex com a integração completa de uma loja com o Scopuli — o hub de marketplaces da Amage (Mercado Livre, Shopee, Mercado Pago/InfinitePay, NF-e/NFS-e).
Substitui o convex/hub.ts escrito à mão em cada loja por três linhas, e
desde a v0.2 também engole o lado que a LOJA chama no Scopuli:
- Checkout no storefront — link hospedado InfinitePay
(
createCheckoutLink({orderId}), idempotente, com query reativa do status) e MP Bricks completo (processPayment, public key, customers, cartões salvos, refund admin). - Cotação de frete no carrinho —
calculateShipping({toCep, items})com dimensões/peso lidos dos PRODUTOS (nunca do browser) e cache TTL. - Pedido idempotente — o Scopuli pode re-tentar
createMarketplaceOrder(timeout, retry de webhook); o componente mantém um ledger por(marketplace, externalOrderId)e nunca duplica pedido nem decrementa estoque duas vezes. - Notificação de estoque coalescida com retry — outbox por produto onde a
última mudança vence (um retry de valor antigo jamais entrega estoque stale
por cima de um mais novo), com backoff 10s/30s/90s e visão operacional via
hub:status.
Instalação
npm install scopuli-convexPublicado em https://www.npmjs.com/package/scopuli-convex. Releases:
npm login + npm run release (patch) ou npm run alpha (pre-release).
// convex/convex.config.ts
import { defineApp } from "convex/server";
import scopuli from "scopuli-convex/convex.config.js";
const app = defineApp();
app.use(scopuli);
export default app;// convex/hub.ts — isto é TUDO que a loja precisa escrever
import { components } from "./_generated/api";
import { ScopuliHub, standardStoreAdapter } from "scopuli-convex";
export const hub = new ScopuliHub(components.scopuli, standardStoreAdapter());
export const {
listPublished,
getProduct,
createMarketplaceOrder,
updateOrderStatus,
updatePaymentStatus,
notifyStockChange,
status,
// Bridges internas (v0.2): dão às actions de checkout/frete acesso às
// tabelas do app via adapter. Precisam estar re-exportadas aqui.
getOrderForCheckout,
getShippingProducts,
// Diagnóstico: npx convex run hub:doctor
doctor,
} = hub.api();E configure no deployment da loja (npx convex env set):
| Env var | O quê |
|---|---|
| SCOPULI_URL | URL pública do Scopuli (ex.: https://scopuli.amageweb.com) |
| SCOPULI_STORE_KEY | API key da loja no Scopuli — scopes write:inventory + write:checkout + write:shipping |
| SCOPULI_STORE_ID | UUID da loja no Scopuli (paths REST) — necessário p/ checkout e frete |
| SCOPULI_FROM_CEP | CEP de origem do frete (galpão da loja) — necessário p/ cotação |
Validou tudo? npx convex run hub:doctor — diagnostica componente,
envs, bridges, adapter, conectividade e os scopes da key em um comando, cada
falha com o fix exato. É o primeiro passo de qualquer troubleshooting.
Agentes de IA: leiam o AGENTS.md (vem no pacote —
node_modules/scopuli-convex/AGENTS.md).
Semântica de configuração ausente, de propósito assimétrica:
| Recurso | Sem envs |
|---|---|
| Outbox de estoque | no-op com warning (telemetria; o poll de 5 min do Scopuli cobre) |
| Checkout / frete | ConvexError SCOPULI_NOT_CONFIGURED listando o que falta (request/response síncrono do comprador — falha silenciosa = checkout quebrado invisível) |
Avisando o Scopuli quando o estoque muda
Em qualquer mutation da loja que altere estoque, uma linha:
import { hub } from "./hub";
export const setStock = mutation({
args: { productId: v.id("products"), estoque: v.number() },
handler: async (ctx, args) => {
await ctx.db.patch("products", args.productId, { estoque: args.estoque });
await hub.queueStockNotification(ctx, {
productId: args.productId,
stock: args.estoque,
});
},
});É transacional com a mutation (se ela falhar, nada é enfileirado), coalescido por produto e entregue com retry. Pedidos criados pelo marketplace já notificam sozinhos o estoque decrementado.
Checkout e frete no storefront (v0.2)
Exponha as funções públicas com a SUA regra de auth — uma vez:
// convex/storefront.ts
import { hub } from "./hub";
export const {
createCheckoutLink, // InfinitePay: pedido → URL hospedada (idempotente)
getCheckoutLinkStatus, // polling na página de retorno
watchCheckoutLink, // query REATIVA: a UI reage quando vira "paid"
getMercadoPagoPublicKey, // inicializar o Bricks
processPayment, // token do Brick → pagamento (pix/cartão/boleto)
getPaymentStatus,
calculateShipping, // cotação no carrinho (dimensões saem do banco)
createCustomer, listCards, saveCard, deleteCard, // cartões salvos/1-click
} = hub.storefrontApi({
auth: async (ctx, { fn }) => {
// Guest checkout? Retorne true explicitamente nas funções que quiser:
// if (fn === "calculateShipping") return true;
return (await ctx.auth.getUserIdentity()) !== null;
},
});No browser fica assim:
const { checkoutUrl } = await convex.action(api.storefront.createCheckoutLink, {
orderId: order._id,
redirectUrl: `${origin}/checkout/retorno?order=${order._id}`,
});
window.location.href = checkoutUrl;
// Cotação no carrinho — só CEP + itens; peso/medidas vêm do servidor:
const { options } = await convex.action(api.storefront.calculateShipping, {
toCep: cep,
items: cart.map((i) => ({ productId: i.productId, quantity: i.qty })),
});Garantias anti-tampering: total_amount, external_reference, itens e
dimensões são derivados do PEDIDO/PRODUTOS no banco via adapter — o client só
escolhe O QUE pagar/cotar, nunca QUANTO. O retorno assíncrono do pagamento
continua chegando via webhook → hub:updatePaymentStatus (v0.1); o
watchCheckoutLink/getCheckoutLinkStatus são o fallback de polling.
Refund é admin-only (fora do storefrontApi) — chame de uma
internalAction sua: await hub.refundPayment(ctx, { paymentId, amount? }).
O contrato com o Scopuli
O backend Go chama estas funções via HTTP API (Admin Key) — os nomes
re-exportados em convex/hub.ts são o contrato:
| Path | Tipo | O quê |
|---|---|---|
| hub:listPublished | internalQuery | Produtos publicados, paginados, normalizados PT→EN |
| hub:getProduct | internalQuery | Um produto normalizado (ou null) |
| hub:createMarketplaceOrder | internalMutation | Cria pedido; valida estoque ANTES (lança INSUFFICIENT_STOCK: ...); idempotente |
| hub:updateOrderStatus | internalMutation | Status do pedido (shipped, delivered…) |
| hub:updatePaymentStatus | internalMutation | Status de pagamento (MP/InfinitePay) |
| hub:notifyStockChange | internalMutation | Compat com o hub.ts manual ({productId, newStock}) |
| hub:status | internalQuery | Ops: profundidade do outbox + falhas recentes (npx convex run hub:status) |
Os shapes de resposta estão pinados em internal/adapters/convex/store.go
no repo do Scopuli — mudar um lado exige mudar o outro.
Schema diferente do padrão?
O standardStoreAdapter() assume o schema canônico das lojas Amage
(products em PT com índice by_situacao_and_publicado, orders +
orderItems). Tudo é configurável:
standardStoreAdapter({
tables: { products: "produtos" },
stockField: "qtde",
listVariations: async (ctx, doc) => [...], // lojas com grade
mapProduct: async (ctx, doc, mapped) => ({ ...mapped, unit: "PC" }),
resolvePaymentOrder: async (ctx, { externalReference }) => {
const order = await ctx.db
.query("orders")
.withIndex("by_external_ref", (q) => q.eq("externalRef", externalReference))
.unique();
return order?._id ?? null;
},
orderDefaults: { initialStatus: "pago" },
});Para checkout/frete o adapter padrão também resolve: pedido de
orders+orderItems (index by_orderId; customize com orderItemsIndex ou
enriqueça com mapCheckoutOrder — ex. e-mail do cliente) e dimensões de
peso/altura/largura/comprimento.
Para schemas muito diferentes, implemente a interface ScopuliStoreAdapter
direto (5 métodos obrigatórios + getOrderForCheckout/getShippingDimensions
opcionais para habilitar checkout/frete) — a idempotência e os caches
continuam por conta do componente.
Testando a loja
import { convexTest } from "convex-test";
import scopuliTest from "scopuli-convex/test";
import schema from "./schema";
const t = convexTest(schema, modules);
scopuliTest.register(t);
// t.mutation(internal.hub.createMarketplaceOrder, ...) etc.Veja example/convex/hub.test.ts — exercita o contrato exatamente como o
Scopuli chama (idempotência, INSUFFICIENT_STOCK, coalescing, retry).
Desenvolvimento (deste pacote)
npm install
npm run build:codegen # codegen do componente + tsc → dist/
npm test # vitest (35 testes, convex-test)
npm run typecheck # pacote + example
npm run lintO example/ é uma loja canônica mínima usada por testes e typecheck — também
serve de referência de instalação.
