Payment and Compliance

Mar 18, 2026
Solution Architect

Nearly every software application we build and ship to production will, at some point, need to take payment from a customer. A SaaS product collects subscriptions. A marketplace facilitates transactions between buyers and sellers. An e-commerce store processes card payments at checkout. Even a side project that gains traction eventually needs a way to charge for the value it provides.

The moment your system handles money, the rules change. You are no longer just writing code that works — you are writing code that must satisfy payment networks, financial regulators, data protection authorities, and the security teams of every bank in the chain. Understanding how to comply with these standards is not a niche specialization; it is a core competency for any software engineer building production systems.

I have been exploring this space and it quickly became clear: building a payment system that processes transactions is one problem. Building one that does not get you fined, frozen, or shut down is a different problem entirely. Compliance gaps often go unnoticed until an audit surfaces them — and by then they are far more expensive to fix.

Compliance is not a feature you add later. It is a set of constraints that shapes your database schema, your API contracts, your logging pipeline, and your deployment topology from day one. Here is what matters most.


PCI DSS — Never Touch Raw Card Data

The Payment Card Industry Data Security Standard is the baseline. If you accept, process, store, or transmit cardholder data, you are in scope. The question is how much scope you take on.

There are four compliance levels, determined by annual transaction volume:

LevelAnnual TransactionsValidation Requirement
1Over 6 millionOn-site audit by QSA
21–6 millionSAQ + quarterly network scan
320,000–1 million (e-commerce)SAQ + quarterly network scan
4Under 20,000 (e-commerce)SAQ recommended

The safest pattern is: never let card data touch your servers. Use the PSP's client-side SDK to tokenize the card in the browser, and send the token to your backend. Your server never sees the PAN, CVV, or expiry date. This keeps you at SAQ-A — the smallest scope — instead of SAQ-D, which requires hundreds of controls across your entire infrastructure.

The critical detail: your backend receives tok_xxx, never 4242 4242 4242 4242. The tokenization happens entirely on the client side, between the browser and the PSP's servers over TLS.

async function createCharge(req: Request) {
  const { token, amount, currency, idempotencyKey } = await req.json()
 
  // token is "tok_xxx" — we never see the raw card number
  const charge = await stripe.charges.create(
    {
      amount,
      currency,
      source: token,
      metadata: { idempotency_key: idempotencyKey },
    },
    { idempotencyKey }
  )
 
  return { id: charge.id, status: charge.status }
}

If you are building a platform where merchants enter card details through your UI, use Stripe Elements, Adyen Drop-in, or the equivalent hosted fields component instead. The moment raw card data hits your server logs, your database, or even your server's memory, your PCI scope explodes and so does your audit cost.


Strong Customer Authentication and 3D Secure

PSD2 in Europe mandates Strong Customer Authentication for most online payments. SCA requires two of three factors: something the customer knows (password, PIN), something they have (phone, hardware token), or something they are (fingerprint, face). 3D Secure 2 is the protocol that implements this for card payments.

The practical impact: your checkout flow now has an additional redirect or challenge step for many transactions. The PSP handles the 3DS flow, but your system needs to handle the asynchronous nature of it.

Exemptions

Not every transaction requires SCA. Understanding exemptions is important for keeping conversion rates healthy:

  • Low-value transactions — Under €30 (up to a cumulative €100 or 5 consecutive low-value transactions)
  • Recurring payments — SCA on the first payment, exemptions on subsequent fixed-amount charges
  • Transaction Risk Analysis (TRA) — PSPs with low fraud rates can request exemptions for transactions below certain thresholds
  • Merchant-initiated transactions — Charges initiated by the merchant (subscriptions, metered billing) after an initial authenticated setup

It is easy to treat 3DS as a European problem, but similar regulations are appearing in India (RBI mandates for recurring payments), Brazil, Australia, and elsewhere. It is worth building your payment flow to handle authentication challenges from the start, even if your initial market does not require it.


KYC/AML — Know Your Customer

If your platform moves money — marketplace payouts, merchant settlements, wallet top-ups — you are subject to Know Your Customer and Anti-Money Laundering regulations. This is not optional. Regulators have shut down platforms, frozen funds, and issued eight-figure fines for non-compliance.

What KYC requires

At onboarding, you need to verify the identity of anyone who receives funds through your platform:

  • Individuals — Government-issued ID, proof of address, sometimes source of funds
  • Businesses — Business registration documents, beneficial ownership (who owns 25%+), director verification
  • Ongoing monitoring — Sanctions screening (OFAC, EU sanctions lists), Politically Exposed Person (PEP) checks, unusual activity detection

Tiered KYC

