$ cd ../blog
$ cat ./brazilian-fintech-regulations.md

the hidden complexity of brazilian financial regulations in code

What happens when tax law meets TypeScript: implementing IOF, CPF validation, PIX payments, and BaaS compliance in a production fintech

date: October 7, 2024

read: 15 min

tags: fintech, compliance, brazil, typescript, regulations

Building a fintech platform isn't just about writing clean code—it's about encoding an entire country's financial regulatory framework into your application. When that country is Brazil, the complexity multiplies exponentially.

After shipping a production microfinance platform to the Brazilian market, I learned that regulatory compliance isn't a feature you add—it's a dimension that touches every layer of your architecture. Here's what that actually looks like in code.

IOF: The Tax That Changes Everything

Brazil has a unique tax called IOF (Imposto sobre Operações Financeiras) that applies to credit operations. Unlike most taxes that are just a percentage, IOF is compound: it has both a fixed rate and a daily rate based on loan term.

The Math Behind IOF

const { fixedIofPercent, dailyIofPercent } = await
this.taxRepository.findIofRates();
const fixedIofRate = fixedIofPercent.value / 100;  // e.g., 0.38%
const dailyIofRate = dailyIofPercent.value / 100;   // e.g., 0.0082% per
day

// Calculate days from today to last installment
const today = new Date();
const lastDate = addMonths(firstDueDate, numberInstallments - 1);
const termDays = Math.ceil((lastDate.getTime() - today.getTime()) /
MS_PER_DAY);

// Compound IOF calculation
const totalIofRate = fixedIofRate + dailyIofRate * termDays;
const amountWithIof = requestedAmount * (1 + totalIofRate);

The gotcha: IOF must be calculated before amortization, but it affects every installment calculation. A 90-day loan pays significantly more IOF than a 30-day loan for the same amount.

This means your loan simulator can't just multiply rates—it needs to know:

  • Exact dates of each installment
  • Whether those dates fall on weekends/holidays (adjusted to next business day)
  • The actual day count from disbursement to final payment

Real Impact

For a R$1,000 loan:

  • 30 days: ~R$4.84 IOF
  • 90 days: ~R$11.18 IOF
  • 180 days: ~R$18.76 IOF

Your UI needs to show this transparently, or you'll fail regulatory disclosure requirements.

CPF: More Than Just Validation

Every Brazilian has a CPF (Cadastro de Pessoas Físicas)—think SSN but with a checksum algorithm. You can't just regex-validate it; you need to verify the check digits.

The Algorithm

export const isValidCPF = (cpf: string): boolean => {
  const cleanCPF = cpf.replace(/\D/g, '');

  if (cleanCPF.length !== 11) return false;
  if (/^(\d)\1+$/.test(cleanCPF)) return false; // All same digits

  // Calculate first check digit
  let sum = 0;
  for (let i = 0; i < 9; i++) {
    sum += parseInt(cleanCPF[i]) * (10 - i);
  }
  let digit1 = 11 - (sum % 11);
  if (digit1 >= 10) digit1 = 0;

  // Calculate second check digit
  sum = 0;
  for (let i = 0; i < 10; i++) {
    sum += parseInt(cleanCPF[i]) * (11 - i);
  }
  let digit2 = 11 - (sum % 11);
  if (digit2 >= 10) digit2 = 0;

  return cleanCPF[9] === digit1.toString() &&
         cleanCPF[10] === digit2.toString();
};

But here's where it gets interesting: CPF must match across every integration.

The Compliance Chain

  1. User enters CPF in your app
  2. CPF must match government ID in KYC verification
  3. CPF must match Open Banking account ownership
  4. CPF must match BaaS provider records
  5. CPF must match PIX key registration

A mismatch at any step means instant rejection—no manual override allowed.

// From KYC webhook handler
const cpfFromUser = user.cpf;
const cpfFromProfile = kycProfile.data.personal.cpfNumber;

if (cpfFromUser !== cpfFromProfile) {
  await this.rejectUser(user.id, userOnboardingId);
  throw new UnprocessableEntityException(
    'User data does not match with KYC profile information.'
  );
}

