Exemplos
Integrações de referência
Três serviços pequenos, em TypeScript, que integram produtos da Xpertech ao Eclipta Pay usando só a API pública pelo SDK. O código desta página é o mesmo que roda nos testes de ponta a ponta.
O que cada uma mostra
| Produto | Caso de uso | Pagamento | Regra no produto |
|---|---|---|---|
| Educators | Venda de cursos: matrícula idempotente e checkout. | Pix, boleto, cartão à vista e parcelado. | Acesso liberado só com order.payment_completed; suspenso em estorno ou disputa; revogado com estorno concluído ou chargeback perdido. |
| Órbita | CRM de vendas de mentorias: link de pagamento por oportunidade. | Pix, cartão, dois cartões e Pix em parcelas. | Venda ganha só com evidência do provedor, nunca pela palavra do lead; progresso das parcelas; disputa e chargeback. |
| Agenda Expert | Agendamentos com sinal antecipado e plano mensal do profissional. | Pix pela API (202), estorno e assinatura. | Sinal pago confirma o horário; política de cancelamento própria (devolve ou retém); plano ativo com o ciclo pago. |
O código fica em examples/educators, examples/orbita e examples/agenda-expert, com as peças comuns em examples/kit. Os testes em tests/integration/src/reference-*.int.test.ts sobem a API, o worker (que entrega os webhooks por HTTP ao serviço do exemplo) e o simulador do provedor, e fazem o papel do pagador no checkout.
Padrões comuns
- Uma chave por intenção. Cada escrita usa uma Idempotency-Key derivada da intenção do produto (por exemplo
educators:matricula:<id>:pedido). A intenção e o corpo são gravados antes da chamada; repetir depois de uma falha de rede envia o mesmo corpo com a mesma chave. - 202 sem repetir escrita. Operações incertas (
UNKNOWN) aparecem como "em verificação"; o produto só consulta. Uma tentativa nova só depois de uma falha conclusiva. - Inbox de webhooks. A assinatura é verificada pelo SDK sobre o corpo cru; o evento é gravado uma vez por
eventIde a resposta 2xx sai na hora. O processamento acontece depois, em ordem, com nova tentativa local se a regra do produto falhar. - Estado vem da API. O evento é o gatilho; a decisão usa o pedido, a cobrança, o plano ou o grupo lidos na API. Eventos de pedidos de outro produto da mesma conta são aceitos e ignorados.
- Versão por agregado e tipo.
aggregateVersioné comparada por agregado e tipo de evento: tipos diferentes do mesmo agregado podem ter sequências próprias.
/**
* Receptor de webhooks com inbox: verifica a assinatura sobre o corpo cru, grava o evento uma única vez por
* `eventId` e responde 2xx na hora; o processamento acontece depois, fora da requisição, em ordem de chegada.
*/
export class WebhookInbox<S extends InboxState> {
private loop: Promise<void> | null = null;
private again = false;
private timer: NodeJS.Timeout | null = null;
constructor(private readonly opts: WebhookInboxOptions<S>) {}
private now(): number {
return this.opts.now ? this.opts.now() : Date.now();
}
async receive(rawBody: string | Uint8Array, headers: HeadersLike): Promise<{ status: number; body: Record<string, unknown> }> {
let event: Event;
try {
event = verifyWebhook(rawBody, headers, this.opts.secrets(), { toleranceSeconds: this.opts.toleranceSeconds ?? 300, now: () => this.now() });
} catch (e) {
// Assinatura inválida, timestamp fora da janela ou corpo que não é evento: nada é gravado.
if (e instanceof WebhookVerificationError) return { status: 400, body: { error: e.code } };
throw e;
}
const duplicate = await this.opts.store.update((s) => {
const existing = s.inbox[event.eventId];
if (existing) {
existing.deliveries += 1;
return true;
}
s.inbox[event.eventId] = { event, receivedAt: new Date(this.now()).toISOString(), deliveries: 1, state: "PENDING", attempts: 0, nextAttemptAt: null, lastError: null };
return false;
});
void this.kick();
return { status: 200, body: duplicate ? { received: true, duplicate: true } : { received: true } };
}Educators: venda de cursos
POST /matriculas cria (ou devolve) a matrícula do aluno no curso: cliente, pedido com ON_FULLY_PAID e sessão de checkout. Duplo clique, retentativa ou recarga devolvem o mesmo link, sem nova escrita. O pedido leva só identificadores em metadata, nunca nome, e-mail ou CPF.
/**
* Matricula o aluno: cliente, pedido e sessão de checkout, cada um com Idempotency-Key derivada da intenção.
* Repetir o pedido (duplo clique, retentativa depois de timeout) devolve a mesma matrícula e o mesmo link.
*/
async enroll(courseId: string, student: StudentInput): Promise<EnrollmentView> {
const course = this.catalog.get(courseId);
if (!course) throw new ProductError(404, "CURSO_NAO_ENCONTRADO", "Curso não encontrado.");
if (course.coproducers.length > 0) {
// Bloqueio só do que depende da lacuna: sem cadastro de recebedores na API pública, o split do coprodutor
// não pode ser enviado. Nenhuma cobrança é criada (a venda sem split mudaria quem recebe o dinheiro).
throw new ProductError(422, "COPRODUCAO_INDISPONIVEL", "Cursos em coprodução dependem do cadastro de recebedores no Eclipta Pay, ainda indisponível. Nenhuma cobrança foi criada.");
}
const id = `mat_${course.id}_${student.id}`;
await this.opts.store.update((s) => {
s.enrollments[id] ??= {
id,
courseId: course.id,
studentId: student.id,
amountCents: course.priceCents,
orderId: null,
checkout: null,
paymentIds: [],
access: "PENDENTE",
accessReason: "Aguardando a confirmação do pagamento.",
orderStatus: null,
grant: null,
cancellation: null,
history: [{ at: this.now().toISOString(), what: "matrícula iniciada" }],
createdAt: this.now().toISOString(),
};
});
let e = this.enrollment(id);
if (e.grant) return this.view(id); // já paga: nada a cobrar
let customerId = this.opts.store.read((s) => s.students[student.id]?.customerId ?? null);
if (!customerId) {
const customer = await this.client.createCustomer(
{ name: student.name, taxId: student.taxId, ...(student.email ? { email: student.email } : {}), externalReference: `aluno:${student.id}` },
{ idempotencyKey: intentKey(PRODUCT, "aluno", student.id, "cliente") },
);
customerId = customer.id;
await this.opts.store.update((s) => {
s.students[student.id] = { customerId: customer.id };
});
}
if (!e.orderId) {
// Sem dados pessoais em metadata (B docs/10): só identificadores do produto.
const order = await this.client.createOrder(
{
customerId,
externalOrderReference: `matricula:${id}`,
// Valor gravado na matrícula: a repetição envia exatamente o mesmo corpo (mesma chave, mesmo conteúdo).
amountCents: e.amountCents,
currency: "BRL",
fulfillmentPolicy: "ON_FULLY_PAID",
description: `Matrícula: ${course.title}`,
items: [{ name: course.title, quantity: 1, unitAmountCents: e.amountCents }],
metadata: { produto: PRODUCT, matricula: id, curso: course.id },
},
{ idempotencyKey: intentKey(PRODUCT, "matricula", id, "pedido") },
);
e = await this.patch(id, "pedido criado", (x, s) => {
x.orderId = order.id;
x.orderStatus = order.status;
s.orders[order.id] = id;
});
}
const now = this.now().getTime();
if (!e.checkout || Date.parse(e.checkout.expiresAt) - now < 60_000) {
if (e.checkout) {
// A sessão anterior expirou (só localmente): se o pagamento já entrou, não abre outra.
const order = await this.client.getOrder(e.orderId as string);
if (order.status !== "OPEN") return this.view(id);
}
const attempt = (e.checkout?.attempt ?? 0) + 1;
const session = await this.client.createCheckout(
{ orderId: e.orderId as string, allowedModes: course.modes, returnUrl: `${this.opts.publicUrl}/matriculas/${id}`, expiresInSeconds: this.opts.checkoutTtlSeconds ?? 3600 },
{ idempotencyKey: intentKey(PRODUCT, "matricula", id, "checkout", attempt) },
);
await this.patch(id, `checkout aberto (tentativa ${attempt})`, (x) => {
x.checkout = { attempt, sessionId: session.id, url: session.url, expiresAt: session.expiresAt };
});
}
return this.view(id);
}O acesso nasce no evento order.payment_completed, conferido na API. Estorno e disputa revisam o acesso já liberado: cancelamento dentro da garantia (política de exemplo do produto) pede o estorno e suspende o acesso; só o estorno DONE revoga. Disputa aberta suspende; ganha restabelece; perdida revoga.
/** Regra de um evento já verificado e deduplicado pela inbox. O estado de verdade vem da API. */
async handleEvent(event: Event): Promise<void> {
const orderId = await this.resolver.orderOf(event);
const id = orderId ? this.opts.store.read((s) => s.orders[orderId] ?? null) : null;
if (!orderId || !id) return; // pedido de outro produto da conta, ou evento sem pedido
const paymentId = typeof event.data.paymentId === "string" ? event.data.paymentId : null;
if (paymentId) {
await this.patch(id, null, (e) => {
if (!e.paymentIds.includes(paymentId)) e.paymentIds.push(paymentId);
});
}
if (event.type === "order.payment_completed") {
const order = await this.client.getOrder(orderId);
if (BigInt(order.confirmedCents) >= BigInt(order.amountCents)) {
await this.patch(id, `pagamento confirmado (evento ${event.eventId})`, (e) => {
e.grant ??= { eventId: event.eventId, grantedAt: this.now().toISOString(), confirmedCents: order.confirmedCents };
});
}
}
await this.reconcile(id);
}/**
* Acesso derivado do estado atual do pedido lido na API. A primeira liberação exige o evento
* `order.payment_completed`; disputa e estorno revisam o acesso já concedido.
*/
export function accessFor(input: AccessInput, policy: AccessPolicy): { access: Access; reason: string } {
if (!input.granted) return { access: "PENDENTE", reason: "Aguardando a confirmação do pagamento." };
const status = input.order.status;
if (status === "DISPUTED") {
return input.payments.some((p) => p.reversalStatus === "CHARGED_BACK")
? { access: "REVOGADO", reason: "Chargeback perdido." }
: { access: "SUSPENSO", reason: "Pagamento em disputa." };
}
if (status === "REFUNDED") return { access: "REVOGADO", reason: "Reembolso concluído." };
if (input.cancellationRequested) return { access: "SUSPENSO", reason: "Cancelamento solicitado; reembolso em andamento." };
if (status === "REFUND_PENDING") return { access: "SUSPENSO", reason: "Estorno em andamento." };
if (status === "PARTIALLY_REFUNDED") {
return policy.keepAccessOnPartialRefund ? { access: "LIBERADO", reason: "Estorno parcial; acesso mantido." } : { access: "SUSPENSO", reason: "Estorno parcial." };
}
if (status === "PAYMENT_COMPLETED" && input.order.fulfillmentEligible) return { access: "LIBERADO", reason: "Pagamento confirmado." };
return { access: "SUSPENSO", reason: "Pedido sem pagamento completo." };
}Órbita: venda por evidência
O vendedor abre a oportunidade e gera o link de pagamento (POST /oportunidades/{id}/link). O lead escolhe Pix, cartão, dois cartões ou Pix em parcelas no checkout. Quando o lead diz "já paguei", o Órbita registra a fala para o vendedor e não muda a venda.
/** Pedido + sessão de checkout da oportunidade. O link vai para o lead; o valor nunca sai do navegador dele. */
async paymentLink(id: string): Promise<OpportunityView> {
let o = this.opportunity(id);
if (o.evidence) return this.view(id);
const offer = this.offers.get(o.offerId) as Offer;
const customerId = this.opts.store.read((s) => s.leads[o.leadId]?.customerId) as string;
if (!o.orderId) {
const order = await this.client.createOrder(
{
customerId,
externalOrderReference: `oportunidade:${id}`,
amountCents: o.amountCents,
currency: "BRL",
// Dois cartões e a venda ganha exigem o total confirmado.
fulfillmentPolicy: "ON_FULLY_PAID",
description: offer.title,
items: [{ name: offer.title, quantity: 1, unitAmountCents: o.amountCents }],
metadata: { produto: PRODUCT, oportunidade: id, vendedor: o.sellerId, oferta: offer.id },
},
{ idempotencyKey: intentKey(PRODUCT, "oportunidade", id, "pedido") },
);
o = await this.patch(id, "pedido criado", (x, s) => {
x.orderId = order.id;
s.orders[order.id] = id;
});
}
if (!o.checkout || Date.parse(o.checkout.expiresAt) - this.now().getTime() < 60_000) {
const attempt = (o.checkout?.attempt ?? 0) + 1;
const session = await this.client.createCheckout(
{ orderId: o.orderId as string, allowedModes: offer.modes, returnUrl: `${this.opts.publicUrl}/obrigado/${id}`, expiresInSeconds: 86_400 },
{ idempotencyKey: intentKey(PRODUCT, "oportunidade", id, "checkout", attempt) },
);
await this.patch(id, `link de pagamento gerado (tentativa ${attempt})`, (x) => {
x.checkout = { attempt, sessionId: session.id, url: session.url, expiresAt: session.expiresAt };
});
}
await this.reconcile(id);
return this.view(id);
}/**
* Situação da venda no CRM, só a partir de evidência do provedor lida na API (B docs/10: "não pela fala do lead").
* Venda ganha exige o evento `order.payment_completed`; parcelas pagas mostram progresso, nunca venda ganha.
*/
export function saleStatusFor(f: SaleFacts): { status: SaleStatus; reason: string } {
if (f.evidence) {
if (f.order.status === "DISPUTED") {
return f.payments.some((p) => p.reversalStatus === "CHARGED_BACK") ? { status: "PERDIDA_CHARGEBACK", reason: "Chargeback perdido." } : { status: "EM_DISPUTA", reason: "O pagador contestou o pagamento." };
}
if (f.order.status === "REFUNDED") return { status: "REVERTIDA", reason: "Pagamento devolvido ao lead." };
if (f.order.status === "REFUND_PENDING") return { status: "ESTORNO_EM_ANDAMENTO", reason: "Estorno em andamento." };
return { status: "GANHA", reason: "Pagamento confirmado pelo provedor." };
}
if (f.order.status === "PARTIALLY_PAID" || f.plans.some((p) => BigInt(p.paidCents) > 0n)) return { status: "PAGAMENTO_PARCIAL", reason: "Parte do valor paga; a venda fecha com o total." };
if (f.order.status === "PAYMENT_COMPLETED") return { status: "PAGAMENTO_EM_ANDAMENTO", reason: "Aguardando o evento assinado de pedido pago." };
// A tentativa mais recente (dois cartões ou plano) diz o que o lead está fazendo agora.
const last = latest<{ createdAt: string; kind: "group" | "plan"; state: string }>([
...f.groups.map((g) => ({ createdAt: g.createdAt, kind: "group" as const, state: g.outcome })),
...f.plans.map((p) => ({ createdAt: p.createdAt, kind: "plan" as const, state: p.status })),
]);
if (last?.kind === "group") {
if (last.state.startsWith("NOT_COMPLETED")) return { status: "PAGAMENTO_NAO_CONCLUIDO", reason: "Pagamento com dois cartões não concluído; nada ficou cobrado. O lead pode tentar de novo." };
return { status: "PAGAMENTO_EM_ANDAMENTO", reason: last.state === "MANUAL_REVIEW" ? "Pagamento com dois cartões em revisão pelo suporte." : "Pagamento com dois cartões em processamento." };
}
if (last?.kind === "plan") {
if (last.state === "PENDING_ACTIVATION") return { status: "PAGAMENTO_EM_ANDAMENTO", reason: "Parcelamento sendo gerado." };
if (last.state === "FAILED" || last.state === "CANCELLED") return { status: "PAGAMENTO_NAO_CONCLUIDO", reason: "Parcelamento não concluído. O lead pode tentar de novo." };
return { status: "LINK_ENVIADO", reason: "Parcelamento gerado; aguardando a primeira parcela." };
}
return f.linkSent ? { status: "LINK_ENVIADO", reason: "Aguardando o pagamento do lead." } : { status: "ABERTA", reason: "Oportunidade sem link de pagamento." };
}Dois cartões com um recusado mostram "pagamento não concluído" (nada ficou cobrado) e o lead pode tentar de novo na mesma sessão. No Pix em parcelas, cada parcela paga aparece como progresso; a venda só é ganha com todas.
Agenda Expert: sinal, cancelamento e plano mensal
A reserva cobra o sinal por Pix direto pela API: POST /v1/payments responde 202 e o serviço espera a operação por alguns segundos. Se não terminar, responde "gerando cobrança"; a consulta seguinte mostra o QR. Uma resposta perdida do provedor (UNKNOWN) nunca gera outra cobrança.
/**
* Cobrança do sinal: a intenção (tentativa, vencimento) é gravada antes da chamada; repetir depois de uma falha de
* rede usa a mesma Idempotency-Key e o mesmo corpo, e a API devolve a operação original. Uma tentativa nova só
* depois de uma falha conclusiva (FAILED). UNKNOWN nunca gera outra cobrança.
*/
private async chargeDeposit(id: string): Promise<void> {
let b = this.booking(id);
if (!b.charge || b.status === "FALHA_NA_COBRANCA") {
const attempt = (b.charge?.attempt ?? 0) + 1;
b = await this.patchBooking(id, `cobrança do sinal (tentativa ${attempt})`, (x) => {
x.charge = { attempt, dueDate: civilDate(this.now()), operationId: null, operationStatus: null, paymentId: null, pix: null };
this.setStatus(x, "GERANDO_COBRANCA", "Gerando o Pix do sinal.");
});
}
const charge = b.charge;
if (!charge || charge.operationId) return;
const op = await this.client.createPayment(
{ orderId: b.orderId as string, method: "PIX", dueDate: charge.dueDate },
{ idempotencyKey: intentKey(PRODUCT, "agendamento", id, "sinal", charge.attempt) },
);
await this.patchBooking(id, null, (x) => {
if (x.charge) Object.assign(x.charge, { operationId: op.id, operationStatus: op.status, paymentId: op.resourceId ?? null });
});
}
/** Acompanha a operação 202 sem repetir a escrita: espera curta e, se não terminou, só consulta depois. */
async refreshCharge(id: string, waitMs = 0): Promise<void> {
const b = this.booking(id);
const operationId = b.charge?.operationId;
if (!operationId || b.status !== "GERANDO_COBRANCA") return;
let op: Operation;
try {
op = waitMs > 0 ? await this.client.waitForOperation(operationId, { timeoutMs: waitMs, intervalMs: 200 }) : await this.client.getOperation(operationId);
} catch (e) {
if (!(e instanceof OperationTimeoutError)) throw e;
op = e.operation; // ainda ACCEPTED/DISPATCHING/UNKNOWN: o cliente vê "processando"
}
if (op.status === "SUCCEEDED" && op.resourceId) {
const pix = await this.client.getPixQr(op.resourceId);
await this.patchBooking(id, "Pix do sinal disponível", (x) => {
if (x.charge) Object.assign(x.charge, { operationStatus: op.status, paymentId: op.resourceId ?? null, pix: { payload: pix.payload, encodedImage: pix.encodedImage, expiresAt: pix.expiresAt } });
if (x.status === "GERANDO_COBRANCA") this.setStatus(x, "AGUARDANDO_SINAL", "Aguardando o pagamento do sinal.");
});
} else if (op.status === "FAILED") {
await this.patchBooking(id, `cobrança do sinal recusada (${op.error?.code ?? "sem código"})`, (x) => {
if (x.charge) x.charge.operationStatus = op.status;
this.setStatus(x, "FALHA_NA_COBRANCA", "Não foi possível gerar o Pix. Tente de novo.");
});
} else {
await this.patchBooking(id, null, (x) => {
if (x.charge) x.charge.operationStatus = op.status;
x.statusReason =
op.status === "UNKNOWN" ? "Resultado em verificação pela plataforma; não gere outra cobrança." : op.status === "MANUAL_REVIEW" ? "Cobrança em revisão pelo suporte." : "Gerando o Pix do sinal.";
});
}
}O cancelamento segue a política do produto, separada do pagamento: com antecedência mínima o sinal é devolvido (estorno 202, concluído pelo evento); em cima da hora fica retido, sem chamada de estorno. Se a API recusar a devolução (por exemplo, saldo insuficiente), o agendamento fica com a devolução pendente para o suporte, que repete com a mesma chave.
/** Decide o que fazer com o sinal ao cancelar. Pura: o estorno em si é uma intenção 202 na API. */
export function cancellationDecision(input: { depositPaid: boolean; startsAt: Date; now: Date }, policy: CancellationPolicy): { decision: CancellationDecision; noticeHours: number } {
const noticeHours = (input.startsAt.getTime() - input.now.getTime()) / 3_600_000;
if (!input.depositPaid) return { decision: "SEM_PAGAMENTO", noticeHours };
return { decision: noticeHours >= policy.fullRefundMinNoticeHours ? "DEVOLVER_SINAL" : "RETER_SINAL", noticeHours };
}O profissional assina o plano mensal (Individual ou Empresarial, preços sintéticos) por Pix. O provedor gera as cobranças; cada ciclo pago conta pelo order.payment_completed do pedido do ciclo.
/** Assina o plano mensal por Pix: a assinatura gera as cobranças no provedor; o produto não cria cobranças. */
async subscribe(professionalId: string, person: PersonInput, tier: PlanTier) {
const plan = EXAMPLE_PLANS[tier];
if (!this.opts.store.read((s) => s.professionals[professionalId])) {
const customer = await this.client.createCustomer(
{ name: person.name, taxId: person.taxId, ...(person.email ? { email: person.email } : {}), externalReference: `profissional:${professionalId}` },
{ idempotencyKey: intentKey(PRODUCT, "profissional", professionalId, "cliente") },
);
await this.opts.store.update((s) => {
s.professionals[professionalId] ??= { id: professionalId, customerId: customer.id, plan: null, history: [] };
});
}
let p = this.professional(professionalId);
const live = p.plan && LIVE_PLAN.includes(p.plan.status);
if (live && p.plan?.operationId) return this.planView(professionalId);
if (!live) {
const attempt = (p.plan?.attempt ?? 0) + 1;
p = await this.patchProfessional(professionalId, `plano ${tier.toLowerCase()} solicitado`, (x) => {
x.plan = { tier, attempt, nextDueDate: addDays(civilDate(this.now()), this.opts.firstDueInDays ?? 3), operationId: null, subscriptionId: null, subscriptionStatus: null, status: "AGUARDANDO_ATIVACAO", paidOrders: [], cancelRequest: null };
});
}
const intent = p.plan as ProfessionalPlan;
const op = await this.client.createSubscription(
{
customerId: p.customerId,
method: "PIX",
amountCents: EXAMPLE_PLANS[intent.tier].amountCents,
cycle: "MONTHLY",
nextDueDate: intent.nextDueDate,
description: EXAMPLE_PLANS[intent.tier].title,
externalReference: `profissional:${professionalId}:plano:${intent.attempt}`,
},
{ idempotencyKey: intentKey(PRODUCT, "profissional", professionalId, "plano", intent.attempt) },
);
await this.patchProfessional(professionalId, `assinatura ${plan.title} aceita (202)`, (x) => {
if (x.plan) Object.assign(x.plan, { operationId: op.id, subscriptionId: op.resourceId ?? null });
});
return this.planView(professionalId);
}Como rodar
export ECLIPTA_PAY_API="<endereço da API do ambiente de testes>"
export ECLIPTA_PAY_KEY="ep_test_..." # chave própria do produto
export ECLIPTA_PAY_ACCOUNT="acc_..."
export PUBLIC_URL="https://educators.exemplo.com.br"
# 1. Cadastra o endpoint de webhook e mostra o segredo uma única vez (guarde no cofre).
pnpm --filter @eclipta-pay/example-educators registrar-webhook
# 2. Sobe o serviço com o segredo (dois, separados por vírgula, durante a rotação).
export ECLIPTA_PAY_WEBHOOK_SECRETS="whsec_..."
pnpm --filter @eclipta-pay/example-educators startpnpm vitest run --project integration tests/integration/src/reference-*.int.test.tsLimitações conhecidas
- Não há cadastro de recebedores para split de coprodutores, e o campo
splitsdePOST /v1/paymentsainda não tem efeito: não o use. GET /v1/paymentsnão filtra por pedido: os exemplos guardam o vínculo cobrança → pedido a partir dos eventos de cobrança (payment.created,payment.confirmed).- Os eventos não trazem o produto da chave: cada produto reconhece os próprios pedidos e ignora os demais.
- Assinatura no cartão ainda não tem página de captura própria (veja Assinaturas); os exemplos usam Pix.