Spaces:
Runtime error
Runtime error
David Prince commited on
Commit ·
d077e09
1
Parent(s): 0638f8d
Add KYC, PIN, airtime, data, purity memory
Browse files- app.py +12 -1
- dolor3v-frontend/src/components/PurityAI.css +13 -0
- dolor3v-frontend/src/components/PurityAI.jsx +57 -28
- dolor3v-frontend/src/pages/Dashboard.jsx +37 -78
- endpoints.py +211 -36
- models.py +20 -1
app.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
| 1 |
import os
|
| 2 |
from flask import Flask, jsonify
|
| 3 |
from database import init_db, create_tables
|
| 4 |
-
from endpoints import register, login, transfer, external_transfer,
|
|
|
|
|
|
|
|
|
|
| 5 |
from flask_cors import CORS
|
| 6 |
|
| 7 |
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
@@ -24,6 +27,14 @@ app.add_url_rule("/balance", "balance", balance, methods=["GET"])
|
|
| 24 |
app.add_url_rule("/history", "history", history, methods=["GET"])
|
| 25 |
app.add_url_rule("/alerts", "alerts", alerts, methods=["GET"])
|
| 26 |
app.add_url_rule("/lookup", "lookup", lookup, methods=["GET"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
@app.route("/health")
|
| 29 |
def health():
|
|
|
|
| 1 |
import os
|
| 2 |
from flask import Flask, jsonify
|
| 3 |
from database import init_db, create_tables
|
| 4 |
+
from endpoints import (register, login, transfer, external_transfer,
|
| 5 |
+
balance, history, alerts, lookup, set_pin, verify_pin,
|
| 6 |
+
kyc_upgrade, profile, buy_airtime, buy_data,
|
| 7 |
+
purity_memory, get_purity_memory, jwt)
|
| 8 |
from flask_cors import CORS
|
| 9 |
|
| 10 |
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
| 27 |
app.add_url_rule("/history", "history", history, methods=["GET"])
|
| 28 |
app.add_url_rule("/alerts", "alerts", alerts, methods=["GET"])
|
| 29 |
app.add_url_rule("/lookup", "lookup", lookup, methods=["GET"])
|
| 30 |
+
app.add_url_rule("/set-pin", "set_pin", set_pin, methods=["POST"])
|
| 31 |
+
app.add_url_rule("/verify-pin", "verify_pin", verify_pin, methods=["POST"])
|
| 32 |
+
app.add_url_rule("/kyc-upgrade", "kyc_upgrade", kyc_upgrade, methods=["POST"])
|
| 33 |
+
app.add_url_rule("/profile", "profile", profile, methods=["GET"])
|
| 34 |
+
app.add_url_rule("/buy-airtime", "buy_airtime", buy_airtime, methods=["POST"])
|
| 35 |
+
app.add_url_rule("/buy-data", "buy_data", buy_data, methods=["POST"])
|
| 36 |
+
app.add_url_rule("/purity-memory", "purity_memory", purity_memory, methods=["POST"])
|
| 37 |
+
app.add_url_rule("/purity-memory", "get_purity_memory", get_purity_memory, methods=["GET"])
|
| 38 |
|
| 39 |
@app.route("/health")
|
| 40 |
def health():
|
dolor3v-frontend/src/components/PurityAI.css
CHANGED
|
@@ -95,3 +95,16 @@
|
|
| 95 |
}
|
| 96 |
|
| 97 |
.purity-hints span:hover { border-color: #7c3aed; color: #a78bfa; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
}
|
| 96 |
|
| 97 |
.purity-hints span:hover { border-color: #7c3aed; color: #a78bfa; }
|
| 98 |
+
|
| 99 |
+
.purity-response {
|
| 100 |
+
background: #130d24;
|
| 101 |
+
border: 1px solid #7c3aed44;
|
| 102 |
+
border-radius: 10px;
|
| 103 |
+
padding: 10px 16px;
|
| 104 |
+
color: #a78bfa;
|
| 105 |
+
font-size: 0.85rem;
|
| 106 |
+
font-style: italic;
|
| 107 |
+
text-align: center;
|
| 108 |
+
max-width: 300px;
|
| 109 |
+
animation: slideUp 0.3s ease;
|
| 110 |
+
}
|
dolor3v-frontend/src/components/PurityAI.jsx
CHANGED
|
@@ -1,17 +1,19 @@
|
|
| 1 |
import { useState, useRef } from "react";
|
|
|
|
| 2 |
import "./PurityAI.css";
|
| 3 |
|
| 4 |
const ELEVEN_KEY = "sk_2d090a3d6c13a30f2a89c5913a30c3f83ac82ff99a46c9b8";
|
| 5 |
-
const VOICE_ID = "EXAVITQu4vr4xnSDxMaL";
|
| 6 |
|
| 7 |
-
export default function PurityAI({ balance, onTransfer }) {
|
| 8 |
const [active, setActive] = useState(false);
|
| 9 |
const [status, setStatus] = useState("idle");
|
| 10 |
const [transcript, setTranscript] = useState("");
|
| 11 |
-
const
|
| 12 |
|
| 13 |
const speak = async (text) => {
|
| 14 |
setStatus("speaking");
|
|
|
|
| 15 |
try {
|
| 16 |
const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE_ID}`, {
|
| 17 |
method: "POST",
|
|
@@ -25,53 +27,74 @@ export default function PurityAI({ balance, onTransfer }) {
|
|
| 25 |
voice_settings: { stability: 0.5, similarity_boost: 0.75 },
|
| 26 |
}),
|
| 27 |
});
|
|
|
|
| 28 |
const blob = await res.blob();
|
| 29 |
const url = URL.createObjectURL(blob);
|
| 30 |
const audio = new Audio(url);
|
| 31 |
audio.onended = () => setStatus("idle");
|
| 32 |
-
audio.play();
|
| 33 |
} catch {
|
| 34 |
-
// fallback to browser TTS
|
| 35 |
const u = new SpeechSynthesisUtterance(text);
|
| 36 |
-
u.rate = 0.
|
|
|
|
|
|
|
|
|
|
| 37 |
u.onend = () => setStatus("idle");
|
| 38 |
speechSynthesis.speak(u);
|
| 39 |
}
|
| 40 |
};
|
| 41 |
|
| 42 |
-
const handleCommand = (text) => {
|
| 43 |
const lower = text.toLowerCase();
|
| 44 |
-
if (lower.includes("balance")) {
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
} else {
|
| 52 |
-
speak("I can help you check your balance or make a transfer. Just
|
| 53 |
}
|
| 54 |
};
|
| 55 |
|
| 56 |
const startListening = () => {
|
| 57 |
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
| 58 |
-
if (!SR) {
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
const text = e.results[0][0].transcript;
|
| 63 |
setTranscript(text);
|
| 64 |
handleCommand(text);
|
| 65 |
};
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
};
|
| 71 |
|
| 72 |
return (
|
| 73 |
<div className="purity-wrap">
|
| 74 |
-
<div className={`purity-orb ${status}`} onClick={
|
| 75 |
<div className="orb-ring" />
|
| 76 |
<div className="orb-ring r2" />
|
| 77 |
<div className="orb-core">
|
|
@@ -84,12 +107,18 @@ export default function PurityAI({ balance, onTransfer }) {
|
|
| 84 |
{status === "listening" ? "Listening..." : status === "speaking" ? "Speaking..." : active ? "Tap to speak" : "Tap to wake up"}
|
| 85 |
</p>
|
| 86 |
|
| 87 |
-
{transcript &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
<div className="purity-hints">
|
| 90 |
-
<span onClick={() => handleCommand("check my balance")}>
|
| 91 |
-
<span onClick={() => handleCommand("
|
| 92 |
-
<span onClick={() => handleCommand("
|
|
|
|
| 93 |
</div>
|
| 94 |
</div>
|
| 95 |
);
|
|
|
|
| 1 |
import { useState, useRef } from "react";
|
| 2 |
+
import API from "../api";
|
| 3 |
import "./PurityAI.css";
|
| 4 |
|
| 5 |
const ELEVEN_KEY = "sk_2d090a3d6c13a30f2a89c5913a30c3f83ac82ff99a46c9b8";
|
| 6 |
+
const VOICE_ID = "EXAVITQu4vr4xnSDxMaL";
|
| 7 |
|
| 8 |
+
export default function PurityAI({ balance, onTransfer, onExternalTransfer }) {
|
| 9 |
const [active, setActive] = useState(false);
|
| 10 |
const [status, setStatus] = useState("idle");
|
| 11 |
const [transcript, setTranscript] = useState("");
|
| 12 |
+
const [response, setResponse] = useState("");
|
| 13 |
|
| 14 |
const speak = async (text) => {
|
| 15 |
setStatus("speaking");
|
| 16 |
+
setResponse(text);
|
| 17 |
try {
|
| 18 |
const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE_ID}`, {
|
| 19 |
method: "POST",
|
|
|
|
| 27 |
voice_settings: { stability: 0.5, similarity_boost: 0.75 },
|
| 28 |
}),
|
| 29 |
});
|
| 30 |
+
if (!res.ok) throw new Error("ElevenLabs failed");
|
| 31 |
const blob = await res.blob();
|
| 32 |
const url = URL.createObjectURL(blob);
|
| 33 |
const audio = new Audio(url);
|
| 34 |
audio.onended = () => setStatus("idle");
|
| 35 |
+
await audio.play();
|
| 36 |
} catch {
|
|
|
|
| 37 |
const u = new SpeechSynthesisUtterance(text);
|
| 38 |
+
u.rate = 0.9; u.pitch = 1.1;
|
| 39 |
+
const voices = speechSynthesis.getVoices();
|
| 40 |
+
const female = voices.find(v => v.name.includes("Female") || v.name.includes("Samantha") || v.name.includes("Google UK English Female"));
|
| 41 |
+
if (female) u.voice = female;
|
| 42 |
u.onend = () => setStatus("idle");
|
| 43 |
speechSynthesis.speak(u);
|
| 44 |
}
|
| 45 |
};
|
| 46 |
|
| 47 |
+
const handleCommand = async (text) => {
|
| 48 |
const lower = text.toLowerCase();
|
| 49 |
+
if (lower.includes("balance") || lower.includes("how much")) {
|
| 50 |
+
const bal = balance?.balance?.toLocaleString("en-NG") ?? "unavailable";
|
| 51 |
+
speak(`Your available balance is ${bal} Naira.`);
|
| 52 |
+
} else if (lower.includes("send") || lower.includes("transfer") || lower.includes("pay")) {
|
| 53 |
+
speak("Opening the transfer screen for you right now.");
|
| 54 |
+
setTimeout(() => onTransfer && onTransfer(), 1500);
|
| 55 |
+
} else if (lower.includes("bank") || lower.includes("external") || lower.includes("moniepoint")) {
|
| 56 |
+
speak("Opening external bank transfer.");
|
| 57 |
+
setTimeout(() => onExternalTransfer && onExternalTransfer(), 1500);
|
| 58 |
+
} else if (lower.includes("hello") || lower.includes("hi") || lower.includes("hey")) {
|
| 59 |
+
speak(`Hello! I'm Purity, your dolor3v assistant. I can check your balance, help you send money, or transfer to other banks. What would you like to do?`);
|
| 60 |
+
} else if (lower.includes("account") || lower.includes("number")) {
|
| 61 |
+
speak(`Your account number is ${balance?.account_number ?? "unavailable"}.`);
|
| 62 |
} else {
|
| 63 |
+
speak("I can help you check your balance, send money, or make a bank transfer. Just tell me what you need!");
|
| 64 |
}
|
| 65 |
};
|
| 66 |
|
| 67 |
const startListening = () => {
|
| 68 |
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
| 69 |
+
if (!SR) {
|
| 70 |
+
speak("Voice recognition isn't available on this browser. Try Chrome.");
|
| 71 |
+
return;
|
| 72 |
+
}
|
| 73 |
+
const r = new SR();
|
| 74 |
+
r.lang = "en-US";
|
| 75 |
+
r.onstart = () => setStatus("listening");
|
| 76 |
+
r.onresult = (e) => {
|
| 77 |
const text = e.results[0][0].transcript;
|
| 78 |
setTranscript(text);
|
| 79 |
handleCommand(text);
|
| 80 |
};
|
| 81 |
+
r.onerror = () => { setStatus("idle"); speak("I didn't catch that. Please try again."); };
|
| 82 |
+
r.onend = () => { if (status === "listening") setStatus("idle"); };
|
| 83 |
+
r.start();
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
const handleTap = () => {
|
| 87 |
+
if (!active) {
|
| 88 |
+
setActive(true);
|
| 89 |
+
speak("Hi! I'm Purity, your dolor3v banking assistant. How can I help you today?");
|
| 90 |
+
} else {
|
| 91 |
+
startListening();
|
| 92 |
+
}
|
| 93 |
};
|
| 94 |
|
| 95 |
return (
|
| 96 |
<div className="purity-wrap">
|
| 97 |
+
<div className={`purity-orb ${status}`} onClick={handleTap}>
|
| 98 |
<div className="orb-ring" />
|
| 99 |
<div className="orb-ring r2" />
|
| 100 |
<div className="orb-core">
|
|
|
|
| 107 |
{status === "listening" ? "Listening..." : status === "speaking" ? "Speaking..." : active ? "Tap to speak" : "Tap to wake up"}
|
| 108 |
</p>
|
| 109 |
|
| 110 |
+
{transcript && (
|
| 111 |
+
<div className="purity-transcript">You: "{transcript}"</div>
|
| 112 |
+
)}
|
| 113 |
+
{response && (
|
| 114 |
+
<div className="purity-response">Purity: "{response}"</div>
|
| 115 |
+
)}
|
| 116 |
|
| 117 |
<div className="purity-hints">
|
| 118 |
+
<span onClick={() => handleCommand("check my balance")}>💰 Balance</span>
|
| 119 |
+
<span onClick={() => handleCommand("send money")}>↑ Send</span>
|
| 120 |
+
<span onClick={() => handleCommand("transfer to bank")}>🏦 Bank</span>
|
| 121 |
+
<span onClick={() => handleCommand("hello")}>👋 Hi</span>
|
| 122 |
</div>
|
| 123 |
</div>
|
| 124 |
);
|
dolor3v-frontend/src/pages/Dashboard.jsx
CHANGED
|
@@ -3,6 +3,8 @@ import API from "../api";
|
|
| 3 |
import { AuthContext } from "../auth/AuthContext";
|
| 4 |
import { useNavigate } from "react-router-dom";
|
| 5 |
import PurityAI from "../components/PurityAI";
|
|
|
|
|
|
|
| 6 |
import "./Dashboard.css";
|
| 7 |
|
| 8 |
export default function Dashboard() {
|
|
@@ -10,11 +12,7 @@ export default function Dashboard() {
|
|
| 10 |
const [history, setHistory] = useState([]);
|
| 11 |
const [alerts, setAlerts] = useState([]);
|
| 12 |
const [tab, setTab] = useState("home");
|
| 13 |
-
const
|
| 14 |
-
const [extTransfer, setExtTransfer] = useState({ bank_name: "", recipient_name: "", recipient_account: "", amount: "" });
|
| 15 |
-
const [msg, setMsg] = useState("");
|
| 16 |
-
const [loading, setLoading] = useState(false);
|
| 17 |
-
const { logout, accountNumber } = useContext(AuthContext);
|
| 18 |
const nav = useNavigate();
|
| 19 |
|
| 20 |
useEffect(() => { load(); }, []);
|
|
@@ -32,37 +30,11 @@ export default function Dashboard() {
|
|
| 32 |
} catch { nav("/"); }
|
| 33 |
};
|
| 34 |
|
| 35 |
-
const doTransfer = async () => {
|
| 36 |
-
setLoading(true); setMsg("");
|
| 37 |
-
try {
|
| 38 |
-
const res = await API.post("/transfer", { ...transfer, amount: parseFloat(transfer.amount) });
|
| 39 |
-
setMsg(`✅ ${res.data.message} — Ref: ${res.data.reference}`);
|
| 40 |
-
load();
|
| 41 |
-
} catch (e) {
|
| 42 |
-
setMsg(`❌ ${e.response?.data?.message || "Transfer failed"}`);
|
| 43 |
-
}
|
| 44 |
-
setLoading(false);
|
| 45 |
-
};
|
| 46 |
-
|
| 47 |
-
const doExtTransfer = async () => {
|
| 48 |
-
setLoading(true); setMsg("");
|
| 49 |
-
try {
|
| 50 |
-
const res = await API.post("/external-transfer", { ...extTransfer, amount: parseFloat(extTransfer.amount) });
|
| 51 |
-
setMsg(`✅ ${res.data.message} — Ref: ${res.data.reference}`);
|
| 52 |
-
load();
|
| 53 |
-
} catch (e) {
|
| 54 |
-
setMsg(`❌ ${e.response?.data?.message || "Transfer failed"}`);
|
| 55 |
-
}
|
| 56 |
-
setLoading(false);
|
| 57 |
-
};
|
| 58 |
-
|
| 59 |
-
const handleLogout = () => { logout(); nav("/"); };
|
| 60 |
-
|
| 61 |
return (
|
| 62 |
<div className="db-bg">
|
| 63 |
<div className="db-header">
|
| 64 |
<div className="db-logo">dolor<span>3v</span></div>
|
| 65 |
-
<button className="db-logout" onClick={
|
| 66 |
</div>
|
| 67 |
|
| 68 |
{tab === "home" && (
|
|
@@ -86,26 +58,33 @@ export default function Dashboard() {
|
|
| 86 |
|
| 87 |
<div className="quick-actions">
|
| 88 |
<button onClick={() => setTab("transfer")} className="qa-btn">
|
| 89 |
-
<span>↑</span>
|
| 90 |
</button>
|
| 91 |
<button onClick={() => setTab("external")} className="qa-btn">
|
| 92 |
-
<span>🏦</span>
|
| 93 |
</button>
|
| 94 |
<button onClick={() => setTab("history")} className="qa-btn">
|
| 95 |
-
<span>≡</span>
|
| 96 |
</button>
|
| 97 |
<button onClick={() => setTab("purity")} className="qa-btn purity-btn">
|
| 98 |
-
<span>
|
| 99 |
</button>
|
| 100 |
</div>
|
| 101 |
|
| 102 |
<h3 className="section-title">Recent Transactions</h3>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
{history.slice(0, 5).map(t => (
|
| 104 |
<div key={t.id} className={`txn-item ${t.type}`}>
|
| 105 |
<div className="txn-left">
|
| 106 |
<span className="txn-icon">{t.type === "sent" ? "↑" : "↓"}</span>
|
| 107 |
<div>
|
| 108 |
-
<p className="txn-ref">
|
|
|
|
|
|
|
| 109 |
<p className="txn-status">{t.status} • {new Date(t.timestamp).toLocaleDateString()}</p>
|
| 110 |
</div>
|
| 111 |
</div>
|
|
@@ -116,60 +95,36 @@ export default function Dashboard() {
|
|
| 116 |
)}
|
| 117 |
|
| 118 |
{tab === "transfer" && (
|
| 119 |
-
<
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
<div className="form-group">
|
| 124 |
-
<label>Recipient Account</label>
|
| 125 |
-
<input placeholder="ACC1234567890" value={transfer.recipient_account_number}
|
| 126 |
-
onChange={e => setTransfer({ ...transfer, recipient_account_number: e.target.value })} />
|
| 127 |
-
</div>
|
| 128 |
-
<div className="form-group">
|
| 129 |
-
<label>Amount (₦)</label>
|
| 130 |
-
<input type="number" placeholder="0.00" value={transfer.amount}
|
| 131 |
-
onChange={e => setTransfer({ ...transfer, amount: e.target.value })} />
|
| 132 |
-
</div>
|
| 133 |
-
<button className="submit-btn" onClick={doTransfer} disabled={loading}>
|
| 134 |
-
{loading ? <span className="spinner" /> : "Send Money"}
|
| 135 |
-
</button>
|
| 136 |
-
</div>
|
| 137 |
)}
|
| 138 |
|
| 139 |
{tab === "external" && (
|
| 140 |
-
<
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
{[
|
| 145 |
-
{ key: "bank_name", label: "Bank Name", ph: "GTBank" },
|
| 146 |
-
{ key: "recipient_name", label: "Recipient Name", ph: "John Doe" },
|
| 147 |
-
{ key: "recipient_account", label: "Account Number", ph: "0123456789" },
|
| 148 |
-
{ key: "amount", label: "Amount (₦)", ph: "0.00", type: "number" },
|
| 149 |
-
].map(f => (
|
| 150 |
-
<div className="form-group" key={f.key}>
|
| 151 |
-
<label>{f.label}</label>
|
| 152 |
-
<input type={f.type || "text"} placeholder={f.ph}
|
| 153 |
-
value={extTransfer[f.key]}
|
| 154 |
-
onChange={e => setExtTransfer({ ...extTransfer, [f.key]: e.target.value })} />
|
| 155 |
-
</div>
|
| 156 |
-
))}
|
| 157 |
-
<button className="submit-btn" onClick={doExtTransfer} disabled={loading}>
|
| 158 |
-
{loading ? <span className="spinner" /> : "Transfer"}
|
| 159 |
-
</button>
|
| 160 |
-
</div>
|
| 161 |
)}
|
| 162 |
|
| 163 |
{tab === "history" && (
|
| 164 |
<div className="db-content">
|
| 165 |
<button className="back-btn" onClick={() => setTab("home")}>← Back</button>
|
| 166 |
<h2 className="page-title">Transaction History</h2>
|
|
|
|
|
|
|
|
|
|
| 167 |
{history.map(t => (
|
| 168 |
<div key={t.id} className={`txn-item ${t.type}`}>
|
| 169 |
<div className="txn-left">
|
| 170 |
<span className="txn-icon">{t.type === "sent" ? "↑" : "↓"}</span>
|
| 171 |
<div>
|
| 172 |
-
<p className="txn-ref">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
<p className="txn-status">{t.status} • {new Date(t.timestamp).toLocaleDateString()}</p>
|
| 174 |
<p className="txn-ai">{t.ai_review}</p>
|
| 175 |
</div>
|
|
@@ -183,7 +138,11 @@ export default function Dashboard() {
|
|
| 183 |
{tab === "purity" && (
|
| 184 |
<div className="db-content">
|
| 185 |
<button className="back-btn" onClick={() => setTab("home")}>← Back</button>
|
| 186 |
-
<PurityAI
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
</div>
|
| 188 |
)}
|
| 189 |
</div>
|
|
|
|
| 3 |
import { AuthContext } from "../auth/AuthContext";
|
| 4 |
import { useNavigate } from "react-router-dom";
|
| 5 |
import PurityAI from "../components/PurityAI";
|
| 6 |
+
import Transfer from "./Transfer";
|
| 7 |
+
import ExternalTransfer from "./ExternalTransfer";
|
| 8 |
import "./Dashboard.css";
|
| 9 |
|
| 10 |
export default function Dashboard() {
|
|
|
|
| 12 |
const [history, setHistory] = useState([]);
|
| 13 |
const [alerts, setAlerts] = useState([]);
|
| 14 |
const [tab, setTab] = useState("home");
|
| 15 |
+
const { logout } = useContext(AuthContext);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
const nav = useNavigate();
|
| 17 |
|
| 18 |
useEffect(() => { load(); }, []);
|
|
|
|
| 30 |
} catch { nav("/"); }
|
| 31 |
};
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
return (
|
| 34 |
<div className="db-bg">
|
| 35 |
<div className="db-header">
|
| 36 |
<div className="db-logo">dolor<span>3v</span></div>
|
| 37 |
+
<button className="db-logout" onClick={() => { logout(); nav("/"); }}>Logout</button>
|
| 38 |
</div>
|
| 39 |
|
| 40 |
{tab === "home" && (
|
|
|
|
| 58 |
|
| 59 |
<div className="quick-actions">
|
| 60 |
<button onClick={() => setTab("transfer")} className="qa-btn">
|
| 61 |
+
<span>↑</span>Send
|
| 62 |
</button>
|
| 63 |
<button onClick={() => setTab("external")} className="qa-btn">
|
| 64 |
+
<span>🏦</span>Banks
|
| 65 |
</button>
|
| 66 |
<button onClick={() => setTab("history")} className="qa-btn">
|
| 67 |
+
<span>≡</span>History
|
| 68 |
</button>
|
| 69 |
<button onClick={() => setTab("purity")} className="qa-btn purity-btn">
|
| 70 |
+
<span>✦</span>Purity
|
| 71 |
</button>
|
| 72 |
</div>
|
| 73 |
|
| 74 |
<h3 className="section-title">Recent Transactions</h3>
|
| 75 |
+
{history.length === 0 && (
|
| 76 |
+
<p style={{ color: "#334155", fontSize: "0.85rem", textAlign: "center", padding: "20px 0" }}>
|
| 77 |
+
No transactions yet
|
| 78 |
+
</p>
|
| 79 |
+
)}
|
| 80 |
{history.slice(0, 5).map(t => (
|
| 81 |
<div key={t.id} className={`txn-item ${t.type}`}>
|
| 82 |
<div className="txn-left">
|
| 83 |
<span className="txn-icon">{t.type === "sent" ? "↑" : "↓"}</span>
|
| 84 |
<div>
|
| 85 |
+
<p className="txn-ref">
|
| 86 |
+
{t.is_external ? `🏦 ${t.recipient.replace("EXT:", "").split(":")[0]}` : `To: ${t.recipient}`}
|
| 87 |
+
</p>
|
| 88 |
<p className="txn-status">{t.status} • {new Date(t.timestamp).toLocaleDateString()}</p>
|
| 89 |
</div>
|
| 90 |
</div>
|
|
|
|
| 95 |
)}
|
| 96 |
|
| 97 |
{tab === "transfer" && (
|
| 98 |
+
<Transfer
|
| 99 |
+
onBack={() => setTab("home")}
|
| 100 |
+
onDone={() => { setTab("home"); load(); }}
|
| 101 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
)}
|
| 103 |
|
| 104 |
{tab === "external" && (
|
| 105 |
+
<ExternalTransfer
|
| 106 |
+
onBack={() => setTab("home")}
|
| 107 |
+
onDone={() => { setTab("home"); load(); }}
|
| 108 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
)}
|
| 110 |
|
| 111 |
{tab === "history" && (
|
| 112 |
<div className="db-content">
|
| 113 |
<button className="back-btn" onClick={() => setTab("home")}>← Back</button>
|
| 114 |
<h2 className="page-title">Transaction History</h2>
|
| 115 |
+
{history.length === 0 && (
|
| 116 |
+
<p style={{ color: "#334155", textAlign: "center", padding: "40px 0" }}>No transactions yet</p>
|
| 117 |
+
)}
|
| 118 |
{history.map(t => (
|
| 119 |
<div key={t.id} className={`txn-item ${t.type}`}>
|
| 120 |
<div className="txn-left">
|
| 121 |
<span className="txn-icon">{t.type === "sent" ? "↑" : "↓"}</span>
|
| 122 |
<div>
|
| 123 |
+
<p className="txn-ref">
|
| 124 |
+
{t.is_external
|
| 125 |
+
? `🏦 ${t.recipient.replace("EXT:", "").split(":").slice(0,2).join(" • ")}`
|
| 126 |
+
: `Ref: ${t.reference}`}
|
| 127 |
+
</p>
|
| 128 |
<p className="txn-status">{t.status} • {new Date(t.timestamp).toLocaleDateString()}</p>
|
| 129 |
<p className="txn-ai">{t.ai_review}</p>
|
| 130 |
</div>
|
|
|
|
| 138 |
{tab === "purity" && (
|
| 139 |
<div className="db-content">
|
| 140 |
<button className="back-btn" onClick={() => setTab("home")}>← Back</button>
|
| 141 |
+
<PurityAI
|
| 142 |
+
balance={balance}
|
| 143 |
+
onTransfer={() => setTab("transfer")}
|
| 144 |
+
onExternalTransfer={() => setTab("external")}
|
| 145 |
+
/>
|
| 146 |
</div>
|
| 147 |
)}
|
| 148 |
</div>
|
endpoints.py
CHANGED
|
@@ -1,13 +1,20 @@
|
|
| 1 |
from flask import request, jsonify
|
| 2 |
-
from models import db, User, Transaction, Alert
|
| 3 |
from flask_jwt_extended import JWTManager, jwt_required, create_access_token, get_jwt_identity
|
| 4 |
from werkzeug.security import generate_password_hash, check_password_hash
|
| 5 |
-
from datetime import datetime
|
| 6 |
from ai_review import ai_review_transaction
|
|
|
|
| 7 |
import random, string, uuid
|
| 8 |
|
| 9 |
jwt = JWTManager()
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
def generate_account_number():
|
| 12 |
while True:
|
| 13 |
number = "ACC" + "".join(random.choices(string.digits, k=10))
|
|
@@ -15,8 +22,7 @@ def generate_account_number():
|
|
| 15 |
return number
|
| 16 |
|
| 17 |
def create_alert(account_number, message):
|
| 18 |
-
|
| 19 |
-
db.session.add(alert)
|
| 20 |
|
| 21 |
def register():
|
| 22 |
data = request.get_json()
|
|
@@ -27,10 +33,11 @@ def register():
|
|
| 27 |
email=data["email"],
|
| 28 |
password=generate_password_hash(data["password"]),
|
| 29 |
account_number=generate_account_number(),
|
|
|
|
| 30 |
)
|
| 31 |
db.session.add(user)
|
| 32 |
db.session.commit()
|
| 33 |
-
create_alert(user.account_number, f"Welcome to dolor3v, {user.name}!
|
| 34 |
db.session.commit()
|
| 35 |
return jsonify({"message": "User registered successfully", "account_number": user.account_number})
|
| 36 |
|
|
@@ -40,19 +47,110 @@ def login():
|
|
| 40 |
if not user or not check_password_hash(user.password, data["password"]):
|
| 41 |
return jsonify({"message": "Invalid credentials"}), 401
|
| 42 |
access_token = create_access_token(identity=user.account_number)
|
| 43 |
-
return jsonify({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
@jwt_required()
|
| 46 |
def transfer():
|
| 47 |
data = request.get_json()
|
| 48 |
sender_acc = get_jwt_identity()
|
| 49 |
sender = User.query.filter_by(account_number=sender_acc).first()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
recipient = User.query.filter_by(account_number=data["recipient_account_number"]).first()
|
| 51 |
if not sender or not recipient:
|
| 52 |
return jsonify({"message": "Invalid account numbers"}), 404
|
| 53 |
if sender.account_number == recipient.account_number:
|
| 54 |
return jsonify({"message": "Cannot transfer to same account"}), 400
|
|
|
|
| 55 |
amount = float(data["amount"])
|
|
|
|
|
|
|
|
|
|
| 56 |
if sender.balance < amount:
|
| 57 |
return jsonify({"message": "Insufficient funds"}), 400
|
| 58 |
|
|
@@ -66,15 +164,12 @@ def transfer():
|
|
| 66 |
t = Transaction(
|
| 67 |
sender_account_number=sender.account_number,
|
| 68 |
recipient_account_number=recipient.account_number,
|
| 69 |
-
amount=amount,
|
| 70 |
-
|
| 71 |
-
is_external=False,
|
| 72 |
-
reference=str(uuid.uuid4())[:8].upper(),
|
| 73 |
-
ai_review=reason,
|
| 74 |
)
|
| 75 |
db.session.add(t)
|
| 76 |
-
create_alert(sender.account_number, f"
|
| 77 |
-
create_alert(recipient.account_number, f"
|
| 78 |
db.session.commit()
|
| 79 |
return jsonify({"message": f"Transfer {status}", "reference": t.reference, "ai_review": reason})
|
| 80 |
|
|
@@ -83,15 +178,23 @@ def external_transfer():
|
|
| 83 |
data = request.get_json()
|
| 84 |
sender_acc = get_jwt_identity()
|
| 85 |
sender = User.query.filter_by(account_number=sender_acc).first()
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
amount = float(data["amount"])
|
|
|
|
|
|
|
|
|
|
| 89 |
if sender.balance < amount:
|
| 90 |
return jsonify({"message": "Insufficient funds"}), 400
|
| 91 |
|
| 92 |
bank_name = data.get("bank_name", "External Bank")
|
| 93 |
recipient_name = data.get("recipient_name", "Unknown")
|
| 94 |
-
|
| 95 |
decision, reason = ai_review_transaction(sender, recipient_name, amount, True)
|
| 96 |
status = "pending" if decision == "hold" else "processing"
|
| 97 |
|
|
@@ -102,44 +205,102 @@ def external_transfer():
|
|
| 102 |
t = Transaction(
|
| 103 |
sender_account_number=sender.account_number,
|
| 104 |
recipient_account_number=f"EXT:{bank_name}:{data.get('recipient_account')}",
|
| 105 |
-
amount=amount,
|
| 106 |
-
|
| 107 |
-
is_external=True,
|
| 108 |
-
reference=ref,
|
| 109 |
-
ai_review=reason,
|
| 110 |
)
|
| 111 |
db.session.add(t)
|
| 112 |
-
create_alert(sender.account_number, f"External transfer
|
| 113 |
db.session.commit()
|
| 114 |
-
return jsonify({"message": f"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
@jwt_required()
|
| 117 |
def balance():
|
| 118 |
-
|
| 119 |
-
user = User.query.filter_by(account_number=
|
| 120 |
if not user:
|
| 121 |
return jsonify({"message": "Invalid account number"}), 404
|
| 122 |
-
return jsonify({"balance": user.balance, "account_number": user.account_number})
|
| 123 |
|
| 124 |
@jwt_required()
|
| 125 |
def history():
|
| 126 |
-
|
| 127 |
txns = Transaction.query.filter(
|
| 128 |
-
(Transaction.sender_account_number ==
|
| 129 |
-
(Transaction.recipient_account_number ==
|
| 130 |
).order_by(Transaction.timestamp.desc()).all()
|
| 131 |
-
|
| 132 |
result = []
|
| 133 |
for t in txns:
|
| 134 |
result.append({
|
| 135 |
"id": t.id,
|
| 136 |
-
"type": "sent" if t.sender_account_number ==
|
| 137 |
"amount": t.amount,
|
| 138 |
"sender": t.sender_account_number,
|
| 139 |
"recipient": t.recipient_account_number,
|
| 140 |
"status": t.status,
|
| 141 |
"reference": t.reference,
|
| 142 |
"is_external": t.is_external,
|
|
|
|
| 143 |
"ai_review": t.ai_review,
|
| 144 |
"timestamp": t.timestamp.isoformat(),
|
| 145 |
})
|
|
@@ -147,9 +308,8 @@ def history():
|
|
| 147 |
|
| 148 |
@jwt_required()
|
| 149 |
def alerts():
|
| 150 |
-
|
| 151 |
-
unread = Alert.query.filter_by(account_number=
|
| 152 |
-
.order_by(Alert.timestamp.desc()).all()
|
| 153 |
result = [{"id": a.id, "message": a.message, "timestamp": a.timestamp.isoformat()} for a in unread]
|
| 154 |
for a in unread:
|
| 155 |
a.read = True
|
|
@@ -158,8 +318,23 @@ def alerts():
|
|
| 158 |
|
| 159 |
@jwt_required()
|
| 160 |
def lookup():
|
| 161 |
-
|
| 162 |
-
user = User.query.filter_by(account_number=
|
| 163 |
if not user:
|
| 164 |
return jsonify({"message": "Account not found"}), 404
|
| 165 |
return jsonify({"name": user.name, "account_number": user.account_number, "bank": "dolor3v"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from flask import request, jsonify
|
| 2 |
+
from models import db, User, Transaction, Alert, PurityMemory
|
| 3 |
from flask_jwt_extended import JWTManager, jwt_required, create_access_token, get_jwt_identity
|
| 4 |
from werkzeug.security import generate_password_hash, check_password_hash
|
|
|
|
| 5 |
from ai_review import ai_review_transaction
|
| 6 |
+
from datetime import datetime
|
| 7 |
import random, string, uuid
|
| 8 |
|
| 9 |
jwt = JWTManager()
|
| 10 |
|
| 11 |
+
TIER_LIMITS = {
|
| 12 |
+
0: {"daily": 0, "max_balance": 0, "label": "Unverified"},
|
| 13 |
+
1: {"daily": 50000, "max_balance": 300000, "label": "Tier 1 - NIN"},
|
| 14 |
+
2: {"daily": 200000, "max_balance": 500000, "label": "Tier 2 - NIN+BVN"},
|
| 15 |
+
3: {"daily": 5000000, "max_balance": float('inf'), "label": "Tier 3 - Full KYC"},
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
def generate_account_number():
|
| 19 |
while True:
|
| 20 |
number = "ACC" + "".join(random.choices(string.digits, k=10))
|
|
|
|
| 22 |
return number
|
| 23 |
|
| 24 |
def create_alert(account_number, message):
|
| 25 |
+
db.session.add(Alert(account_number=account_number, message=message))
|
|
|
|
| 26 |
|
| 27 |
def register():
|
| 28 |
data = request.get_json()
|
|
|
|
| 33 |
email=data["email"],
|
| 34 |
password=generate_password_hash(data["password"]),
|
| 35 |
account_number=generate_account_number(),
|
| 36 |
+
phone=data.get("phone", ""),
|
| 37 |
)
|
| 38 |
db.session.add(user)
|
| 39 |
db.session.commit()
|
| 40 |
+
create_alert(user.account_number, f"Welcome to dolor3v, {user.name}! Complete KYC to unlock transfers.")
|
| 41 |
db.session.commit()
|
| 42 |
return jsonify({"message": "User registered successfully", "account_number": user.account_number})
|
| 43 |
|
|
|
|
| 47 |
if not user or not check_password_hash(user.password, data["password"]):
|
| 48 |
return jsonify({"message": "Invalid credentials"}), 401
|
| 49 |
access_token = create_access_token(identity=user.account_number)
|
| 50 |
+
return jsonify({
|
| 51 |
+
"access_token": access_token,
|
| 52 |
+
"account_number": user.account_number,
|
| 53 |
+
"name": user.name,
|
| 54 |
+
"tier": user.tier,
|
| 55 |
+
})
|
| 56 |
+
|
| 57 |
+
@jwt_required()
|
| 58 |
+
def set_pin():
|
| 59 |
+
data = request.get_json()
|
| 60 |
+
acc = get_jwt_identity()
|
| 61 |
+
user = User.query.filter_by(account_number=acc).first()
|
| 62 |
+
pin = data.get("pin", "")
|
| 63 |
+
if len(pin) != 4 or not pin.isdigit():
|
| 64 |
+
return jsonify({"message": "PIN must be 4 digits"}), 400
|
| 65 |
+
user.pin = generate_password_hash(pin)
|
| 66 |
+
db.session.commit()
|
| 67 |
+
return jsonify({"message": "PIN set successfully"})
|
| 68 |
+
|
| 69 |
+
@jwt_required()
|
| 70 |
+
def verify_pin():
|
| 71 |
+
data = request.get_json()
|
| 72 |
+
acc = get_jwt_identity()
|
| 73 |
+
user = User.query.filter_by(account_number=acc).first()
|
| 74 |
+
if not user.pin:
|
| 75 |
+
return jsonify({"valid": False, "message": "No PIN set"}), 400
|
| 76 |
+
valid = check_password_hash(user.pin, data.get("pin", ""))
|
| 77 |
+
return jsonify({"valid": valid})
|
| 78 |
+
|
| 79 |
+
@jwt_required()
|
| 80 |
+
def kyc_upgrade():
|
| 81 |
+
acc = get_jwt_identity()
|
| 82 |
+
user = User.query.filter_by(account_number=acc).first()
|
| 83 |
+
data = request.get_json()
|
| 84 |
+
tier = data.get("tier", 1)
|
| 85 |
+
|
| 86 |
+
if tier == 1:
|
| 87 |
+
if not data.get("nin"):
|
| 88 |
+
return jsonify({"message": "NIN required for Tier 1"}), 400
|
| 89 |
+
user.nin = data["nin"]
|
| 90 |
+
user.tier = 1
|
| 91 |
+
user.daily_limit = 50000
|
| 92 |
+
user.max_balance = 300000
|
| 93 |
+
create_alert(acc, "KYC Tier 1 approved! Daily limit: ₦50,000")
|
| 94 |
+
|
| 95 |
+
elif tier == 2:
|
| 96 |
+
if not data.get("bvn"):
|
| 97 |
+
return jsonify({"message": "BVN required for Tier 2"}), 400
|
| 98 |
+
user.bvn = data["bvn"]
|
| 99 |
+
user.tier = 2
|
| 100 |
+
user.daily_limit = 200000
|
| 101 |
+
user.max_balance = 500000
|
| 102 |
+
create_alert(acc, "KYC Tier 2 approved! Daily limit: ₦200,000")
|
| 103 |
+
|
| 104 |
+
elif tier == 3:
|
| 105 |
+
user.tier = 3
|
| 106 |
+
user.daily_limit = 5000000
|
| 107 |
+
user.max_balance = float('inf')
|
| 108 |
+
create_alert(acc, "KYC Tier 3 approved! Daily limit: ₦5,000,000. Unlimited balance.")
|
| 109 |
+
|
| 110 |
+
db.session.commit()
|
| 111 |
+
return jsonify({"message": f"Tier {tier} upgrade successful", "tier": user.tier})
|
| 112 |
+
|
| 113 |
+
@jwt_required()
|
| 114 |
+
def profile():
|
| 115 |
+
acc = get_jwt_identity()
|
| 116 |
+
user = User.query.filter_by(account_number=acc).first()
|
| 117 |
+
limits = TIER_LIMITS.get(user.tier, TIER_LIMITS[0])
|
| 118 |
+
return jsonify({
|
| 119 |
+
"name": user.name,
|
| 120 |
+
"email": user.email,
|
| 121 |
+
"phone": user.phone,
|
| 122 |
+
"account_number": user.account_number,
|
| 123 |
+
"tier": user.tier,
|
| 124 |
+
"tier_label": limits["label"],
|
| 125 |
+
"daily_limit": limits["daily"],
|
| 126 |
+
"max_balance": limits["max_balance"] if limits["max_balance"] != float('inf') else -1,
|
| 127 |
+
"balance": user.balance,
|
| 128 |
+
"pin_set": user.pin is not None,
|
| 129 |
+
})
|
| 130 |
|
| 131 |
@jwt_required()
|
| 132 |
def transfer():
|
| 133 |
data = request.get_json()
|
| 134 |
sender_acc = get_jwt_identity()
|
| 135 |
sender = User.query.filter_by(account_number=sender_acc).first()
|
| 136 |
+
|
| 137 |
+
if sender.tier == 0:
|
| 138 |
+
return jsonify({"message": "Complete KYC to make transfers"}), 403
|
| 139 |
+
if not sender.pin:
|
| 140 |
+
return jsonify({"message": "Set a PIN before making transfers"}), 403
|
| 141 |
+
if not check_password_hash(sender.pin, data.get("pin", "")):
|
| 142 |
+
return jsonify({"message": "Invalid PIN"}), 401
|
| 143 |
+
|
| 144 |
recipient = User.query.filter_by(account_number=data["recipient_account_number"]).first()
|
| 145 |
if not sender or not recipient:
|
| 146 |
return jsonify({"message": "Invalid account numbers"}), 404
|
| 147 |
if sender.account_number == recipient.account_number:
|
| 148 |
return jsonify({"message": "Cannot transfer to same account"}), 400
|
| 149 |
+
|
| 150 |
amount = float(data["amount"])
|
| 151 |
+
limit = TIER_LIMITS[sender.tier]["daily"]
|
| 152 |
+
if amount > limit:
|
| 153 |
+
return jsonify({"message": f"Amount exceeds your daily limit of ₦{limit:,.0f}"}), 400
|
| 154 |
if sender.balance < amount:
|
| 155 |
return jsonify({"message": "Insufficient funds"}), 400
|
| 156 |
|
|
|
|
| 164 |
t = Transaction(
|
| 165 |
sender_account_number=sender.account_number,
|
| 166 |
recipient_account_number=recipient.account_number,
|
| 167 |
+
amount=amount, status=status, is_external=False,
|
| 168 |
+
reference=str(uuid.uuid4())[:8].upper(), ai_review=reason,
|
|
|
|
|
|
|
|
|
|
| 169 |
)
|
| 170 |
db.session.add(t)
|
| 171 |
+
create_alert(sender.account_number, f"Debit: ₦{amount:,.0f} to {recipient.name}. Ref: {t.reference}")
|
| 172 |
+
create_alert(recipient.account_number, f"Credit: ₦{amount:,.0f} from {sender.name}. Ref: {t.reference}")
|
| 173 |
db.session.commit()
|
| 174 |
return jsonify({"message": f"Transfer {status}", "reference": t.reference, "ai_review": reason})
|
| 175 |
|
|
|
|
| 178 |
data = request.get_json()
|
| 179 |
sender_acc = get_jwt_identity()
|
| 180 |
sender = User.query.filter_by(account_number=sender_acc).first()
|
| 181 |
+
|
| 182 |
+
if sender.tier == 0:
|
| 183 |
+
return jsonify({"message": "Complete KYC to make transfers"}), 403
|
| 184 |
+
if not sender.pin:
|
| 185 |
+
return jsonify({"message": "Set a PIN before making transfers"}), 403
|
| 186 |
+
if not check_password_hash(sender.pin, data.get("pin", "")):
|
| 187 |
+
return jsonify({"message": "Invalid PIN"}), 401
|
| 188 |
+
|
| 189 |
amount = float(data["amount"])
|
| 190 |
+
limit = TIER_LIMITS[sender.tier]["daily"]
|
| 191 |
+
if amount > limit:
|
| 192 |
+
return jsonify({"message": f"Exceeds daily limit of ₦{limit:,.0f}"}), 400
|
| 193 |
if sender.balance < amount:
|
| 194 |
return jsonify({"message": "Insufficient funds"}), 400
|
| 195 |
|
| 196 |
bank_name = data.get("bank_name", "External Bank")
|
| 197 |
recipient_name = data.get("recipient_name", "Unknown")
|
|
|
|
| 198 |
decision, reason = ai_review_transaction(sender, recipient_name, amount, True)
|
| 199 |
status = "pending" if decision == "hold" else "processing"
|
| 200 |
|
|
|
|
| 205 |
t = Transaction(
|
| 206 |
sender_account_number=sender.account_number,
|
| 207 |
recipient_account_number=f"EXT:{bank_name}:{data.get('recipient_account')}",
|
| 208 |
+
amount=amount, status=status, is_external=True,
|
| 209 |
+
reference=ref, ai_review=reason,
|
|
|
|
|
|
|
|
|
|
| 210 |
)
|
| 211 |
db.session.add(t)
|
| 212 |
+
create_alert(sender.account_number, f"External transfer: ₦{amount:,.0f} to {recipient_name} @ {bank_name}. Ref: {ref}")
|
| 213 |
db.session.commit()
|
| 214 |
+
return jsonify({"message": f"Transfer {status}", "reference": ref, "ai_review": reason})
|
| 215 |
+
|
| 216 |
+
@jwt_required()
|
| 217 |
+
def buy_airtime():
|
| 218 |
+
data = request.get_json()
|
| 219 |
+
acc = get_jwt_identity()
|
| 220 |
+
user = User.query.filter_by(account_number=acc).first()
|
| 221 |
+
|
| 222 |
+
if not user.pin or not check_password_hash(user.pin, data.get("pin", "")):
|
| 223 |
+
return jsonify({"message": "Invalid PIN"}), 401
|
| 224 |
+
|
| 225 |
+
amount = float(data["amount"])
|
| 226 |
+
phone = data.get("phone")
|
| 227 |
+
network = data.get("network", "MTN")
|
| 228 |
+
|
| 229 |
+
if user.balance < amount:
|
| 230 |
+
return jsonify({"message": "Insufficient funds"}), 400
|
| 231 |
+
|
| 232 |
+
user.balance -= amount
|
| 233 |
+
ref = "AIR" + str(uuid.uuid4())[:6].upper()
|
| 234 |
+
t = Transaction(
|
| 235 |
+
sender_account_number=user.account_number,
|
| 236 |
+
recipient_account_number=f"AIRTIME:{network}:{phone}",
|
| 237 |
+
amount=amount, status="completed", is_external=True,
|
| 238 |
+
reference=ref, ai_review="Airtime purchase",
|
| 239 |
+
transaction_type="airtime",
|
| 240 |
+
)
|
| 241 |
+
db.session.add(t)
|
| 242 |
+
create_alert(user.account_number, f"Airtime: ₦{amount:,.0f} {network} sent to {phone}. Ref: {ref}")
|
| 243 |
+
db.session.commit()
|
| 244 |
+
return jsonify({"message": f"₦{amount:,.0f} {network} airtime sent to {phone}", "reference": ref})
|
| 245 |
+
|
| 246 |
+
@jwt_required()
|
| 247 |
+
def buy_data():
|
| 248 |
+
data = request.get_json()
|
| 249 |
+
acc = get_jwt_identity()
|
| 250 |
+
user = User.query.filter_by(account_number=acc).first()
|
| 251 |
+
|
| 252 |
+
if not user.pin or not check_password_hash(user.pin, data.get("pin", "")):
|
| 253 |
+
return jsonify({"message": "Invalid PIN"}), 401
|
| 254 |
+
|
| 255 |
+
amount = float(data["amount"])
|
| 256 |
+
phone = data.get("phone")
|
| 257 |
+
network = data.get("network", "MTN")
|
| 258 |
+
plan = data.get("plan", "1GB")
|
| 259 |
+
|
| 260 |
+
if user.balance < amount:
|
| 261 |
+
return jsonify({"message": "Insufficient funds"}), 400
|
| 262 |
+
|
| 263 |
+
user.balance -= amount
|
| 264 |
+
ref = "DATA" + str(uuid.uuid4())[:6].upper()
|
| 265 |
+
t = Transaction(
|
| 266 |
+
sender_account_number=user.account_number,
|
| 267 |
+
recipient_account_number=f"DATA:{network}:{phone}",
|
| 268 |
+
amount=amount, status="completed", is_external=True,
|
| 269 |
+
reference=ref, ai_review="Data purchase",
|
| 270 |
+
transaction_type="data",
|
| 271 |
+
)
|
| 272 |
+
db.session.add(t)
|
| 273 |
+
create_alert(user.account_number, f"Data: {plan} {network} sent to {phone}. Ref: {ref}")
|
| 274 |
+
db.session.commit()
|
| 275 |
+
return jsonify({"message": f"{plan} {network} data sent to {phone}", "reference": ref})
|
| 276 |
|
| 277 |
@jwt_required()
|
| 278 |
def balance():
|
| 279 |
+
acc = get_jwt_identity()
|
| 280 |
+
user = User.query.filter_by(account_number=acc).first()
|
| 281 |
if not user:
|
| 282 |
return jsonify({"message": "Invalid account number"}), 404
|
| 283 |
+
return jsonify({"balance": user.balance, "account_number": user.account_number, "name": user.name})
|
| 284 |
|
| 285 |
@jwt_required()
|
| 286 |
def history():
|
| 287 |
+
acc = get_jwt_identity()
|
| 288 |
txns = Transaction.query.filter(
|
| 289 |
+
(Transaction.sender_account_number == acc) |
|
| 290 |
+
(Transaction.recipient_account_number == acc)
|
| 291 |
).order_by(Transaction.timestamp.desc()).all()
|
|
|
|
| 292 |
result = []
|
| 293 |
for t in txns:
|
| 294 |
result.append({
|
| 295 |
"id": t.id,
|
| 296 |
+
"type": "sent" if t.sender_account_number == acc else "received",
|
| 297 |
"amount": t.amount,
|
| 298 |
"sender": t.sender_account_number,
|
| 299 |
"recipient": t.recipient_account_number,
|
| 300 |
"status": t.status,
|
| 301 |
"reference": t.reference,
|
| 302 |
"is_external": t.is_external,
|
| 303 |
+
"transaction_type": t.transaction_type,
|
| 304 |
"ai_review": t.ai_review,
|
| 305 |
"timestamp": t.timestamp.isoformat(),
|
| 306 |
})
|
|
|
|
| 308 |
|
| 309 |
@jwt_required()
|
| 310 |
def alerts():
|
| 311 |
+
acc = get_jwt_identity()
|
| 312 |
+
unread = Alert.query.filter_by(account_number=acc, read=False).order_by(Alert.timestamp.desc()).all()
|
|
|
|
| 313 |
result = [{"id": a.id, "message": a.message, "timestamp": a.timestamp.isoformat()} for a in unread]
|
| 314 |
for a in unread:
|
| 315 |
a.read = True
|
|
|
|
| 318 |
|
| 319 |
@jwt_required()
|
| 320 |
def lookup():
|
| 321 |
+
acc_num = request.args.get("account_number")
|
| 322 |
+
user = User.query.filter_by(account_number=acc_num).first()
|
| 323 |
if not user:
|
| 324 |
return jsonify({"message": "Account not found"}), 404
|
| 325 |
return jsonify({"name": user.name, "account_number": user.account_number, "bank": "dolor3v"})
|
| 326 |
+
|
| 327 |
+
@jwt_required()
|
| 328 |
+
def purity_memory():
|
| 329 |
+
acc = get_jwt_identity()
|
| 330 |
+
data = request.get_json()
|
| 331 |
+
mem = PurityMemory(account_number=acc, memory=data.get("memory", ""))
|
| 332 |
+
db.session.add(mem)
|
| 333 |
+
db.session.commit()
|
| 334 |
+
return jsonify({"message": "Memory saved"})
|
| 335 |
+
|
| 336 |
+
@jwt_required()
|
| 337 |
+
def get_purity_memory():
|
| 338 |
+
acc = get_jwt_identity()
|
| 339 |
+
mems = PurityMemory.query.filter_by(account_number=acc).order_by(PurityMemory.timestamp.desc()).limit(20).all()
|
| 340 |
+
return jsonify({"memories": [m.memory for m in mems]})
|
models.py
CHANGED
|
@@ -7,9 +7,21 @@ class User(db.Model):
|
|
| 7 |
id = db.Column(db.Integer, primary_key=True)
|
| 8 |
name = db.Column(db.String(100), nullable=False)
|
| 9 |
email = db.Column(db.String(100), nullable=False, unique=True)
|
| 10 |
-
password = db.Column(db.String(
|
| 11 |
account_number = db.Column(db.String(20), nullable=False, unique=True)
|
| 12 |
balance = db.Column(db.Float, nullable=False, default=0.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
class Transaction(db.Model):
|
| 15 |
id = db.Column(db.Integer, primary_key=True)
|
|
@@ -20,6 +32,7 @@ class Transaction(db.Model):
|
|
| 20 |
is_external = db.Column(db.Boolean, default=False)
|
| 21 |
reference = db.Column(db.String(50), nullable=True)
|
| 22 |
ai_review = db.Column(db.String(500), nullable=True)
|
|
|
|
| 23 |
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
| 24 |
|
| 25 |
class Alert(db.Model):
|
|
@@ -28,3 +41,9 @@ class Alert(db.Model):
|
|
| 28 |
message = db.Column(db.String(500), nullable=False)
|
| 29 |
read = db.Column(db.Boolean, default=False)
|
| 30 |
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
id = db.Column(db.Integer, primary_key=True)
|
| 8 |
name = db.Column(db.String(100), nullable=False)
|
| 9 |
email = db.Column(db.String(100), nullable=False, unique=True)
|
| 10 |
+
password = db.Column(db.String(200), nullable=False)
|
| 11 |
account_number = db.Column(db.String(20), nullable=False, unique=True)
|
| 12 |
balance = db.Column(db.Float, nullable=False, default=0.0)
|
| 13 |
+
phone = db.Column(db.String(20), nullable=True)
|
| 14 |
+
pin = db.Column(db.String(200), nullable=True)
|
| 15 |
+
tier = db.Column(db.Integer, default=0)
|
| 16 |
+
# KYC fields
|
| 17 |
+
nin = db.Column(db.String(20), nullable=True)
|
| 18 |
+
bvn = db.Column(db.String(20), nullable=True)
|
| 19 |
+
id_card_url = db.Column(db.String(200), nullable=True)
|
| 20 |
+
nepa_bill_url = db.Column(db.String(200), nullable=True)
|
| 21 |
+
kyc_status = db.Column(db.String(20), default="none")
|
| 22 |
+
daily_limit = db.Column(db.Float, default=0.0)
|
| 23 |
+
max_balance = db.Column(db.Float, default=200000.0)
|
| 24 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 25 |
|
| 26 |
class Transaction(db.Model):
|
| 27 |
id = db.Column(db.Integer, primary_key=True)
|
|
|
|
| 32 |
is_external = db.Column(db.Boolean, default=False)
|
| 33 |
reference = db.Column(db.String(50), nullable=True)
|
| 34 |
ai_review = db.Column(db.String(500), nullable=True)
|
| 35 |
+
transaction_type = db.Column(db.String(20), default="transfer")
|
| 36 |
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
| 37 |
|
| 38 |
class Alert(db.Model):
|
|
|
|
| 41 |
message = db.Column(db.String(500), nullable=False)
|
| 42 |
read = db.Column(db.Boolean, default=False)
|
| 43 |
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
| 44 |
+
|
| 45 |
+
class PurityMemory(db.Model):
|
| 46 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 47 |
+
account_number = db.Column(db.String(20), nullable=False)
|
| 48 |
+
memory = db.Column(db.Text, nullable=False)
|
| 49 |
+
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|