A proportional approach works well in practice. Low-risk users with small volumes get a lighter verification. As volume or risk increases, require progressively more documentation.

interface KycTier {
  name: string
  maxMonthlyVolume: number
  requirements: string[]
}
 
const KYC_TIERS: KycTier[] = [
  {
    name: 'basic',
    maxMonthlyVolume: 1_000_00, // $1,000 in cents
    requirements: ['email_verified', 'phone_verified'],
  },
  {
    name: 'standard',
    maxMonthlyVolume: 10_000_00, // $10,000
    requirements: ['email_verified', 'phone_verified', 'id_document', 'address_proof'],
  },
  {
    name: 'enhanced',
    maxMonthlyVolume: Infinity,
    requirements: [
      'email_verified',
      'phone_verified',
      'id_document',
      'address_proof',
      'source_of_funds',
      'beneficial_ownership',
    ],
  },
]
 
function canProcessPayout(user: { kycTier: string; monthlyVolume: number }): boolean {
  const tier = KYC_TIERS.find((t) => t.name === user.kycTier)
  if (!tier) return false
  return user.monthlyVolume < tier.maxMonthlyVolume
}

Do not design KYC as a one-time gate at signup. It is an ongoing process. Users who pass verification today can end up on a sanctions list tomorrow. Run periodic re-screening and transaction monitoring. If you are using a service like Stripe Connect, Adyen for Platforms, or a dedicated KYC provider like Onfido or Jumio, they handle much of this — but the responsibility is still yours.


Audit Trails and Immutable Logs

In financial systems, every state change needs a record. Not for debugging — for legal defense, regulatory audits, and dispute resolution.

The pattern that works: an append-only event log. No UPDATE, no DELETE. Every mutation to a financial record produces a new event with the full context of what changed, who changed it, and why.