One typo in onboarding? User can't get credit. Period.

PIX: The Payment Protocol That's Actually Well-Designed

Brazil's instant payment system (PIX) is genuinely impressive from a technical standpoint. It's always-on, instant, and has zero fees for individuals.

The API Surface

interface IInstantPixPaymentPayload {
  calendario: {
    expiracao: number; // Lifetime in seconds
  };

  devedor?: {
    nome: string;
    cpf: string; // Of course CPF is required
  };

  valor: {
    original: string; // Format: "1000.00" (always 2 decimals)
    modalidadeAlteracao?: 0 | 1; // Can payer change amount?
  };

  chave?: string; // PIX key (phone, email, CPF, or random)

  solicitacaoPagador?: string; // Free text for payer (max 140 chars)
}

Key insight: PIX has two modes—fixed amount or flexible. For loan collections, you want fixed. For donations/tips, you want flexible. Choosing wrong = compliance violation.

The Webhook Dance

PIX is webhook-driven. You create a charge, you get a pixCopiaECola (QR code text), user scans, payment happens, then you get notified.

// PIX webhook payload
{
  txid: "7d2e90f1e3b...",
  status: "CONCLUIDA", // or ATIVA, REMOVIDA_PELO_USUARIO_RECEBEDOR
  valor: { original: "150.00" },
  devedor: { cpf: "12345678901" }
}

The edge case that will bite you: PIX payments can be reversed up to 90 days later if fraud is detected. Your accounting system needs to handle:

  • Payment received → mark installment paid
  • Reversal received → mark installment unpaid + penalty
  • User already paid next installment → complex ledger reconciliation

We learned this the hard way.

BaaS Integration: Banking as a Service, Bureaucracy as a Standard

To actually disburse loans, you need a BaaS (Banking as a Service) provider. In Brazil, that means integrating with providers like Celcoin or Dock.

The Originator Pattern

Brazilian regulations require you to register as an "originator" before creating any loans:

async createOriginatorPerson(originatorData: IBaasOriginatorPerson) {
  const response = await this.baasClient.post(
    '/banking/originator/persons',
    {
      name: user.full_name,
      taxNumber: user.cpf, // Again with the CPF
      email: user.email,
      phone: user.phone,
      address: {
        street: user.address.street,
        number: user.address.number,
        neighborhood: user.address.neighborhood,
        city: user.address.city,
        state: user.address.state,
        postalCode: user.address.cep, // Brazilian postal code
      }
    }
  );
  return response.data;
}

Each user needs:

  1. Person record created
  2. Documents uploaded (ID, proof of address, selfie)
  3. Manual approval by BaaS compliance team (2-5 business days)
  4. Only then can you create a loan application

Document Requirements

Brazilian regulation requires specific document types:

type BaasDocumentType =
  | 'RG_FRENTE'           // National ID (front)
  | 'RG_VERSO'            // National ID (back)
  | 'CNH'                 // Driver's license
  | 'COMPROVANTE_RESIDENCIA' // Proof of address (last 90 days)
  | 'SELFIE'              // Liveness check
  | 'CONTRATO_SOCIAL';    // For companies

Wrong document type? Rejected. Document older than 90 days? Rejected. Photo quality too low? Rejected.

Delay Penalties: Compound Interest on Steroids

When a borrower misses a payment, Brazilian law allows specific penalty structures:

const calculateDelayPenalty = (
  amortization: Amortization,
  loanBaseRate: number,
  userScore: number
) => {
  const delayDays = diffDays(amortization.payment_date);

  // Daily penalty interest (from regulations)
  const dailyPenaltyRate = getDailyRate(0.02); // 2% per month = ~0.0657%
per day
  const delayInterest =
    amortization.amount_payment * dailyPenaltyRate * delayDays;

  // Delay fee (based on user's credit score)
  const feesRate = loanRules.find(
    rule => userScore >= rule.minScore && userScore <= rule.maxScore
  )!.fees;
  const delayFee = amortization.amount_payment * feesRate;

  return {
    delay_penalty_interest_amount: roundDecimalNumber(delayInterest),
    delay_fees_amount: roundDecimalNumber(delayFee),
    total_owed: amortization.amount_payment + delayInterest + delayFee
  };
};

