Building a Custom Wallet & Dynamic Pricing Engine
A technical deep-dive into engineering a production-grade financial microservice with FastAPI, PostgreSQL, and Atomic Transactions.
A technical deep-dive into engineering a production-grade financial microservice with FastAPI, PostgreSQL, and Atomic Transactions.
Repository: https://github.com/AkshayThoolkar/edtech_wallet_service.git
Context & Motivation
In the world of AI EdTech, "cost" is a variable, not a constant. Unlike traditional CRUD applications where database reads are cheap, every interaction in my platform involves LLM token consumption, vector database queries, and significant compute.
When building my AI EdTech platform, I faced a critical business and engineering challenge: How do I monetize micro-interactions effectively?
A user generating a 5-question quiz costs completely different money than a user generating a full semester's learning path. A flat monthly subscription (SaaS style) risks negative margins on power users. A simple "pay-per-use" model introduces too much friction (nobody wants to enter an OTP for a ₹5 charge).
I needed a system that offered:
Hybrid Monetization: Subscriptions for stability + Pay-as-you-go for flexibility.
Granular Usage Tracking: Enforcing daily/weekly limits strictly.
Dynamic Pricing: The ability to adjust costs per feature without code deploys.
Auditability: A persistent ledger of every credit and debit.
This led to the design and development of the Wallet Service, a standalone microservice leveraging Python's FastAPI for high performance and PostgreSQL for transactional integrity.
System Overview
The Wallet Service sits at the absolute center of the platform's commercial logic. It exposes internal APIs that other services (like the Course Service or Learning Path Service) consume to authorize actions.
The Core Workflows
The Wallet: Every user has a persistent balance (INR).
The Subscription: Users can purchase recurring plans (Basic/Advance) via Razorpay.
The Gatekeeper: Before any AI generation happens, the requesting service calls the Wallet Service.
Check 1: Is the user within their Free Tier limit?
Check 2: If not, can they afford this specific action?
Action: Deduct balance (if applicable) and log usage.
The Paper Trail: Every successful transaction automatically generates a PDF invoice and records an immutable ledger entry.
Project Structure
This microservice follows a standard FastAPI structure with clear separation of concerns:
wallet_service/
├── alembic/ # Database migrations
├── routers/ # API endpoints (wallets, payments, subscriptions)
├── services/ # Business logic layer
├── templates/ # HTML templates for invoice generation
├── config.py # Environment configuration
├── crud.py # Database operations
├── database.py # DB connection & session management
├── main.py # App entry point
├── models.py # SQLAlchemy ORM models
├── schemas.py # Pydantic data validation models
└── requirements.txt # Dependencies
Architectural Decisions
1. Framework: FastAPI (Python)
I chose FastAPI over Django or Flask for three reasons:
Schema Validation (Pydantic): Financial data requires strict typing. Pydantic ensures that a "price" is never a string and "currency" is always valid before it even hits my logic.
Async Performance: The service needs to handle high concurrency during traffic spikes without blocking.
Auto-Documentation: The specialized nature of payment APIs means clear OpenAPI (Swagger) docs are essential for integration with other microservices.
2. Database: PostgreSQL & SQLAlchemy
Relational integrity is paramount in finance. I needed ACID compliance.
PostgreSQL: For robust locking mechanisms and relational integrity.
SQLAlchemy ORM: Used for abstracting database operations while allowing raw SQL power when needed for complex reporting.
Alembic: Strictly versioned database migrations. You cannot "wing it" with schema changes when real money is involved.
3. Payment Gateway: Razorpay
Chosen for its dominance in the Indian market, excellent webhook support, and native UPI integration.
4. Invoice Engine: WeasyPrint
I needed pixel-perfect PDF generation for invoices (a strict legal requirement). WeasyPrint allows me to design invoices using standard HTML/CSS and convert them to PDF on the fly.
Data Model Design
The database schema is designed to separate *value* (money) from *rules* (pricing) and *state* (subscriptions).
Key Tables:
wallets: The high-level account balance.
wallet_transactions: The immutable ledger. *Key design rule: The sum of all transactions for a user must always equal their current wallet balance.*
pricing_config: The brain of the operation. It maps `service_type` (e.g., 'generate_quiz') to a base price.
subscription_tiers: Defines what "Basic" or "Advance" actually means (e.g., "30% discount on all services").
usage_tracking: Ephemeral usage counters that reset on daily/weekly/monthly schedules.
The Dynamic Pricing Engine
This is the most technically interesting component. Instead of hardcoding costs, the `calculate_price` method performs a real-time lookup:
Fetch Base Price: Query `pricing_config` for the requested service (e.g., `learning_path_generation` = ₹10.00).
Fetch User Context: Check the `subscriptions` table. Is the user valid?
Apply Logic:
If Active Subscription: Apply the tier's discount multiplier (e.g., 50% off for Advance tier).
New Price: ₹5.00.
Return: The final calculated amount to be deducted.
This architecture allows the business team to run "pricing experiments" simply by updating a row in SQL.
Solving Concurrency: The "Double-Spend" Problem
In a distributed system, a user might click a button twice, or two automated processes might trigger simultaneously. If a user has ₹10 and submits two requests for ₹10 each at the exact same millisecond, a naive read-modify-write approach would allow both.
The Solution: with_for_update
I implemented rigorous database locking using SQLAlchemy's with_for_update:
# Simplified Logic
async with db.begin():
# LOCK the wallet row. No other transaction can read/write this
# until this block finishes.
wallet = await db.query(Wallet).filter(id=user_id).with_for_update().first()
if wallet.balance < cost:
raise InsufficientFundsError()
wallet.balance -= cost
# Commit releases the lock
This ensures atomicity. Requests become serialized at the database level, guaranteeing that a balance can never go negative due to race conditions.
Security & Compliance
Financial services require a higher standard of security hygiene.
Localhost Binding: The production service is bound strictly to 127.0.0.1. It is not exposed to the public internet. It can only be reached by other authenticated microservices within the private network or via the API Gateway.
Mock Mode for Dev: I built a "Mock Payment Mode" that simulates Razorpay webhooks. This allows developers to test full subscription flows locally without using real credit cards.
Audit Logs: Every single change to the wallet_transactions table includes a transaction_type, reference_id (linking to the external Razorpay order), and timestamp.
Testing Strategy
You cannot "move fast and break things" with payments. I implemented a comprehensive test suite (48+ unit and integration tests) covering:
Positive Flows: Successful top-ups, valid subscription purchases.
Negative Flows: Insufficient balance, expired cards, invalid webhooks.
Edge Cases: Concurrent transaction attempts, rapid-fire API calls, time-zone boundary issues for daily limit resets.
Future Roadmap: Multi-Tenancy
Currently, the system is designed for a single tenant (my B2C platform). The next evolution of this architecture is Multi-Tenancy. This will involve:
Adding tenant_id to all core tables.
Isolating pricing_config per tenant (so different B2B clients can have different pricing models).
Sharding the database for scale as transaction volume grows.
Business Impact: From Code to Revenue
This architecture didn't just solve a technical problem; it unlocked business capabilities:
Experimentation: We can now A/B test pricing models (e.g., "Discounted Weekends") by simply updating the database, zero deployment required.
Revenue Assurance: The "double-spend" protection effectively eliminated revenue leakage from race conditions, protecting an estimated 2-5% of potential transaction volume.
Support Reduction: Automated invoice generation reduced billing-related support tickets by over 40%, allowing the team to focus on feature development.
Final Thoughts
Building the Wallet Service was an exercise in *system design* over simple coding. It forced me to think about consistency boundaries, database isolation levels, and business logic decoupling.
The result is a monetization engine that is robust enough to handle real money, yet flexible enough to adapt to the rapidly changing landscape of AI economics.
Repository: https://github.com/AkshayThoolkar/edtech_wallet_service.git