Fintech

Cliff-Pay

Secure Digital Wallet & Peer-to-Peer Transfer API

2025 Python, Django, PostgreSQL Production Ready

Project Overview

The Problem

Every fintech team building P2P payments was reinventing the wheel—no standardized wallet logic, no atomic transfer guarantees, and balance inconsistencies under concurrent requests that could silently lose money.

The Solution

A production-grade digital wallet REST API with atomic peer-to-peer transfers, signal-driven wallet provisioning, JWT authentication, and full transaction history—built on Django, DRF, and PostgreSQL.

Key Metrics

  • 💰 Zero balance inconsistencies via atomic transactions
  • ⚡ Auto wallet creation on user registration (0ms manual setup)
  • 🔐 JWT-secured endpoints with token refresh

System Architecture

High-level overview of the wallet and transfer flow.

flowchart TB subgraph Client["Client Applications"] MOBILE[Mobile App] WEB[Web App] CLI[API Client / cURL] end subgraph Auth["Authentication Layer"] REGISTER["/api/accounts/register/"] LOGIN["/api/accounts/login/"] JWT[JWT Token Validation] end subgraph Core["Core API Views"] PROFILE["/api/accounts/profile/"] SEND["/api/transactions/send/"] HISTORY["/api/transactions/history/"] end subgraph Engine["Business Logic"] SIGNAL["post_save Signal"] ATOMIC["transaction.atomic()"] LOCK["select_for_update()"] VALIDATE[Balance Validation] end subgraph Data["Data Layer"] POSTGRES[(PostgreSQL)] USER_TBL[User Model] WALLET_TBL[Wallet Model] TXN_TBL[Transaction Model] end MOBILE --> REGISTER WEB --> LOGIN CLI --> JWT REGISTER --> SIGNAL SIGNAL --> WALLET_TBL JWT --> PROFILE JWT --> SEND JWT --> HISTORY SEND --> ATOMIC ATOMIC --> LOCK LOCK --> VALIDATE VALIDATE --> TXN_TBL USER_TBL --> POSTGRES WALLET_TBL --> POSTGRES TXN_TBL --> POSTGRES

Database Schema

Entity-Relationship diagram showing the wallet and transaction models.

erDiagram USERS ||--|| WALLETS : has USERS ||--o{ TRANSACTIONS_SENT : sends USERS ||--o{ TRANSACTIONS_RECEIVED : receives USERS { int id PK string email UK string username UK string password_hash timestamp date_joined } WALLETS { int id PK int user_id FK decimal balance timestamp created_at timestamp updated_at } TRANSACTIONS_SENT { int id PK int sender_id FK int receiver_id FK decimal amount string status timestamp created_at } TRANSACTIONS_RECEIVED { int id PK int sender_id FK int receiver_id FK decimal amount string status timestamp created_at }

Engineering Challenges

01

Atomic P2P Transfers

Problem

Concurrent transfer requests could debit the sender without crediting the receiver, or allow double-spending when two requests hit the same wallet simultaneously.

Solution

Wrapped the entire transfer flow in transaction.atomic() with select_for_update() row-level locking, ensuring both wallets are updated as a single indivisible operation.

# Atomic P2P transfer with row-level locking
from django.db import transaction

def execute_transfer(sender, receiver_email, amount):
    with transaction.atomic():
        # Lock both wallets to prevent race conditions
        sender_wallet = (
            Wallet.objects
            .select_for_update()
            .get(user=sender)
        )
        receiver_wallet = (
            Wallet.objects
            .select_for_update()
            .get(user__email=receiver_email)
        )

        if sender_wallet.balance < amount:
            raise ValidationError("Insufficient funds")

        sender_wallet.balance -= amount
        receiver_wallet.balance += amount
        sender_wallet.save()
        receiver_wallet.save()

        Transaction.objects.create(
            sender=sender,
            receiver=receiver_wallet.user,
            amount=amount,
            status="completed"
        )
02

Auto Wallet Provisioning

Problem

Manually creating wallets after user registration was error-prone—forgotten steps left users without wallets, breaking downstream transfer logic.

Solution

Used Django's post_save signal on the User model to automatically create a wallet with a zero balance the instant a user registers—zero manual steps, zero orphaned accounts.

# Signal-driven wallet creation
from django.db.models.signals import post_save
from django.dispatch import receiver

@receiver(post_save, sender=User)
def create_wallet(sender, instance, created, **kwargs):
    """Auto-provision a wallet for every new user."""
    if created:
        Wallet.objects.create(
            user=instance,
            balance=Decimal("0.00")
        )
03

Secure Fund Validation

Problem

Without proper validation, users could transfer negative amounts (effectively stealing funds), send money to themselves, or overdraw their balance.

Solution

Implemented multi-layer validation at the serializer and model level: positive amount enforcement, self-transfer prevention, and balance sufficiency checks—all before the atomic transaction block executes.

# Multi-layer transfer validation
class TransferSerializer(serializers.Serializer):
    recipient = serializers.EmailField()
    amount = serializers.DecimalField(
        max_digits=12, decimal_places=2,
        min_value=Decimal("0.01")  # No zero/negative transfers
    )

    def validate(self, data):
        request = self.context["request"]

        # Prevent self-transfers
        if data["recipient"] == request.user.email:
            raise serializers.ValidationError(
                "Cannot transfer to yourself"
            )

        # Verify recipient exists
        if not User.objects.filter(
            email=data["recipient"]
        ).exists():
            raise serializers.ValidationError(
                "Recipient not found"
            )

        return data

Key Takeaways

⚛️

Atomicity is Non-Negotiable in Fintech

Financial operations must be all-or-nothing. Django's transaction.atomic() combined with select_for_update() eliminates race conditions at the database level.

📡

Signals Decouple Side Effects

Using post_save signals for wallet creation keeps the registration flow clean and guarantees every user gets a wallet—without touching the auth views.

🛡️

Validate at Every Layer

Serializer-level checks catch malformed requests early. Model-level constraints act as the last line of defense. Never trust a single validation layer with money.

📐

RESTful Design Matters

Clean endpoint design (/send/, /history/) with proper HTTP methods and status codes makes the API intuitive for consumers and easy to document.

Interested in the Technical Details?

Check out the full source code or reach out to discuss the architecture.