Back
FinTech / Microservices2026

BNPL Platform

GoPostgreSQLElasticsearchDockerReactTypeScriptJWTVite
BNPL Platform

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.

Implementation

// production excerpt
bnpl/engine.go
// Money is int64 cents, never float. The installment sum is an invariant.
func (e *Engine) PayInstallment(ctx context.Context, id InstallmentID) error {
    tx, err := e.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
    if err != nil {
        return err
    }
    defer tx.Rollback()

    // Row lock prevents a double-payment race under concurrent load.
    var status string
    err = tx.QueryRowContext(ctx,
        `SELECT status FROM installments WHERE id = $1 FOR UPDATE`,
        id).Scan(&status)
    if err != nil {
        return err
    }
    if status == "paid" {
        return ErrAlreadyPaid          // idempotent: retries are safe
    }

    if _, err = tx.ExecContext(ctx,
        `UPDATE installments SET status = 'paid', paid_at = now() WHERE id = $1`,
        id); err != nil {
        return err
    }
    return tx.Commit()
}

// splitCents distributes a total so the parts ALWAYS sum back to the original.
func splitCents(total int64, n int) []int64 {
    base, rem := total/int64(n), total%int64(n)
    out := make([]int64, n)
    for i := range out {
        out[i] = base
        if int64(i) < rem {            // remainder to the earliest installments
            out[i]++
        }
    }
    return out                          // sum(out) == total, guaranteed
}

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