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.
npx -y skills add cristianorj22/arthus-harness --skill asaas-integrationAssembled 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:
- Webhook authenticity (HMAC, allow-list).
- Idempotency (replay-safe).
- Server-side amount (never trust client).
- Error code translation (pt-BR friendly).
- 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 error | User-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_idor 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 timeoutsrc/integrations/asaas/webhook-handler.ts— token validation + redactionsrc/integrations/asaas/idempotency.ts— replay protection- Asaas docs: https://docs.asaas.com/docs/webhooks