Skip to project
pedromartins.tech
Complete
FinTech / Microservices2026

BNPL Platform

Built a BNPL microservices platform where money remains exact, retries remain safe, and concurrent payments cannot silently double-apply.

Role
Backend and platform engineer
Environment
Containerized full-stack demonstration
Ownership
Go services · Transactional model · Search · Authentication · Dashboard · Docker orchestration · Tests
View source
BNPL Platform project visual
bnpl-ledger5 system boundaries
Merchant UI
Merchant API
BNPL Engine
PostgreSQL
3independently deployed Go services
24unit, integration, and HTTP tests
int64money representation in cents
0floating-point currency operations
01 / Interactive system

Move through the system layer by layer

bnpl-ledgerContainerized full-stack demonstration · Read-only
Complete
System map

Boundaries and responsibilities

Public or untrustedAuthenticated boundaryInternal-only dependency
READ-ONLYContainerized full-stack demonstrationtool: systemselection: merchant

Merchant UI selected.

02 / Context

The problem behind the system

Installment payments combine money invariants, concurrent requests, authentication, search, and merchant-facing workflow in one compact system.

The project focuses on the failure modes that make finance software difficult: rounding drift, duplicate payment, partial state, and credentials living in the wrong client storage.

Read the full project overview

A full-stack Buy Now, Pay Later (BNPL) microservices platform modeled after services like Sezzle. Three independently deployed Go services backed by a React/TypeScript merchant dashboard, all orchestrated with Docker Compose.

The BNPL Engine handles order creation and installment payment processing. Money is stored as integers (cents) - never floats - to avoid IEEE 754 rounding errors. Payment splitting guarantees the sum always equals the original total: remainder cents are distributed to the earliest installments. SELECT FOR UPDATE row locks prevent double-payment race conditions under concurrent load. The service ships with 24 tests across unit, integration (real Postgres), and full HTTP end-to-end layers.

The Merchant API adds JWT authentication, Elasticsearch-powered transaction search, and Postgres aggregate stats. The React dashboard surfaces these through debounced search, paginated transaction tables, an installment timeline per order, and protected routes - JWT stored in memory, never localStorage, to avoid XSS exposure.

03 / Decisions

The trade-offs that shaped the build

Decision

Money is integer cents

Make exactness a type-level convention.

Reason
Binary floating point is the wrong representation for installment invariants.
Trade-off
Currency scale and formatting remain explicit concerns.
Revisit when
When a decimal library is required for multi-currency rules.
Decision

Lock before payment

Serialize the critical state transition.

Reason
Two valid requests must not both observe an unpaid installment.
Trade-off
Contention is concentrated on the payment row.
Revisit when
At scale, after measuring contention and provider semantics.
Decision

Search is not the ledger

Separate discoverability from financial truth.

Reason
A search index should be rebuildable and disposable.
Trade-off
Projection lag must be visible to the merchant.
Revisit when
Never for authoritative payment state.
04 / Security

Threats, controls, and what remains

No control is presented as total risk elimination.

Integrity

Double-payment race

Control
Serializable transaction and row-level lock
Residual risk
External payment providers still need their own idempotency contract.
Accuracy

Currency rounding drift

Control
Integer cents and a tested split invariant
Residual risk
Currency-specific rules must remain explicit.
Session

JWT stolen from persistent storage

Control
Token kept in memory rather than localStorage
Residual risk
An active XSS can still act within the current session.
05 / Implementation

How the decisions appear in the build

bnpl/engine.go
01// Money is int64 cents, never float. The installment sum is an invariant.02func (e *Engine) PayInstallment(ctx context.Context, id InstallmentID) error {03    tx, err := e.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})04    if err != nil {05        return err06    }07    defer tx.Rollback()08 09    // Row lock prevents a double-payment race under concurrent load.10    var status string11    err = tx.QueryRowContext(ctx,12        `SELECT status FROM installments WHERE id = $1 FOR UPDATE`,13        id).Scan(&status)14    if err != nil {15        return err16    }17    if status == "paid" {18        return ErrAlreadyPaid          // idempotent: retries are safe19    }20 21    if _, err = tx.ExecContext(ctx,22        `UPDATE installments SET status = 'paid', paid_at = now() WHERE id = $1`,23        id); err != nil {24        return err25    }26    return tx.Commit()27}28 29// splitCents distributes a total so the parts ALWAYS sum back to the original.30func splitCents(total int64, n int) []int64 {31    base, rem := total/int64(n), total%int64(n)32    out := make([]int64, n)33    for i := range out {34        out[i] = base35        if int64(i) < rem {            // remainder to the earliest installments36            out[i]++37        }38    }39    return out                          // sum(out) == total, guaranteed40}
Representative / sanitized excerpt

Serializable tx + row lock kills double-payment; integer cents keep the split exact.

GoPostgreSQLElasticsearchDockerReactTypeScriptJWTVite
06 / Operations

The unhappy path is part of the design

01

What if two payments arrive together?

One transaction holds the row lock; the other observes the resulting paid state.

Signal
The second request returns the already-paid outcome.
Response
Treat the retry as idempotent instead of issuing another update.
02

What if search is unavailable?

Payment truth remains in PostgreSQL while merchant discovery is degraded.

Signal
Search requests fail independently of ledger writes.
Response
Restore or rebuild the projection without rewriting payment history.
07 / Evidence

Proof, source, and inspectable outcomes

08 / Results

What the project demonstrates

Payment state remains safe under concurrent requests

Installment totals remain exact without floating-point arithmetic

Merchant search and dashboards remain separate from the authoritative ledger

What worked
  • Financial invariants are visible in the code and tests.
  • Service boundaries separate truth, search, and presentation.
Next iteration
  • Provider-level idempotency integration
  • Outbox-driven search projection
  • Longer-running concurrency tests
Professional signal
  • Go backend engineering
  • Transactional reasoning
  • Full-stack service design