Overview
SpendWisely George integrates with Fold Money to automatically sync bank transactions, eliminating manual entry for bank-linked expenses. The integration uses the open-source Unfold CLI to fetch transactions from Fold’s Account Aggregator API.
Important : Fold Money is currently invite-only. You must have a Fold account and connect your banks through the Fold mobile app before using this feature.
Architecture
Fold Mobile App
User connects banks via Account Aggregator (regulated by RBI)
Unfold CLI
Backend binary fetches transactions and stores in SQLite
FastAPI Server
Python server exposes REST API for frontend
Frontend
Displays bank transactions and balance in real-time
Authentication Flow
1. Login with OTP
Users authenticate using their Fold phone number:
class LoginRequest ( BaseModel ):
phone: str
@app.post ( "/api/fold/login" )
def fold_login ( req : LoginRequest):
url = "https://api.fold.money/v1/auth/otp"
# Format phone to +91... if not present
phone = req.phone
if not phone.startswith( "+" ):
phone = "+91" + phone
payload = { "phone" : phone, "channel" : "sms" }
try :
res = requests.post(url, json = payload)
res.raise_for_status()
return { "status" : "otp_sent" }
except Exception as e:
raise HTTPException( status_code = 400 , detail = str (e))
2. Verify OTP
After receiving OTP, verify and store credentials:
class VerifyRequest ( BaseModel ):
phone: str
otp: str
@app.post ( "/api/fold/verify" )
def fold_verify ( req : VerifyRequest):
url = "https://api.fold.money/v1/auth/otp/verify"
phone = req.phone
if not phone.startswith( "+" ):
phone = "+91" + phone
payload = { "phone" : phone, "otp" : req.otp}
try :
res = requests.post(url, json = payload)
res.raise_for_status()
data = res.json().get( "data" , {})
access = data.get( "access_token" )
refresh = data.get( "refresh_token" )
uuid = data.get( "user_meta" , {}).get( "uuid" )
if access and refresh:
save_unfold_config(access, refresh, uuid)
return { "status" : "success" }
else :
raise HTTPException( status_code = 400 , detail = "Invalid response from Fold" )
except Exception as e:
raise HTTPException( status_code = 400 , detail = f "Verification failed: { str (e) } " )
3. Store Credentials
Tokens are saved to a YAML config file for Unfold CLI:
import yaml
import os
UNFOLD_CONFIG = "./unfold_config.yaml"
def save_unfold_config ( access_token , refresh_token , uuid ):
config = {
"token" : {
"access" : access_token,
"refresh" : refresh_token
},
"fold_user" : {
"uuid" : uuid
},
"device_hash" : "python-client-" + os.urandom( 4 ).hex()
}
with open ( UNFOLD_CONFIG , "w" ) as f:
yaml.dump(config, f)
Transaction Syncing
Sync Endpoint
Manual sync triggered by user clicking “SYNC BANK” button:
UNFOLD_BINARY = "./unfold/unfold"
UNFOLD_CONFIG = "./unfold_config.yaml"
DB_PATH = "./unfold/db.sqlite"
@app.post ( "/api/sync" )
def sync_transactions ():
try :
subprocess.run([
UNFOLD_BINARY ,
"transactions" ,
"--db" ,
"--config" ,
UNFOLD_CONFIG
], check = True )
return { "status" : "success" }
except subprocess.CalledProcessError as e:
raise HTTPException(
status_code = 500 ,
detail = "Failed to sync. Ensure you are logged in."
)
except Exception as e:
raise HTTPException( status_code = 500 , detail = str (e))
Frontend Sync Flow
async function syncBank () {
setStatus ( "SYNCING BANK..." , "text-purple-500" , "bg-purple-500" );
try {
const res = await fetch ( '/api/sync' , { method: 'POST' });
if ( res . ok ) {
setTimeout (() => {
fetchBankTransactions ();
setStatus ( "ONLINE" , "text-green-500" , "bg-green-500" );
}, 5000 ); // Wait a bit for unfold to finish
}
} catch ( e ) {
setStatus ( "SYNC FAILED" , "text-red-500" , "bg-red-500" );
}
}
Fetching Transactions
Backend API
Reads from SQLite database populated by Unfold CLI:
import sqlite3
@app.get ( "/api/transactions" )
def get_transactions ():
"""Fetches transactions from the Unfold SQLite DB."""
if not os.path.exists( DB_PATH ) or get_unfold_token():
if not os.path.exists( DB_PATH ):
try :
subprocess.run([
UNFOLD_BINARY ,
"transactions" ,
"--db" ,
"--config" ,
UNFOLD_CONFIG
], check = True )
except :
pass # Might fail if not logged in
if not os.path.exists( DB_PATH ):
return []
conn = sqlite3.connect( DB_PATH )
cursor = conn.cursor()
try :
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='transactions'"
)
if not cursor.fetchone():
return []
cursor.execute( """
SELECT uuid, amount, current_balance, timestamp, type, account, merchant
FROM transactions
ORDER BY timestamp DESC
LIMIT 50
""" )
rows = cursor.fetchall()
results = []
for row in rows:
results.append({
"uuid" : row[ 0 ],
"amount" : row[ 1 ],
"current_balance" : row[ 2 ],
"timestamp" : row[ 3 ],
"type" : row[ 4 ],
"account" : row[ 5 ],
"merchant" : row[ 6 ]
})
return results
except Exception as e:
print ( f "DB Error: { e } " )
return []
finally :
conn.close()
Frontend Display
async function fetchBankTransactions () {
try {
const res = await fetch ( '/api/transactions' );
const data = await res . json ();
const list = document . getElementById ( 'bankTxList' );
document . getElementById ( 'bankLoader' ). classList . add ( 'hidden' );
if ( Array . isArray ( data ) && data . length > 0 ) {
let bankBalance = data [ 0 ]. current_balance || 0 ;
appData . bankBalance = bankBalance ;
list . innerHTML = '' ;
data . slice ( 0 , 10 ). forEach ( tx => {
const date = new Date ( tx . timestamp ). toLocaleDateString ();
const isCredit = tx . amount > 0 ;
const color = isCredit ? 'text-green-600' : 'text-slate-900' ;
list . innerHTML += `
<div class="card p-3 bg-white flex justify-between items-center">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-purple-50 rounded-lg flex items-center justify-center text-sm border border-purple-100">
🏦
</div>
<div>
<p class="font-bold text-xs text-slate-800">
${ tx . merchant || tx . description }
</p>
<p class="text-[9px] font-bold text-slate-400 uppercase">
${ date }
</p>
</div>
</div>
<p class="font-black ${ color } text-sm">
${ isCredit ? '+' : '' } ₹ ${ Math . abs ( tx . amount ) }
</p>
</div>
` ;
});
} else {
list . innerHTML = '<p class="text-xs text-slate-400 text-center py-4">No bank data found. Try syncing.</p>' ;
}
renderUI ();
} catch ( e ) {
console . error ( "Bank Fetch Error" , e );
document . getElementById ( 'bankLoader' ). innerHTML =
'<span class="text-red-500 text-xs">Error</span>' ;
}
}
Balance Tracking
The most recent transaction’s current_balance field is used:
if ( Array . isArray ( data ) && data . length > 0 ) {
let bankBalance = data [ 0 ]. current_balance || 0 ;
appData . bankBalance = bankBalance ;
// Update UI card
updateDisplay ( 'uiBankBal' , appData . bankBalance || 0 );
}
Unfold CLI Details
From the Unfold README:
# Login to Fold account
$ unfold login
# Fetch transactions
$ unfold transactions
# Fetch and save to SQLite
$ unfold transactions --db
# Fetch with date range
$ unfold transactions -s 2023-09-20 --db
# Run as daemon (fetch every 20 seconds)
$ unfold transactions --db -w '@every 20s'
Key Points:
Uses Fold Money’s unofficial API (MITM’d from mobile app)
Stores transactions in SQLite with schema: uuid, amount, current_balance, timestamp, type, account, merchant
Supports cron-like scheduling for auto-sync
Config stored in ~/.config/unfold/config.yaml by default
Database Schema
Unfold creates a SQLite table with this structure:
Column Type Description uuid TEXT Unique transaction ID amount REAL Transaction amount (positive = credit) current_balance REAL Balance after transaction timestamp TEXT ISO 8601 timestamp type TEXT Transaction type account TEXT Bank account identifier merchant TEXT Merchant/payee name
UI Components
Bank Balance Card
< div class = "card p-4 bg-white" >
< p class = "text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1" >
Bank Balance
</ p >
< h3 id = "uiBankBal" class = "text-xl font-black text-slate-800" > ₹0 </ h3 >
< p class = "text-[10px] text-purple-500 font-bold mt-1" > Fold Connected </ p >
</ div >
Transaction List
< div class = "px-5 mt-8" >
< h3 class = "text-xs font-bold text-slate-400 uppercase tracking-widest mb-4 ml-1" >
Recent Bank Data (Fold)
</ h3 >
< div id = "bankTxList" class = "space-y-3 pb-6 relative" >
< div class = "absolute inset-0 bg-white/50 backdrop-blur-[1px] z-10 flex items-center justify-center"
id = "bankLoader" >
< span class = "text-[10px] font-bold text-slate-400" > Loading... </ span >
</ div >
</ div >
</ div >
< button onclick = " syncBank ()" id = "syncBtn"
class = "text-xs font-bold text-purple-600 bg-purple-50 px-4 py-2 rounded-full hover:bg-purple-100 transition-colors" >
SYNC BANK
</ button >
Settings Panel Integration
Fold Login UI
< div class = "p-4 bg-purple-50 rounded-2xl border border-purple-100" >
< h3 class = "font-bold text-purple-900 mb-2" > Connect Fold </ h3 >
< div id = "foldLoginInputs" >
< input type = "tel" id = "foldPhone" placeholder = "Phone (99...)"
class = "w-full p-3 bg-white rounded-xl mb-2 text-sm" >
< button onclick = " sendFoldOtp ()"
class = "w-full bg-purple-600 text-white p-3 rounded-xl font-bold text-sm" >
Send OTP
</ button >
</ div >
< div id = "foldOtpInputs" class = "hidden" >
< input type = "text" id = "foldOtp" placeholder = "Enter OTP"
class = "w-full p-3 bg-white rounded-xl mb-2 text-sm text-center tracking-widest" >
< button onclick = " verifyFoldOtp ()"
class = "w-full bg-green-600 text-white p-3 rounded-xl font-bold text-sm" >
Verify & Login
</ button >
</ div >
< p id = "foldStatusMsg" class = "text-[10px] text-center mt-2 font-bold text-purple-400" ></ p >
</ div >
JavaScript Handlers
async function checkFoldStatus () {
try {
const res = await fetch ( '/api/fold/status' );
const data = await res . json ();
if ( data . logged_in ) {
document . getElementById ( 'foldLoginInputs' ). classList . add ( 'hidden' );
document . getElementById ( 'foldStatusMsg' ). innerText = "✅ Logged in to Fold" ;
}
} catch ( e ) { }
}
async function sendFoldOtp () {
const phone = document . getElementById ( 'foldPhone' ). value ;
try {
const res = await fetch ( '/api/fold/login' , {
method: 'POST' ,
body: JSON . stringify ({ phone: phone }),
headers: { 'Content-Type' : 'application/json' }
});
if ( res . ok ) {
document . getElementById ( 'foldLoginInputs' ). classList . add ( 'hidden' );
document . getElementById ( 'foldOtpInputs' ). classList . remove ( 'hidden' );
document . getElementById ( 'foldStatusMsg' ). innerText = "OTP Sent!" ;
}
} catch ( e ) {
alert ( "Failed to send OTP" );
}
}
async function verifyFoldOtp () {
const phone = document . getElementById ( 'foldPhone' ). value ;
const otp = document . getElementById ( 'foldOtp' ). value ;
try {
const res = await fetch ( '/api/fold/verify' , {
method: 'POST' ,
body: JSON . stringify ({ phone: phone , otp: otp }),
headers: { 'Content-Type' : 'application/json' }
});
if ( res . ok ) {
document . getElementById ( 'foldOtpInputs' ). classList . add ( 'hidden' );
document . getElementById ( 'foldStatusMsg' ). innerText = "✅ Successfully Logged In!" ;
syncBank ();
} else {
alert ( "Verification Failed" );
}
} catch ( e ) {
alert ( "Error verifying OTP" );
}
}
Security Considerations
Session Management : Unfold uses the same session as the Fold mobile app. Logging in via Unfold will log you out of the mobile app .
Token Storage : Access and refresh tokens are stored in plaintext YAML. In production, use encrypted storage.
API Stability : Fold’s API is not public. Future changes to Fold’s backend may break the integration.
Troubleshooting
Verify:
SQLite database exists: ./unfold/db.sqlite
Banks are connected in Fold mobile app
Date range includes recent transactions
Check database: sqlite3 ./unfold/db.sqlite "SELECT COUNT(*) FROM transactions;"
The balance is taken from the current_balance field of the most recent transaction . If transactions are out of order, this may be inaccurate.
Verify phone number format (should be +91…)
Check Fold service status
Ensure you have an active Fold account
Best Practices
Sync regularly Click SYNC BANK daily to keep transactions up to date
Cross-reference Compare synced transactions with manual entries to avoid duplicates
Monitor balance Total balance = Sheet balance + Bank balance + Portfolio value
Backup data Export CSV regularly as Unfold only stores last 50 transactions