Skip to content

Commit 73b0dcf

Browse files
committed
feat: Selcom sandbox escrow mock (hold/release/refund) with UI panel
1 parent b0387e2 commit 73b0dcf

3 files changed

Lines changed: 295 additions & 1 deletion

File tree

app.py

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,27 @@ class Message(db.Model):
340340
sent_at = db.Column(db.DateTime, default=datetime.utcnow)
341341
sender = db.relationship("User", foreign_keys=[sender_id], lazy=True)
342342

343+
# ── Escrow Model ─────────────────────────────────────────────────────────────
344+
class EscrowStatus(enum.Enum):
345+
held = "held"
346+
released = "released"
347+
refunded = "refunded"
348+
349+
class EscrowTransaction(db.Model):
350+
__tablename__ = "escrow_transactions"
351+
id = db.Column(db.Integer, primary_key=True)
352+
conversation_id = db.Column(db.Integer, db.ForeignKey("conversations.id"), nullable=False)
353+
buyer_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
354+
seller_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
355+
amount_tzs = db.Column(db.BigInteger, nullable=False)
356+
status = db.Column(db.Enum(EscrowStatus), default=EscrowStatus.held, nullable=False)
357+
reference = db.Column(db.String(32), unique=True, nullable=False)
358+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
359+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
360+
buyer = db.relationship("User", foreign_keys=[buyer_id], lazy=True)
361+
seller = db.relationship("User", foreign_keys=[seller_id], lazy=True)
362+
conversation = db.relationship("Conversation", foreign_keys=[conversation_id], lazy=True)
363+
343364
# ── Login Manager ─────────────────────────────────────────────────────────────
344365

345366

@@ -2385,6 +2406,93 @@ def notification_unread_count():
23852406
).count()
23862407
return jsonify({"unread": count})
23872408

2409+
@app.route("/api/escrow/hold", methods=["POST"])
2410+
@login_required
2411+
@require_active_account
2412+
def escrow_hold():
2413+
"""Mock: buyer anaanzisha malipo ya escrow."""
2414+
import secrets
2415+
data = request.get_json(silent=True) or {}
2416+
conv_id = data.get("conversation_id")
2417+
amount = data.get("amount_tzs")
2418+
if not conv_id or not amount:
2419+
return jsonify({"error": "Taarifa hazikamiliki"}), 400
2420+
conv = Conversation.query.get_or_404(conv_id)
2421+
if conv.buyer_id != current_user.id:
2422+
return jsonify({"error": "Huna ruhusa"}), 403
2423+
# Angalia kama escrow ipo tayari kwa conversation hii
2424+
existing = EscrowTransaction.query.filter_by(
2425+
conversation_id=conv_id, status=EscrowStatus.held
2426+
).first()
2427+
if existing:
2428+
return jsonify({"error": "Escrow ipo tayari", "reference": existing.reference}), 409
2429+
ref = "ESC-" + secrets.token_hex(6).upper()
2430+
tx = EscrowTransaction(
2431+
conversation_id=conv_id,
2432+
buyer_id=conv.buyer_id,
2433+
seller_id=conv.seller_id,
2434+
amount_tzs=int(amount),
2435+
status=EscrowStatus.held,
2436+
reference=ref,
2437+
)
2438+
db.session.add(tx)
2439+
db.session.commit()
2440+
return jsonify({"success": True, "reference": ref, "status": "held",
2441+
"message": f"TZS {int(amount):,} imehifadhiwa salama. Ref: {ref}"})
2442+
2443+
@app.route("/api/escrow/release", methods=["POST"])
2444+
@login_required
2445+
@require_active_account
2446+
def escrow_release():
2447+
"""Mock: buyer anakubali kutoa pesa kwa seller baada ya kupokea bidhaa."""
2448+
data = request.get_json(silent=True) or {}
2449+
ref = data.get("reference")
2450+
tx = EscrowTransaction.query.filter_by(reference=ref).first_or_404()
2451+
if tx.buyer_id != current_user.id:
2452+
return jsonify({"error": "Huna ruhusa"}), 403
2453+
if tx.status != EscrowStatus.held:
2454+
return jsonify({"error": f"Hali ya sasa: {tx.status.value}"}), 409
2455+
tx.status = EscrowStatus.released
2456+
db.session.commit()
2457+
return jsonify({"success": True, "status": "released",
2458+
"message": f"TZS {tx.amount_tzs:,} imetolewa kwa muuzaji. Asante!"})
2459+
2460+
@app.route("/api/escrow/refund", methods=["POST"])
2461+
@login_required
2462+
@require_active_account
2463+
def escrow_refund():
2464+
"""Mock: admin/seller anarudisha pesa kwa buyer."""
2465+
data = request.get_json(silent=True) or {}
2466+
ref = data.get("reference")
2467+
tx = EscrowTransaction.query.filter_by(reference=ref).first_or_404()
2468+
if current_user.role not in ("admin",) and tx.seller_id != current_user.id:
2469+
return jsonify({"error": "Huna ruhusa"}), 403
2470+
if tx.status != EscrowStatus.held:
2471+
return jsonify({"error": f"Hali ya sasa: {tx.status.value}"}), 409
2472+
tx.status = EscrowStatus.refunded
2473+
db.session.commit()
2474+
return jsonify({"success": True, "status": "refunded",
2475+
"message": f"TZS {tx.amount_tzs:,} imerudishwa kwa mnunuzi."})
2476+
2477+
@app.route("/api/escrow/status/<int:conv_id>", methods=["GET"])
2478+
@login_required
2479+
def escrow_status(conv_id):
2480+
"""Angalia hali ya escrow kwa conversation."""
2481+
conv = Conversation.query.get_or_404(conv_id)
2482+
if current_user.id not in (conv.buyer_id, conv.seller_id):
2483+
return jsonify({"error": "Huna ruhusa"}), 403
2484+
tx = EscrowTransaction.query.filter_by(conversation_id=conv_id).order_by(
2485+
EscrowTransaction.created_at.desc()
2486+
).first()
2487+
if not tx:
2488+
return jsonify({"escrow": None})
2489+
return jsonify({"escrow": {
2490+
"reference": tx.reference,
2491+
"amount_tzs": tx.amount_tzs,
2492+
"status": tx.status.value,
2493+
"created_at": tx.created_at.strftime("%d %b %Y, %H:%M"),
2494+
}})
2495+
23882496
@app.route("/api/conversations/mine", methods=["GET"])
23892497
@login_required
23902498
@require_active_account
@@ -2436,7 +2544,17 @@ def messages_thread(conv_id):
24362544
conv = Conversation.query.get_or_404(conv_id)
24372545
if current_user.id not in (conv.buyer_id, conv.seller_id):
24382546
return redirect(url_for("messages_inbox"))
2439-
return render_template("messages/thread.html", conv_id=conv_id)
2547+
is_buyer = current_user.id == conv.buyer_id
2548+
is_seller = current_user.id == conv.seller_id
2549+
listing_price = conv.listing.price_tzs if conv.listing else 0
2550+
listing_qty = conv.listing.quantity_kg if conv.listing else 0
2551+
return render_template("messages/thread.html",
2552+
conv_id=conv_id,
2553+
is_buyer=is_buyer,
2554+
is_seller=is_seller,
2555+
listing_price=listing_price,
2556+
listing_qty=listing_qty,
2557+
)
24402558