Critical detail: Penalty calculations must be disclosed upfront in the loan contract, or they're unenforceable. Your contract generator needs to include:

  • Daily penalty rate
  • Maximum penalty cap
  • Examples showing actual amounts for 7, 15, 30 days late

The Automated Accounting Requirement

Brazilian financial regulations require double-entry bookkeeping for every transaction. Not "nice to have"—mandatory.

Every loan creates entries:

// Loan disbursement
await accounting.loanDisbursed({
  loanId: loan.id,
  principal_cents: loan.amount * 100,
  iof_cents: loan.total_iof * 100,
  disbursementDate: new Date()
});

// Creates entries: // DR: Loans Receivable 1,000.00 // DR: IOF Receivable 11.18 // CR: Cash 1,011.18

When payment comes in:

// Payment received
await accounting.paymentReceived({
  amortizationId: installment.id,
  amount_cents: 120.50 * 100,
  receivedAt: pixWebhook.paidAt
});

// Creates entries: // DR: Cash 120.50 // CR: Loans Receivable 100.00 // CR: Interest Revenue 20.50

Miss an accounting entry? Fail a Central Bank audit.

KYC Webhooks: The Async Compliance Nightmare

Brazilian KYC providers (like IDWall) verify identity asynchronously via webhooks:

@Post('/kyc/webhook')
async handleKycWebhook(@Body() payload: KycWebhookDto) {
  switch (payload.status) {
    case 'Finished':
      // User approved - complete onboarding
      await this.updateUserStatus(payload.profileRef, 'VERIFIED');
      break;

    case 'WaitingManualAction':
      // Needs human review
      await this.updateUserStatus(payload.profileRef, 'PENDING_MANUAL');
      break;

    case 'Invalid':
      // User rejected - deactivate account
      await this.rejectUser(payload.profileRef);
      break;
  }
}

The problem: Webhooks can arrive:

  • Out of order
  • Multiple times (must be idempotent)
  • Hours or days later
  • Never (need timeout fallback)

Your state machine needs to handle all of these gracefully while keeping the user informed.

Lessons Learned

  1. Regulations Are Not Edge Cases

IOF, CPF validation, PIX integrations—these aren't "nice to haves." They're table stakes. Build them into your core domain model from day one.

  1. Every Integration Point Is a Failure Point

BaaS, KYC, PIX, Open Banking—each has different error modes, retry logic, and timeout behaviors. Instrument everything.

  1. Timezone Hell Is Real

Brazil uses GMT-3, but daylight saving rules change yearly. Always store UTC in database, convert to America/Sao_Paulo for display:

const brazilDate = new Date(${dateString}T23:59:00-03:00);

  1. Document Everything

When auditors come (and they will), you need to prove every calculation. We added audit logs for every financial transaction:

await auditLog.insert({
  userId: user.id,
  action: 'LOAN_CALCULATION',
  parameters: {
    amount,
    installments,
    iofRate,
    dailyIofRate,
    termDays,
    totalIof
  },
  result: loanOffer
});
  1. Test With Real Data

CPF check digit algorithms, IOF calculations, PIX payloads—unit tests aren't enough. Use real anonymized production data in staging.

The Bottom Line

Building a Brazilian fintech taught me that code is just the syntax—the real language is regulation.

You're not just building a loan calculator; you're implementing:

  • Tax law (IOF)
  • Identity verification (CPF)
  • Payment protocols (PIX)
  • Banking regulations (BaaS)
  • Consumer protection (penalty disclosures)
  • Accounting standards (double-entry)

Each one has edge cases that will surprise you. But getting them right isn't optional—it's the difference between a compliant platform and a regulatory liability.

Would I do it again? Absolutely. Few engineering challenges are as satisfying as seeing a user get approved for credit in 90 seconds because your code correctly calculated IOF for a 73-day loan term with a first installment falling on a Monday.


Built with: NestJS, Prisma, TypeScript, PostgreSQL, Redis, AWS

Stack: React 19, Material UI, Zustand, React Query, Formik, Zod

$