CREATE TABLE payment_audit_log (
    id            BIGSERIAL PRIMARY KEY,
    event_type    TEXT NOT NULL,
    entity_type   TEXT NOT NULL,
    entity_id     TEXT NOT NULL,
    actor_type    TEXT NOT NULL, -- 'user', 'system', 'admin', 'webhook'
    actor_id      TEXT NOT NULL,
    old_state     JSONB,
    new_state     JSONB,
    metadata      JSONB NOT NULL DEFAULT '{}',
    ip_address    INET,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE INDEX idx_audit_entity ON payment_audit_log (entity_type, entity_id);
CREATE INDEX idx_audit_created ON payment_audit_log (created_at);
interface AuditEvent {
  eventType: string
  entityType: 'payment' | 'refund' | 'payout' | 'dispute'
  entityId: string
  actorType: 'user' | 'system' | 'admin' | 'webhook'
  actorId: string
  oldState: Record<string, unknown> | null
  newState: Record<string, unknown>
  metadata: Record<string, unknown>
  ipAddress: string | null
}
 
async function recordAuditEvent(db: Database, event: AuditEvent): Promise<void> {
  await db.query(
    `INSERT INTO payment_audit_log
     (event_type, entity_type, entity_id, actor_type, actor_id, old_state, new_state, metadata, ip_address)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
    [
      event.eventType,
      event.entityType,
      event.entityId,
      event.actorType,
      event.actorId,
      JSON.stringify(event.oldState),
      JSON.stringify(event.newState),
      JSON.stringify(event.metadata),
      event.ipAddress,
    ]
  )
}

Immutability matters because when a customer disputes a charge six months later, you need to reconstruct exactly what happened — what the payment state was at each point, who touched it, and what the system's automated decisions were. If your audit log allows updates, its integrity can be questioned. If it is append-only and tamper-evident, it becomes your strongest asset in a dispute.

On tables that hold financial records (charges, refunds, ledger entries), it is good practice to disable UPDATE and DELETE at the application layer and enforce it with database triggers or row-level security. Corrections are modeled as new entries — a refund reverses a charge, an adjustment corrects a ledger balance. The original record is never modified.


Data Retention, GDPR, and the Right to Erasure

Financial regulations and privacy regulations pull in opposite directions. Tax authorities typically require you to retain transaction records for 6–10 years. AML regulations may require 5 years after a business relationship ends. GDPR gives users the right to request deletion of their personal data.

The solution is not deletion — it is pseudonymization. When a user exercises their right to erasure, you replace personally identifiable information with irreversible tokens while keeping the financial record intact for regulatory purposes.

Data TypeRetention PeriodErasure Strategy
Transaction records7 years (tax)Pseudonymize PII, retain amounts and dates
KYC documents5 years after relationship ends (AML)Delete after retention period
Payment method tokensUntil customer removes or account closureDelete on request
Audit logs7 yearsPseudonymize actor identifiers
Customer PII (name, email, address)Duration of relationshipPseudonymize on erasure request
IP addresses and device data90 days for fraud, then purgeAuto-purge on schedule

The implementation: maintain a mapping between a user_id and a pseudonym_id. On erasure, replace all occurrences of the user's PII across your system with the pseudonym. The financial records remain complete for auditors — they show that a charge of $49.99 was made on a date by pseudonym usr_anon_8f3a2b — but the personal identity is gone.


Reconciliation and Settlement

Reconciliation is how you prove your books are correct. In payment systems, that means your internal ledger, the PSP's records, and the bank statements must all agree. When they do not, you have a problem — and you need to find it before your auditor does.

Three-way reconciliation runs daily:

  1. Internal ledger vs. PSP — Every charge, refund, and payout in your system should have a matching record in the PSP's settlement report. Mismatches indicate failed webhooks, dropped events, or timing differences.

  2. PSP vs. bank — The PSP's settlement payout should match the credit on your bank statement. Differences come from fees, FX conversions, reserves, or chargebacks deducted at settlement.

  3. Internal ledger vs. bank — The end-to-end check. Your expected balance, after accounting for PSP fees and timing, should match what is in the bank.

When reconciliation runs late, small problems compound quickly. You might miss a PSP payout that was short, fail to catch a refund processed twice, or not notice a chargeback deducted but never recorded in your system. By the time someone notices, you are reconciling months of drift and finance teams are rebuilding trust in numbers that should have been verified daily.


Chargebacks and Dispute Handling

A chargeback is a forced reversal initiated by the cardholder's bank. The merchant loses the funds, pays a fee ($15–$100 per chargeback depending on the processor), and takes a hit to their chargeback ratio. Visa and Mastercard monitor this ratio, and if it exceeds their thresholds (typically 0.9%–1% of transactions), they place you in a monitoring program with escalating fines and, eventually, termination.

The lifecycle

Your audit trail is your defense. When you receive a dispute, you need to assemble evidence quickly:

  • The original transaction record with timestamps
  • Proof of delivery or service fulfillment
  • Customer communication history
  • IP address and device fingerprint at time of purchase
  • 3DS authentication result (liability shift)
  • Refund policy the customer agreed to

Treating chargebacks as purely an operations problem means you lose winnable disputes because the evidence is scattered across systems, incomplete, or takes too long to assemble. Building evidence collection into your payment event pipeline means that when a dispute arrives, the package is ready to submit.


Common Mistakes to Avoid

Logging full card numbers. This can happen when a debug log statement captures the raw request body, which includes the PAN. Now your log aggregator is in PCI scope. Always scrub sensitive fields before they hit any log pipeline — or better yet, use the tokenization pattern so the PAN never reaches your server in the first place.

Treating PCI as a checkbox exercise. Passing a SAQ does not mean you are secure. It means you have attested that you follow certain practices at a point in time. Continuous compliance — automated scanning, dependency updates, access reviews — is what actually protects you.

Not planning for multi-jurisdiction requirements. Payment regulations vary by country. India requires domestic data storage. Brazil has specific installment payment regulations. The EU has PSD2/SCA. Hardcoding assumptions about a single regulatory environment means expanding to a new market becomes a rewrite, not a configuration change.

Hardcoding a single currency. Store amounts in the smallest unit (cents, pence) as integers, but always pair them with a currency code. Some currencies have zero decimal places (JPY), others have three (KWD). A system that assumes two decimal places will eventually produce incorrect amounts.

Ignoring webhook signature verification. PSPs sign their webhooks. Verify the signature on every incoming event. Without verification, an attacker can forge webhook payloads — marking charges as succeeded when they did not, triggering fraudulent payouts, or corrupting your ledger.

function verifyWebhookSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const expected = createHmac('sha256', secret).update(payload).digest('hex')
 
  return timingSafeEqual(
    Buffer.from(signature, 'hex'),
    Buffer.from(expected, 'hex')
  )
}

Always use constant-time comparison. A naive === comparison leaks timing information that can be used to forge signatures byte by byte.


The Takeaway

Compliance is not a layer you bolt on top of a payment system. It is the payment system. PCI scope determines your architecture. KYC requirements shape your onboarding flow. Audit requirements dictate your data model. Retention policies constrain your storage design. Chargeback handling depends on your event pipeline.

The engineers who build reliable payment systems share one trait: they read the regulations before they write the code. Not because they enjoy regulatory documents, but because discovering a compliance gap in production is orders of magnitude more expensive than discovering it in a design review. The code is the easy part. Understanding what the code is required to do — and what it is forbidden to do — is the actual work.