24412559
# ════════════════════════════════════════════════════════════════════════════
24422560
# B2B BUYER PORTAL
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""add escrow_transactions table
2+
Revision ID: a354710ade85
3+
Revises: 4fbd0f4eed9a
4+
Create Date: 2026-06-17 18:06:42.671208
5+
"""
6+
from alembic import op
7+
import sqlalchemy as sa
8+
9+
revision = 'a354710ade85'
10+
down_revision = '4fbd0f4eed9a'
11+
branch_labels = None
12+
depends_on = None
13+
14+
def upgrade():
15+
op.create_table('escrow_transactions',
16+
sa.Column('id', sa.Integer(), nullable=False),
17+
sa.Column('conversation_id', sa.Integer(), nullable=False),
18+
sa.Column('buyer_id', sa.Integer(), nullable=False),
19+
sa.Column('seller_id', sa.Integer(), nullable=False),
20+
sa.Column('amount_tzs', sa.BigInteger(), nullable=False),
21+
sa.Column('status', sa.Enum('held', 'released', 'refunded', name='escrowstatus'), nullable=False),
22+
sa.Column('reference', sa.String(length=32), nullable=False),
23+
sa.Column('created_at', sa.DateTime(), nullable=True),
24+
sa.Column('updated_at', sa.DateTime(), nullable=True),
25+
sa.ForeignKeyConstraint(['buyer_id'], ['users.id'], ),
26+
sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], ),
27+
sa.ForeignKeyConstraint(['seller_id'], ['users.id'], ),
28+
sa.PrimaryKeyConstraint('id'),
29+
sa.UniqueConstraint('reference')
30+
)
31+
32+
def downgrade():
33+
op.drop_table('escrow_transactions')

templates/messages/thread.html

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@
101101
<div class="thread-listing" id="thread-listing">...</div>
102102
</div>
103103
<div class="thread-actions">
104+
<button class="thread-action-btn" onclick="toggleEscrow()" title="Lipa Salama" id="escrow-btn">
105+
<i data-lucide="shield" style="width:17px;height:17px"></i>
106+
</button>
104107
<button class="thread-action-btn" onclick="toggleBgPicker()" title="Badilisha mandhari">
105108
<i data-lucide="palette" style="width:17px;height:17px"></i>
106109
</button>
@@ -145,6 +148,60 @@
145148
</div>
146149
</div>
147150

151+
152+
<!-- Escrow Panel -->
153+
<div id="escrow-panel" style="display:none;position:fixed;bottom:80px;left:50%;transform:translateX(-50%);z-index:200;width:min(94vw,420px)">
154+
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:var(--shadow-lg);padding:1.1rem 1.25rem">
155+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem">
156+
<div style="display:flex;align-items:center;gap:0.5rem;font-weight:700;font-size:0.9rem;color:var(--text-primary)">
157+
<i data-lucide="shield" style="width:16px;height:16px;color:#2D6A4F"></i>Lipa Salama (Escrow)
158+
</div>
159+
<button onclick="document.getElementById('escrow-panel').style.display='none'" style="background:none;border:none;cursor:pointer;color:var(--text-muted)">
160+
<i data-lucide="x" style="width:16px;height:16px"></i>
161+
</button>
162+
</div>
163+
<div id="escrow-status-area">
164+
<div style="font-size:0.78rem;color:var(--text-muted);margin-bottom:0.75rem">Malipo yanahifadhiwa salama hadi bidhaa ipokelewa.</div>
165+
<div style="display:flex;gap:0.5rem;align-items:center;margin-bottom:0.75rem">
166+
<label style="font-size:0.8rem;color:var(--text-secondary);white-space:nowrap">Kiasi (TZS)</label>
167+
<input id="escrow-amount" type="number" min="1000" step="1000"
168+
style="flex:1;padding:0.4rem 0.6rem;border:1px solid var(--border);border-radius:8px;font-size:0.85rem;background:var(--bg-input,var(--bg-card));color:var(--text-primary)"
169+
placeholder="e.g. 150000"/>
170+
</div>
171+
<div style="display:flex;gap:0.5rem">
172+
<button onclick="escrowHold()" id="escrow-hold-btn"
173+
style="flex:1;padding:0.5rem;background:#2D6A4F;color:#fff;border:none;border-radius:8px;font-size:0.82rem;font-weight:700;cursor:pointer">
174+
<i data-lucide="lock" style="width:13px;height:13px"></i> Hifadhi Malipo
175+
</button>
176+
</div>
177+
</div>
178+
<div id="escrow-held-area" style="display:none">
179+
<div style="background:rgba(45,106,79,0.08);border:1px solid rgba(45,106,79,0.25);border-radius:8px;padding:0.75rem;margin-bottom:0.75rem">
180+
<div style="font-size:0.78rem;color:var(--text-muted)">Rejeleo</div>
181+
<div id="escrow-ref" style="font-weight:700;font-size:0.88rem;color:#2D6A4F;font-family:monospace"></div>
182+
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:4px">Kiasi: TZS <span id="escrow-amt-display"></span></div>
183+
<div style="font-size:0.78rem;margin-top:4px">Hali: <span id="escrow-state-badge" style="font-weight:700"></span></div>
184+
</div>
185+
{% if is_buyer %}
186+
<button onclick="escrowRelease()" id="escrow-release-btn"
187+
style="width:100%;padding:0.5rem;background:#1B4332;color:#fff;border:none;border-radius:8px;font-size:0.82rem;font-weight:700;cursor:pointer;margin-bottom:0.4rem">
188+
<i data-lucide="check-circle" style="width:13px;height:13px"></i> Nimepokea Bidhaa — Toa Malipo
189+
</button>
190+
{% endif %}
191+
{% if is_seller %}
192+
<button onclick="escrowRefund()" id="escrow-refund-btn"
193+
style="width:100%;padding:0.5rem;background:var(--danger,#c0392b);color:#fff;border:none;border-radius:8px;font-size:0.82rem;font-weight:700;cursor:pointer">
194+
<i data-lucide="undo-2" style="width:13px;height:13px"></i> Rudisha Malipo kwa Mnunuzi
195+
</button>
196+
{% endif %}
197+
</div>
198+
<div id="escrow-done-area" style="display:none;text-align:center;padding:0.5rem 0">
199+
<i data-lucide="check-circle-2" style="width:28px;height:28px;color:#2D6A4F"></i>
200+
<div id="escrow-done-msg" style="font-size:0.85rem;color:var(--text-secondary);margin-top:0.4rem"></div>
201+
</div>
202+
</div>
203+
</div>
204+
148205
<!-- Context Menu -->
149206
<div class="ctx-menu" id="ctx-menu">
150207
<button class="ctx-item" id="ctx-reply" onclick="ctxReply()">
@@ -160,6 +217,92 @@
160217

161218
<script>
162219
const CONV_ID = {{ conv_id }};
220+
221+
// ── Escrow ────────────────────────────────────────────────────────────────
222+
const IS_BUYER = {{ 'true' if is_buyer else 'false' }};
223+
const IS_SELLER = {{ 'true' if is_seller else 'false' }};
224+
const LISTING_PRICE = {{ listing_price }};
225+
const LISTING_QTY = {{ listing_qty }};
226+
let _escrowRef = null;
227+
228+
function toggleEscrow(){
229+
const p = document.getElementById('escrow-panel');
230+
if(p.style.display === 'none'){ p.style.display='block'; loadEscrowStatus(); }
231+
else p.style.display='none';
232+
lucide.createIcons();
233+
}
234+
235+
async function loadEscrowStatus(){
236+
try{
237+
const r = await fetch(`/api/escrow/status/${CONV_ID}`);
238+
const d = await r.json();
239+
if(d.escrow){
240+
_escrowRef = d.escrow.reference;
241+
showEscrowHeld(d.escrow);
242+
} else {
243+
// Pendekeza kiasi
244+
const suggested = LISTING_PRICE * LISTING_QTY;
245+
if(suggested > 0) document.getElementById('escrow-amount').value = suggested;
246+
}
247+
}catch(e){}
248+
}
249+
250+
function showEscrowHeld(e){
251+
document.getElementById('escrow-status-area').style.display='none';
252+
document.getElementById('escrow-held-area').style.display='block';
253+
document.getElementById('escrow-ref').textContent = e.reference;
254+
document.getElementById('escrow-amt-display').textContent = Number(e.amount_tzs).toLocaleString();
255+
const badge = document.getElementById('escrow-state-badge');
256+
const colors = {held:'#b45309',released:'#2D6A4F',refunded:'#c0392b'};
257+
const labels = {held:'Imehifadhiwa',released:'Imetolewa',refunded:'Imerudishwa'};
258+
badge.textContent = labels[e.status] || e.status;
259+
badge.style.color = colors[e.status] || '#888';
260+
if(e.status !== 'held'){
261+
document.getElementById('escrow-held-area').querySelectorAll('button').forEach(b=>b.style.display='none');
262+
}
263+
}
264+
265+
async function escrowHold(){
266+
const amt = parseInt(document.getElementById('escrow-amount').value);
267+
if(!amt || amt < 1000){ alert('Ingiza kiasi sahihi (angalau TZS 1,000)'); return; }
268+
const btn = document.getElementById('escrow-hold-btn');
269+
btn.disabled = true; btn.textContent = 'Inahifadhi...';
270+
try{
271+
const r = await fetch('/api/escrow/hold',{method:'POST',headers:{'Content-Type':'application/json'},
272+
body:JSON.stringify({conversation_id:CONV_ID, amount_tzs:amt})});
273+
const d = await r.json();
274+
if(d.success){ _escrowRef=d.reference; showEscrowHeld({reference:d.reference,amount_tzs:amt,status:'held'}); }
275+
else { alert(d.error||'Imeshindwa'); btn.disabled=false; btn.textContent='Hifadhi Malipo'; }
276+
}catch(e){ btn.disabled=false; btn.textContent='Hifadhi Malipo'; }
277+
}
278+
279+
async function escrowRelease(){
280+
if(!confirm('Una uhakika umepokea bidhaa na unataka kutoa malipo kwa muuzaji?')) return;
281+
const r = await fetch('/api/escrow/release',{method:'POST',headers:{'Content-Type':'application/json'},
282+
body:JSON.stringify({reference:_escrowRef})});
283+
const d = await r.json();
284+
if(d.success){ showDone(d.message); }
285+
else alert(d.error||'Imeshindwa');
286+
}
287+
288+
async function escrowRefund(){
289+
if(!confirm('Una uhakika unataka kurudisha malipo kwa mnunuzi?')) return;
290+
const r = await fetch('/api/escrow/refund',{method:'POST',headers:{'Content-Type':'application/json'},
291+
body:JSON.stringify({reference:_escrowRef})});
292+
const d = await r.json();
293+
if(d.success){ showDone(d.message); }
294+
else alert(d.error||'Imeshindwa');
295+
}
296+
297+
function showDone(msg){
298+
document.getElementById('escrow-held-area').style.display='none';
299+
document.getElementById('escrow-status-area').style.display='none';
300+
const a = document.getElementById('escrow-done-area');
301+
a.style.display='block';
302+
document.getElementById('escrow-done-msg').textContent=msg;
303+
lucide.createIcons();
304+
}
305+
163306
let convStatus = 'active';
164307
let _msgs = [];
165308
let _ctxMsg = null;

0 commit comments

Comments
 (0)