agentsclimarketplace

Asaas integration

Skill cristianorj22/arthus-harness/plugins/payment-asaas/claude/skills/asaas-integration

Opinionated Claude Code scaffolder — agents, skills, hooks, slash commands. Like create-t3-app, but for your .claude/. Install months of Claude Code discipline in 30 seconds.

Install
npx -y skills add cristianorj22/arthus-harness --skill asaas-integration

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

Asaas payment integration patterns — webhook HMAC validation, idempotency, error code mapping to pt-BR, log redaction, server-side amount source. Use when touching supabase/functions/asaas-* or src/integrations/asaas/.

SKILL.md

7.4 KB, as published. Nobody here has run it

Asaas integration — pattern

Money in this kind of system is held in escrow until the event happens. A wrong line of code here costs users real BRL.

What this skill enforces

Five non-negotiable rules:

  1. Webhook authenticity (HMAC, allow-list).
  2. Idempotency (replay-safe).
  3. Server-side amount (never trust client).
  4. Error code translation (pt-BR friendly).
  5. PII / card data redaction (logs).

1. Webhook authenticity

Every Asaas webhook handler validates origin before doing anything:

// supabase/functions/asaas-webhook/index.ts
import { createHmac } from 'node:crypto';

Deno.serve(async (req) => {
  const signature = req.headers.get('asaas-access-token') || '';
  const expected = Deno.env.get('ASAAS_WEBHOOK_TOKEN');

  if (!signature || signature !== expected) {
    return new Response('Unauthorised', { status: 401 });
  }

  // OR (when Asaas adopts HMAC signing)
  // const body = await req.text();
  // const sig = req.headers.get('x-asaas-signature') || '';
  // const computed = createHmac('sha256', Deno.env.get('ASAAS_WEBHOOK_SECRET')!).update(body).digest('hex');
  // if (!timingSafeEqual(sig, computed)) return new Response('Unauthorised', { status: 401 });

  // ... continue
});

Without this gate, anyone with the function URL can post fake "payment received" events and credit a host. Treated as CRITICAL vulnerability if missing.

Belt + braces: also validate IP (req.headers.get('x-forwarded-for')) against Asaas IP allow-list when documented.

2. Idempotency

Every webhook delivery may arrive multiple times (Asaas retries on 5xx, 408, 429). Replay must NOT double-credit.

Pattern: store asaas_payment_id (or asaas_charge_id) as UNIQUE and use upsert / on conflict:

-- migration
create table public.payments (
  id bigint generated by default as identity primary key,
  asaas_payment_id text unique not null,  -- ★ idempotency anchor
  status text not null,
  amount numeric(12, 2) not null,
  ...
);

-- handler
insert into payments (asaas_payment_id, status, amount, ...)
values (...)
on conflict (asaas_payment_id) do update
set status = excluded.status,
    updated_at = now()
where payments.status != excluded.status;  -- ★ idempotent: no-op if status unchanged

The webhook handler returns 200 in all idempotent cases — Asaas stops retrying.

3. Server-side amount

The amount charged is always computed server-side from the venue's stored price. Never accept the client's claim.

// ❌ BAD — trusts client
const { amount, venueId, dates } = await req.json();
const charge = await asaas.createPayment({ value: amount, ... });

// ✅ GOOD — server reads price
const { venueId, dates, addOns } = await req.json();
const { data: venue } = await supabase.from('venues').select('price_per_day').eq('id', venueId).single();
const days = computeDays(dates);
const subtotal = venue.price_per_day * days;
const addOnTotal = addOns.reduce((sum, a) => sum + a.price, 0);
const fee = (subtotal + addOnTotal) * PLATFORM_FEE_RATE;
const total = subtotal + addOnTotal + fee;
const charge = await asaas.createPayment({ value: total, ... });

Same pattern for refunds — server computes the refund amount per the venue's cancellation policy, not from a client field.

4. Error code → pt-BR mapping

When Asaas returns an error, surface a friendly Portuguese message — never the raw code:

Asaas errorUser-facing pt-BR
INSUFFICIENT_FUNDS"Cartão sem saldo. Tente outro?"
INVALID_CARD_NUMBER"Número do cartão parece inválido. Confira os dígitos."
CARD_DECLINED"Banco recusou o pagamento. Tente outro cartão ou PIX."
EXPIRED_CARD"Esse cartão venceu. Use outro?"
INVALID_CVV"Confira o código de segurança do cartão."
INVALID_HOLDER_NAME"Nome no cartão precisa bater com o cadastro."
BLOCKED_CARD"O banco bloqueou esse cartão. Tente PIX ou outro cartão."
INVALID_CPF"CPF inválido. Confira os dígitos."
(genérico)"Não rolou. Tente novamente em alguns instantes."

Implementation lives in src/integrations/asaas/client.ts (mapAsaasError).

Never expose technical codes to the user. Log them server-side with a request ID for support traceability.

5. Log redaction

Asaas webhooks include card data (last 4 digits OK, full PAN never), CPF (PII), email (PII). Logs and console output redact these fields:

function redactForLog(payload: any): any {
  return {
    ...payload,
    customer: payload.customer ? {
      name: payload.customer.name,
      cpfCnpj: payload.customer.cpfCnpj?.slice(0, 3) + '***' + payload.customer.cpfCnpj?.slice(-2),
      email: redactEmail(payload.customer.email),
    } : undefined,
    creditCard: payload.creditCard ? {
      lastFour: payload.creditCard.creditCardNumber?.slice(-4),
      brand: payload.creditCard.creditCardBrand,
      // never log: number, ccv, holderName
    } : undefined,
  };
}

console.log('[asaas-webhook]', JSON.stringify(redactForLog(payload)));

Same applies to Sentry / observability — set scrubbing rules on cpfCnpj, creditCard.*, email, phone.

Common silent failures

  • ❌ Webhook handler with try { ... } catch { /* swallow */ } — Asaas thinks it succeeded; we lose the event.
  • ❌ Returning 500 on a parse error → Asaas retries 10 times → log spam → real issue masked.
  • await asaas.createPayment(...).catch(() => null) — payment failure becomes silent null; user thinks they paid.
  • ❌ Missing timeout on outbound HTTP. Asaas API down = our edge function hangs 30s → cold-start penalty → user sees spinner forever.

Pattern:

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
  const res = await fetch(asaasUrl, { signal: controller.signal, ... });
  // ...
} catch (err) {
  if (err.name === 'AbortError') {
    // log + surface "Asaas demorou demais. Tenta de novo?" to user
  }
  throw err; // re-raise, don't swallow
} finally {
  clearTimeout(timeout);
}

Already implemented in src/integrations/asaas/client.ts (asaasRequest).

Audit checklist

When touching Asaas-adjacent code:

  • Webhook handler validates origin (token / HMAC).
  • Idempotency anchor (asaas_payment_id or equivalent) is stored UNIQUE.
  • Replay of same webhook payload does not double-credit / charge.
  • Amount comes from server (DB), not from request body.
  • All Asaas error codes have pt-BR mapping; no raw code reaches user.
  • Logs redact CPF, full card numbers, email, phone.
  • Outbound calls to Asaas have 5s timeout + abort.
  • Errors are propagated, not swallowed.
  • Webhook handler returns 200 on success/idempotent-replay; only 5xx for transient failures.

See also

  • src/integrations/asaas/client.ts — env-driven Asaas client with timeout
  • src/integrations/asaas/webhook-handler.ts — token validation + redaction
  • src/integrations/asaas/idempotency.ts — replay protection
  • Asaas docs: https://docs.asaas.com/docs/webhooks

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.