An AI-powered offline payment system built on top of a Paytm-like Flutter + Python stack. Payments work without internet — via local credit limits, BLE device-to-device transfer, and background sync when connectivity is restored.
APK and IPA files in release [https://github.com/Vivekgupta008/payapp/releases/tag/app-release]
Demo Video Link: [https://drive.google.com/file/d/1WoChgWa9LuhZAAbDqEW_2rmaIrNNCr8P/view?usp=sharing]
- Concept
- Architecture
- Payment Cases
- Offline Credit Limit & ML Model
- Sync Engine
- Project Structure
- Tech Stack
- Backend API Reference
- Running Locally
- Deployment
- Installing the App
- Presentation
Traditional UPI payments fail the moment internet drops. This project decouples payment capture from payment settlement:
- Every user has an AI-assigned offline credit limit (₹0–₹5,000), cached on-device
- Payments within this limit are captured locally as payment blobs
- Blobs sync to the backend when connectivity is restored, where the backend settles and reconciles them
- A local risk penalty is applied immediately after each offline payment, so the displayed limit adjusts without needing a server round-trip
┌────────────────────────────────────────────────────┐
│ Flutter App │
│ │
│ ConnectivityService (stream) │
│ ↓ online / offline │
│ ┌─────────────┐ ┌──────────────────────────┐ │
│ │ Online path │ │ Offline path │ │
│ │ HTTP → API │ │ PaymentBlob → SQLite │ │
│ └─────────────┘ │ OfflineLimitService │ │
│ │ BLEService (Case 3) │ │
│ └──────────────────────────┘ │
│ ↓ │
│ SyncEngine (background) │
│ POST /api/offline/sync │
└────────────────────────────────────────────────────┘
↕ HTTPS
┌────────────────────────────────────────────────────┐
│ FastAPI Backend (Python) │
│ │
│ Auth (JWT + Ed25519) │
│ Token issuance → ML limit calculation │
│ POST /api/offline/sync → dedup + settle │
│ GET /api/user/offline-limit → ML score → limit │
│ │
│ PostgreSQL (Render) │
└────────────────────────────────────────────────────┘
Sender (offline) Receiver (online)
│ │
│── Scan QR (receiverId) ──────────────│
│── Check offline limit locally │
│── Deduct from limit (SharedPrefs) │
│── Create PaymentBlob (SQLite) │
│── Apply risk penalty locally │
│── Show "Payment sent (offline)" │
│ │
│ [Device comes online] │
│── SyncEngine → POST /offline/sync ──▶│
│ Backend settles ──▶ Receiver notified
Sender (online) Receiver (offline)
│ │
│── Scan QR ────────────────────────── │
│── POST /api/payments/online ────────▶│
│ Bank debited immediately │
│ Backend holds credit for receiver │
│ │
│ [Receiver comes online] │
│ SyncEngine ────────────▶│
│ Fetch pending credits │
│ Update balance + notify │
Sender (offline) Receiver (offline)
│ │
│ Receiver generates BLE session UUID│
│ UUID embedded in QR code │
│── Scan QR (receiverId + bleUUID) ────│
│── Create PaymentBlob │
│── BLE scan for UUID ────────────────▶│
│── GATT connect ─────────────────────▶│
│── Write blob JSON (chunked) ─────────│
│ Store in SQLite│
│ Show ₹X received│
│── Mark as sent_via_ble │
│── Deduct from local limit │
│ │
│ [Either party comes online] │
│── POST /api/offline/sync │
│ Backend deduplicates by │
│ (senderId+receiverId+nonce+ts) │
BLE implementation:
- Receiver (peripheral): Native
CBPeripheralManager(iOS) /BluetoothGattServer(Android) exposed to Flutter viaMethodChannel+EventChannel - Sender (central):
flutter_blue_plusscans for the session UUID, connects, writes blob in ≤512-byte chunks
| Store | Key | Value |
|---|---|---|
| SharedPreferences | offline_limit |
Total limit (double) |
| SharedPreferences | offline_limit_remaining |
Available limit (double) |
| SharedPreferences | offline_limit_expiry |
ISO8601 expiry (24h TTL) |
After each offline payment:
deductFromLimit(amount)— decrementsoffline_limit_remainingapplyLocalRiskPenalty(pendingCount)— reduces effective cap by 10% per pending blob, floored at 30% of totalWalletProvider.loadCachedTokens()— reloads from SharedPrefs so UI updates immediately
Scoring features:
| Feature | Direction |
|---|---|
transaction_count_last_30_days |
↑ higher limit |
avg_transaction_value |
↑ higher limit |
account_age_days |
↑ higher limit |
kyc_tier (0–3) |
↑ higher limit |
fraud_flags |
↓ lower limit |
device_trust_score |
↑ higher limit |
Output: risk score (0.0–1.0) → mapped to limit tier:
| Risk Score | Limit |
|---|---|
| < 0.2 | ₹5,000 |
| 0.2–0.4 | ₹3,000 |
| 0.4–0.6 | ₹1,500 |
| 0.6–0.8 | ₹500 |
| 0.8–0.9 | ₹100 |
| ≥ 0.9 | ₹0 (restricted) |
Limit is recalculated and pushed to the device after every successful sync.
mobile/lib/services/sync_engine.dart runs as a background monitor:
- Watches
ConnectivityServicestream - On connectivity restored →
SyncService.syncPendingTransactions() POST /api/offline/syncwith allpending_syncblobs- Backend response per blob:
accepted/rejected/adjusted - On
accepted→ mark blobsynced, deduct from actual bank balance - On
rejected→ reverse local limit deduction, notify user - After sync →
OfflineLimitService.fetchAndCacheLimit()pulls recalculated ML limit WalletProviderreloads from SharedPrefs → UI updates
Backend deduplicates using composite key: (sender_id, receiver_id, nonce, timestamp)
payapp_/
├── mobile/ # Flutter app
│ ├── lib/
│ │ ├── main.dart
│ │ ├── config/
│ │ │ ├── constants.dart
│ │ │ └── theme.dart # Paytm brand palette
│ │ ├── models/
│ │ │ ├── payment_blob.dart # Core offline payment unit
│ │ │ ├── payment_token.dart
│ │ │ ├── transaction.dart
│ │ │ └── user.dart
│ │ ├── providers/
│ │ │ ├── auth_provider.dart
│ │ │ ├── wallet_provider.dart # Offline limit, tokens, connectivity
│ │ │ └── transaction_provider.dart
│ │ ├── services/
│ │ │ ├── ble_service.dart # BLE central (sender) via flutter_blue_plus
│ │ │ ├── connectivity_service.dart
│ │ │ ├── offline_limit_service.dart
│ │ │ ├── offline_queue_service.dart # SQLite blob queue
│ │ │ ├── offline_storage.dart
│ │ │ ├── qr_transfer.dart
│ │ │ ├── sync_engine.dart
│ │ │ ├── sync_service.dart
│ │ │ └── token_service.dart
│ │ ├── screens/
│ │ │ ├── home_screen.dart
│ │ │ ├── login_screen.dart
│ │ │ ├── register_screen.dart
│ │ │ ├── show_qr_screen.dart # BLE peripheral + QR display
│ │ │ ├── payment_receipt_screen.dart
│ │ │ ├── splash_screen.dart
│ │ │ ├── user/
│ │ │ │ ├── user_dashboard.dart
│ │ │ │ └── pay_screen.dart
│ │ │ └── merchant/
│ │ │ └── merchant_dashboard.dart
│ │ └── widgets/
│ │ └── transaction_tile.dart
│ ├── ios/
│ │ └── Runner/
│ │ ├── AppDelegate.swift # BLE peripheral (iOS) via CBPeripheralManager
│ │ └── Info.plist # BLE permissions
│ ├── android/
│ │ └── app/src/main/kotlin/.../
│ │ └── MainActivity.kt # BLE peripheral (Android) via BluetoothGattServer
│ ├── PaytmOfflinePay.apk # Latest release APK (Android)
│ └── PaytmOfflinePay.ipa # Latest build IPA (iOS)
│
├── backend/ # FastAPI server
│ ├── app/
│ │ ├── main.py
│ │ ├── models.py # SQLAlchemy models
│ │ ├── schemas.py
│ │ ├── database.py
│ │ ├── config.py
│ │ ├── routes/
│ │ │ ├── auth_routes.py # Register, login, profile
│ │ │ ├── token_routes.py # Offline token issuance
│ │ │ ├── sync_routes.py # POST /api/offline/sync
│ │ │ └── dashboard.py # User/merchant dashboards
│ │ ├── ml/
│ │ │ ├── model.py # Risk scoring → limit calculation
│ │ │ └── train_model.py
│ │ └── services/
│ ├── requirements.txt
│ ├── seed.py # Demo data seeding
│ └── risk_model.joblib # Trained sklearn model
│
├── presentation/
│ ├── slides.md # 15-slide Slidev presentation
│ ├── PaytmOfflinePay_Slides.pdf # Exported PDF
│ └── assets/
│ ├── diagrams/ # Mermaid architecture PNGs
│ │ ├── arch.png
│ │ ├── case1.png
│ │ ├── case2.png
│ │ ├── case3.png
│ │ ├── ml.png
│ │ ├── security.png
│ │ └── sync.png
│ └── screens/ # App mockup screenshots
│ ├── home_screen.png
│ ├── receipt_screen.png
│ └── qr_screen.png
│
├── render.yaml # Render.com deployment config
└── runtime.txt # Python 3.11.9
| Package | Version | Purpose |
|---|---|---|
provider |
^6.1.1 | State management |
flutter_blue_plus |
^1.31.0 | BLE central (sender scanning) |
sqflite |
^2.3.0 | Local SQLite — blob queue + transactions |
shared_preferences |
^2.2.2 | Offline limit cache (24h TTL) |
connectivity_plus |
^5.0.2 | Stream-based online/offline detection |
mobile_scanner |
^4.0.0 | QR code scanning |
qr_flutter |
^4.1.0 | QR code generation |
uuid |
^4.2.1 | Blob and nonce generation |
path_provider |
^2.1.1 | SQLite file path |
BLE peripheral (advertising) is implemented natively:
- iOS:
CBPeripheralManagerinAppDelegate.swift, registered viaFlutterPluginRegistry - Android:
BluetoothGattServer+BluetoothLeAdvertiserinMainActivity.kt
Both expose the same MethodChannel("com.offlinepay/ble_peripheral") + EventChannel("com.offlinepay/ble_peripheral_events") interface to Dart.
| Package | Version | Purpose |
|---|---|---|
fastapi |
0.104.1 | API framework |
uvicorn |
0.24.0 | ASGI server |
sqlalchemy |
2.0.23 | ORM |
psycopg2-binary |
2.9.9 | PostgreSQL driver |
python-jose |
3.3.0 | JWT auth |
PyNaCl |
1.5.0 | Ed25519 signing |
scikit-learn |
1.3.2 | ML risk model |
pydantic |
2.5.2 | Request/response schemas |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/auth/register |
Register new user |
POST |
/api/auth/login |
Login, returns JWT |
GET |
/api/auth/profile |
Get current user profile |
PUT |
/api/auth/profile |
Update profile |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/tokens/request |
Issue offline payment tokens |
GET |
/api/user/offline-limit |
Get ML-calculated limit + expiry |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/offline/sync |
Submit blob array; returns status per blob |
Request:
{
"blobs": [
{
"id": "uuid",
"sender_id": "string",
"receiver_id": "string",
"amount": 250.0,
"timestamp": "2026-03-30T10:00:00Z",
"nonce": "uuid",
"device_signature": "string",
"is_offline": true,
"offline_limit_at_time": 1500.0
}
]
}Response:
{
"results": [
{ "id": "uuid", "status": "accepted" },
{ "id": "uuid", "status": "rejected", "reason": "duplicate" }
],
"new_offline_limit": 1250.0
}| Method | Endpoint | Description |
|---|---|---|
GET |
/api/dashboard/user |
Balance, recent transactions, offline limit |
GET |
/api/dashboard/merchant |
Earnings, received payments |
cd backend
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
# Set environment variables
export SECRET_KEY=your-secret-key
export ED25519_PRIVATE_KEY_B64=your-base64-key
export DATABASE_URL=sqlite:///./offline_pay.db # or PostgreSQL URL
uvicorn app.main:app --reload
# API runs at http://localhost:8000
# Docs at http://localhost:8000/docsSeed demo data:
python seed.pycd mobile
flutter pub get
flutter run # debug on connected device
flutter run --release # release modeUpdate the API base URL in lib/config/constants.dart:
static const String apiBaseUrl = 'http://localhost:8000';Deployed on Render.com (free tier, Singapore region).
render.yaml provisions:
- Web service: FastAPI via uvicorn
- PostgreSQL database
# Trigger a deploy
git push origin mainEnvironment variables to set in Render dashboard:
SECRET_KEY— JWT signing secretED25519_PRIVATE_KEY_B64— Base64-encoded Ed25519 private key for blob signing
- Enable Install unknown apps in device settings
- Transfer
mobile/PaytmOfflinePay.apkto device - Tap the file to install
Or via ADB:
adb install mobile/PaytmOfflinePay.apkThe IPA is signed with a development certificate (no App Store distribution cert).
Via Xcode:
- Open Xcode → Window → Devices and Simulators
- Select your device
- Drag
mobile/PaytmOfflinePay.ipaonto the Installed Apps list
Via xcrun:
xcrun devicectl device install app --device <UDID> mobile/PaytmOfflinePay.ipaVia AltStore / Sideloadly: Import PaytmOfflinePay.ipa directly.
BLE on iOS: On first launch, iOS will request Bluetooth permission. Tap Allow. If the prompt never appeared, go to Settings → Privacy & Security → Bluetooth → enable OfflinePay.
BLE on Android: On Android 12+, grant Nearby devices permission when prompted. If denied, go to Settings → Apps → OfflinePay → Permissions → Nearby devices → Allow.
A 15-slide Slidev presentation is in presentation/:
cd presentation
npm install
npm run dev # live preview at localhost:3030Or open the exported PDF directly: presentation/PaytmOfflinePay_Slides.pdf
Slides cover:
- Cover — market stats (₹18.4L Cr UPI volume, 500M users)
- Problem — 4G reliability, rural connectivity, revenue loss
- Solution — three-tier offline architecture
- System Architecture diagram
- App Demo — home screen, offline limit badge 6–8. Cases 1, 2, 3 — sequence diagrams
- ML Engine — feature scoring, limit tiers
- Security — Ed25519 signing, nonce replay protection
- Sync Engine — settlement flow
- Live App — screenshots
- Paytm Integration — SDK hooks, rollout plan
- Business Impact — stat cards, competitive comparison
- Roadmap & Team
The core data structure passed between sender, receiver, and backend:
class PaymentBlob {
final String id; // UUID v4
final String senderId;
final String receiverId;
final double amount;
final DateTime timestamp;
final String nonce; // UUID v4 — replay protection
final String deviceSignature; // Ed25519 placeholder
final String status; // pending_sync | synced | rejected
final bool isOffline;
final double offlineLimitAtTime; // limit available when payment was made
}Deduplication key on backend: (sender_id, receiver_id, nonce, timestamp)