RentNow / utility.py
rairo's picture
Implement renter search, previews and simulated payment flows
be21bae
Raw History Blame Contribute Delete
42.6 kB
"""
utility.py β€” RentNow marketplace core.
Covers: profiles, listings, search/match, spoof payments, rental pass,
viewings, wallet ledger, payouts, leases, Gemini helpers, PDFs.
Money is stored as integer cents. Payments are SPOOFED for MVP.
Wire real EcoCash later behind create_payment()/confirm_payment().
"""
import os
import re
import json
import time
import uuid
import logging
import io
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# ── Gemini (optional) ────────────────────────────────────────────────
try:
from google import genai
from google.genai import types as genai_types
except ImportError:
genai = None
genai_types = None
GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY", "")
GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.1-flash-lite")
_gemini = None
if genai and GOOGLE_API_KEY:
try:
_gemini = genai.Client(api_key=GOOGLE_API_KEY)
except Exception as e:
logger.error(f"Gemini init failed: {e}")
# ── Firebase (optional, tolerant) ────────────────────────────────────
db: Optional[Any] = None
try:
import firebase_admin
from firebase_admin import credentials, firestore, storage as fb_storage
def _init_db():
global db
try:
if firebase_admin._apps:
db = firestore.client(database_id=os.environ.get("FIRESTORE_DB_NAME", "(default)"))
return db
env_json = os.environ.get("FIREBASE", "")
if not env_json:
logger.warning("FIREBASE not set β€” running without persistence (seed data only).")
return None
cred = credentials.Certificate(json.loads(env_json))
opts = {}
bucket = os.environ.get("FIREBASE_STORAGE_BUCKET", "")
if bucket:
opts["storageBucket"] = bucket
firebase_admin.initialize_app(cred, opts)
db = firestore.client(database_id=os.environ.get("FIRESTORE_DB_NAME", "(default)"))
logger.info("Firebase ready.")
return db
except Exception as e:
logger.error(f"Firebase init failed: {e}")
return None
_init_db()
except ImportError:
logger.warning("firebase-admin not installed β€” persistence disabled.")
# ── Constants ────────────────────────────────────────────────────────
APP_NAME = "RentNow"
RENTAL_PASS_CENTS = 1000 # $10
RENTAL_PASS_CREDITS = 3
RENTAL_PASS_DAYS = 14
VIEWING_FEE_CENTS = 500 # $5
LANDLORD_SHARE_CENTS = 200 # $2
PLATFORM_SHARE_CENTS = 300 # $3
PROPERTY_TYPES = ["single room", "cottage", "1 bedroom", "2 bedroom", "house"]
AMENITIES = ["solar", "borehole", "tiled floors", "fitted kitchen", "parking",
"security", "water", "electricity", "furnished", "wifi"]
CITIES = ["Harare", "Bulawayo", "Mutare", "Gweru", "Victoria Falls"]
def _pics(seed: str, n: int = 3) -> List[str]:
return [f"https://picsum.photos/seed/{seed}-{i}/600/400" for i in range(1, n + 1)]
SEED_LISTINGS: List[Dict] = [
{"id": "seed-01", "provider_phone": "+263781234001", "provider_name": "T. Moyo",
"type": "2 bedroom", "title": "2 Bedroom House, Westgate",
"rent_cents": 38000, "deposit_cents": 38000,
"city": "Harare", "suburb": "Westgate", "available_from": "immediately",
"occupancy_limit": 4, "description": "Modern 2-bed with fitted kitchen, walled yard, borehole. Quiet street near shops and schools.",
"amenities": ["solar", "parking", "borehole", "walled", "tiled", "fitted kitchen"],
"media": _pics("rent1"), "status": "active", "verification": "verified", "seed": True},
{"id": "seed-02", "provider_phone": "+263781234002", "provider_name": "R. Chikafu",
"type": "2 bedroom", "title": "2 Bedroom Cottage, Westgate",
"rent_cents": 35000, "deposit_cents": 35000,
"city": "Harare", "suburb": "Westgate", "available_from": "immediately",
"occupancy_limit": 3, "description": "Neat cottage, own entrance and meter. Tiled throughout, secure yard.",
"amenities": ["solar", "parking", "tiled", "secure"],
"media": _pics("rent2"), "status": "active", "verification": "verified", "seed": True},
{"id": "seed-03", "provider_phone": "+263781234003", "provider_name": "S. Dube",
"type": "1 bedroom", "title": "1 Bedroom Flat, Avondale",
"rent_cents": 28000, "deposit_cents": 28000,
"city": "Harare", "suburb": "Avondale", "available_from": "immediately",
"occupancy_limit": 2, "description": "Sunny 1-bed in secure complex. Fitted kitchen, parking, backup water.",
"amenities": ["solar", "borehole", "parking", "security", "fitted kitchen"],
"media": _pics("rent3"), "status": "active", "verification": "verified", "seed": True},
{"id": "seed-04", "provider_phone": "+263781234004", "provider_name": "K. Mutasa",
"type": "cottage", "title": "Cottage, Glen View",
"rent_cents": 15000, "deposit_cents": 15000,
"city": "Harare", "suburb": "Glen View", "available_from": "immediately",
"occupancy_limit": 3, "description": "Quiet yard cottage near transport. Own meter, water available.",
"amenities": ["parking", "water", "electricity", "security"],
"media": _pics("rent4"), "status": "active", "verification": "verified", "seed": True},
{"id": "seed-05", "provider_phone": "+263781234005", "provider_name": "B. Nyathi",
"type": "single room", "title": "Single Room, Mbare",
"rent_cents": 6000, "deposit_cents": 6000,
"city": "Harare", "suburb": "Mbare", "available_from": "immediately",
"occupancy_limit": 2, "description": "Budget room minutes from bus terminus. Shared ablutions, water daily.",
"amenities": ["water", "electricity"],
"media": _pics("rent5"), "status": "active", "verification": "verified", "seed": True},
{"id": "seed-06", "provider_phone": "+263781234006", "provider_name": "A. Sibanda",
"type": "2 bedroom", "title": "2 Bedroom House, Kuwadzana",
"rent_cents": 25000, "deposit_cents": 25000,
"city": "Harare", "suburb": "Kuwadzana", "available_from": "flexible",
"occupancy_limit": 4, "description": "Family house with borehole and parking. Schools nearby.",
"amenities": ["borehole", "parking", "security", "fitted kitchen"],
"media": _pics("rent6"), "status": "active", "verification": "verified", "seed": True},
{"id": "seed-07", "provider_phone": "+263781234007", "provider_name": "M. Hove",
"type": "cottage", "title": "Cottage, Hatcliffe",
"rent_cents": 18000, "deposit_cents": 18000,
"city": "Harare", "suburb": "Hatcliffe", "available_from": "immediately",
"occupancy_limit": 3, "description": "Spacious cottage, walled stand, pet friendly. Solar lighting.",
"amenities": ["solar", "walled", "pet friendly", "parking"],
"media": _pics("rent7"), "status": "active", "verification": "verified", "seed": True},
{"id": "seed-08", "provider_phone": "+263781234008", "provider_name": "P. Zulu",
"type": "single room", "title": "Single Room, Epworth",
"rent_cents": 7000, "deposit_cents": 7000,
"city": "Harare", "suburb": "Epworth", "available_from": "immediately",
"occupancy_limit": 2, "description": "Clean room, outside toilet, ZESA available. Deposit negotiable.",
"amenities": ["water", "electricity"],
"media": _pics("rent8"), "status": "active", "verification": "verified", "seed": True},
]
_mem: Dict[str, Dict] = {} # in-memory fallback when Firestore is absent
_sess_mem: Dict[str, Dict] = {}
SESS_TTL = 1800
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def sess_get(phone: str) -> Optional[Dict]:
"""Server-side session. Firestore-backed so restarts never drop context."""
s = _sess_mem.get(phone)
if s and time.time() - s.get("ts", 0) < SESS_TTL:
return s
if db:
try:
doc = db.collection("rentnow_sessions").document(phone).get()
if doc.exists:
s = doc.to_dict()
if time.time() - s.get("ts", 0) < SESS_TTL:
_sess_mem[phone] = s
return s
except Exception as e:
logger.error(f"sess_get: {e}")
return None
def sess_set(phone: str, state: Dict) -> None:
state = {**state, "ts": time.time(), "updated_at": _now()}
_sess_mem[phone] = state
if db:
try:
db.collection("rentnow_sessions").document(phone).set(state)
except Exception as e:
logger.error(f"sess_set: {e}")
def sess_clear(phone: str) -> None:
_sess_mem.pop(phone, None)
if db:
try:
db.collection("rentnow_sessions").document(phone).delete()
except Exception:
pass
def cents_fmt(c: int) -> str:
return f"${c/100:.0f}" if c % 100 == 0 else f"${c/100:.2f}"
# ── Profiles ─────────────────────────────────────────────────────────
def get_profile(phone: str) -> Dict:
if db:
try:
doc = db.collection("rentnow_profiles").document(phone).get()
if doc.exists:
return doc.to_dict()
except Exception as e:
logger.error(f"get_profile: {e}")
return _mem.get(f"profile:{phone}", {"phone": phone, "roles": []})
def save_profile(phone: str, patch: Dict) -> Dict:
cur = get_profile(phone)
cur.update(patch)
cur["phone"] = phone
if db:
try:
db.collection("rentnow_profiles").document(phone).set(cur, merge=True)
except Exception as e:
logger.error(f"save_profile: {e}")
_mem[f"profile:{phone}"] = cur
return cur
def get_chat_history(phone: str, limit: int = 6) -> str:
if not db:
return ""
try:
docs = (db.collection("rentnow_profiles").document(phone).collection("chat_logs")
.order_by("ts", direction="DESCENDING").limit(limit).stream())
msgs = [f"User: {d.to_dict().get('message','')}\nBot: {d.to_dict().get('response','')}" for d in docs]
return "\n".join(reversed(msgs))
except Exception:
return ""
def save_chat_log(phone: str, message: str, response: str, intent: Dict) -> None:
if not db:
return
try:
db.collection("rentnow_profiles").document(phone).collection("chat_logs").add(
{"message": message, "response": response, "intent": intent, "ts": _now()})
except Exception as e:
logger.error(f"chat log: {e}")
# ── Listings ─────────────────────────────────────────────────────────
def get_listings(active_only: bool = True) -> List[Dict]:
out: List[Dict] = []
if db:
try:
q = db.collection("rentnow_listings")
if active_only:
q = q.where("status", "==", "active")
for d in q.stream():
item = d.to_dict()
item["id"] = d.id
out.append(item)
except Exception as e:
logger.error(f"get_listings: {e}")
if not out:
# seed fallback (demo without Firestore, or empty DB)
mem_extra = [v for k, v in _mem.items() if k.startswith("listing:")]
out = list(SEED_LISTINGS) + [v for v in mem_extra]
if active_only:
out = [x for x in out if x.get("status") == "active"]
return out
def get_listing(listing_id: str) -> Optional[Dict]:
for x in get_listings(active_only=False):
if x.get("id") == listing_id:
return x
if db:
try:
doc = db.collection("rentnow_listings").document(listing_id).get()
if doc.exists:
item = doc.to_dict()
item["id"] = doc.id
return item
except Exception:
pass
return None
def create_listing(data: Dict) -> Dict:
lid = f"L-{uuid.uuid4().hex[:6].upper()}"
data = {**data, "id": lid, "status": "pending_review",
"verification": "pending", "created_at": _now(), "updated_at": _now()}
if db:
try:
db.collection("rentnow_listings").document(lid).set(data)
except Exception as e:
logger.error(f"create_listing: {e}")
_mem[f"listing:{lid}"] = data
return data
def seed_upsert() -> int:
"""Write the full seed set to Firestore (upsert). Returns count written."""
n = 0
for s in SEED_LISTINGS:
item = {**s, "updated_at": _now()}
item.setdefault("created_at", _now())
if db:
try:
db.collection("rentnow_listings").document(s["id"]).set(item)
n += 1
except Exception as e:
logger.error(f"seed_upsert: {e}")
_mem[f"listing:{s['id']}"] = item
n += 0 if db else 1
return n
def delete_listing(listing_id: str) -> bool:
"""Remove a listing (seed cleanup, admin). Returns True if one existed."""
gone = False
if db:
try:
ref = db.collection("rentnow_listings").document(listing_id)
if ref.get().exists:
ref.delete()
gone = True
except Exception as e:
logger.error(f"delete_listing: {e}")
if _mem.pop(f"listing:{listing_id}", None) is not None:
gone = True
if any(s.get("id") == listing_id for s in SEED_LISTINGS):
gone = True # seed id recognised even when served from code fallback
return gone
def admin_create_listing(data: Dict, reviewer: str = "admin") -> Dict:
"""Admin lists a home on behalf of an owner. Goes live immediately."""
lid = f"L-{uuid.uuid4().hex[:6].upper()}"
try:
rent_c = int(float(data.get("rent", 0)) * 100)
except Exception:
rent_c = 0
try:
dep_c = int(float(data.get("deposit", 0)) * 100)
except Exception:
dep_c = 0
item = {
"id": lid, "provider_phone": data.get("provider_phone", ""),
"provider_name": data.get("provider_name", ""),
"type": (data.get("type") or "").lower(), "title": data.get("title", ""),
"rent_cents": rent_c, "deposit_cents": dep_c,
"city": data.get("city", "Harare"), "suburb": data.get("suburb", ""),
"available_from": data.get("available_from", "immediately"),
"occupancy_limit": data.get("occupancy_limit", ""),
"description": data.get("description", ""),
"amenities": data.get("amenities", []) if isinstance(data.get("amenities"), list)
else [a.strip() for a in (data.get("amenities") or "").split(",") if a.strip()],
"media": data.get("media", []) if isinstance(data.get("media"), list)
else [u.strip() for u in (data.get("media") or "").split(",") if u.strip()],
"video_url": data.get("video_url", ""),
"rental_requirements": data.get("rental_requirements", ""),
"status": "active", "verification": "verified",
"created_by": reviewer, "created_at": _now(), "updated_at": _now(),
}
if db:
try:
db.collection("rentnow_listings").document(lid).set(item)
db.collection("rentnow_audit").add(
{"actor": reviewer, "action": "listing.admin_created",
"entity": "listing", "entity_id": lid, "ts": _now()})
except Exception as e:
logger.error(f"admin_create_listing: {e}")
_mem[f"listing:{lid}"] = item
return item
def moderate_listing(listing_id: str, approve: bool, reviewer: str = "admin") -> Optional[Dict]:
item = get_listing(listing_id)
if not item:
return None
item["status"] = "active" if approve else "rejected"
item["verification"] = "verified" if approve else "rejected"
item["updated_at"] = _now()
if db:
try:
db.collection("rentnow_listings").document(listing_id).set(item, merge=True)
db.collection("rentnow_verifications").add(
{"target": listing_id, "type": "listing", "status": item["status"],
"reviewer": reviewer, "ts": _now()})
except Exception as e:
logger.error(f"moderate: {e}")
_mem[f"listing:{listing_id}"] = item
return item
# ── Search / match ───────────────────────────────────────────────────
def match_score(item: Dict, crit: Dict) -> Tuple[int, List[str]]:
score, reasons = 0, []
want_type = (crit.get("type") or "").lower()
if want_type and want_type in (item.get("type") or "").lower():
score += 30
reasons.append("correct type")
suburb = (crit.get("suburb") or "").lower().replace("mt pleasant", "mount pleasant")
if suburb and suburb in (item.get("suburb") or "").lower():
score += 25
reasons.append("correct suburb")
try:
max_b = float(crit.get("max_budget") or 0)
except Exception:
max_b = 0
rent = (item.get("rent_cents") or 0) / 100
if max_b and rent <= max_b:
score += 20
reasons.append("within budget")
wants = [a.lower() for a in (crit.get("amenities") or [])]
haves = [a.lower() for a in (item.get("amenities") or [])]
hit = [a for a in wants if any(a in h for h in haves)]
if wants:
score += min(20, int(20 * len(hit) / max(1, len(wants))))
reasons += hit
if (item.get("available_from") or "") == "immediately":
score += 5
reasons.append("available immediately")
return min(100, score), reasons
def search_listings(crit: Dict, limit: int = 5) -> List[Dict]:
items = get_listings(active_only=True)
want_type = (crit.get("type") or "").lower()
suburb = (crit.get("suburb") or "").lower().replace("mt pleasant", "mount pleasant")
try:
max_b = float(crit.get("max_budget") or 0)
except Exception:
max_b = 0
min_b = float(crit.get("min_budget") or 0)
ranked = []
for it in items:
if want_type and want_type not in (it.get("type") or "").lower():
continue
if suburb and suburb not in (it.get("suburb") or "").lower().replace("mt pleasant", "mount pleasant"):
continue
if max_b and (it.get("rent_cents") or 0) / 100 > max_b:
continue
if min_b and (it.get("rent_cents") or 0) / 100 < min_b:
continue
wants = [a.lower() for a in crit.get("amenities", [])]
haves = [a.lower() for a in it.get("amenities", [])]
if any(not any(a in h for h in haves) for a in wants):
continue
s, reasons = match_score(it, crit)
ranked.append({**it, "_score": s, "_reasons": reasons})
ranked.sort(key=lambda x: (-x["_score"], x.get("rent_cents", 0)))
return ranked[:limit]
def format_preview(item: Dict, idx: int = 0, score: Optional[int] = None,
reasons: Optional[List[str]] = None) -> str:
tag = f"*{idx}. {item.get('title')}* [{item.get('id')}]" if idx else f"*{item.get('title')}* [{item.get('id')}]"
lines = [tag,
f" Type: {item.get('type')} | {item.get('suburb')}, {item.get('city')}",
f" Rent: *{cents_fmt(item.get('rent_cents', 0))}/mo* | Deposit: {cents_fmt(item.get('deposit_cents', 0))}",
f" {', '.join(item.get('amenities', [])[:5])}"]
if score is not None:
lines.append(f" _{score}% match β€” {', '.join((reasons or [])[:4])}_")
lines.append(" Property: " + ("Verified" if item.get("verification") == "verified" else "Verification pending"))
provider = get_profile(item.get("provider_phone", ""))
lines.append(" Provider: " + ("Verified" if (provider.get("landlord") or {}).get("verification") == "verified" else "Verification pending"))
lines.append(" _Free preview. Full details available with a $10 Rental Pass._")
return "\n".join(lines)
def format_full(item: Dict) -> str:
lines = [f"*{item.get('title')}* [{item.get('id')}]",
f"Type: {item.get('type')} | {item.get('suburb')}, {item.get('city')}",
f"Rent: *{cents_fmt(item.get('rent_cents', 0))}/mo* | Deposit: {cents_fmt(item.get('deposit_cents', 0))}",
f"Available: {item.get('available_from')} | Sleeps: {item.get('occupancy_limit')}",
f"{item.get('description')}",
f"Amenities: {', '.join(item.get('amenities', []))}",
f"Verified: {'yes' if item.get('verification') == 'verified' else 'pending'}",
f"Photos: {len(item.get('media', []))} unlocked",
f"Rental requirements: {item.get('rental_requirements') or item.get('requirements') or 'Ask RentNow for the provider requirements.'}",
f"Provider: {item.get('provider_name') or 'RentNow coordinates with the provider'}",
"Provider contact details are shared after the $5 viewing payment."]
return "\n".join(lines)
# ── Spoof payments ───────────────────────────────────────────────────
def create_payment(phone: str, kind: str, amount_cents: int,
meta: Optional[Dict] = None) -> Dict:
"""Create a spoof EcoCash payment intent. Wire real provider later."""
pid = f"PAY-{uuid.uuid4().hex[:8].upper()}"
ref = f"RN-{uuid.uuid4().hex[:6].upper()}"
pay = {"id": pid, "user_phone": phone, "kind": kind, "amount_cents": amount_cents,
"provider": "ecocash_spoof", "provider_ref": ref, "status": "pending",
"spoof": True, "meta": meta or {}, "created_at": _now()}
if db:
try:
db.collection("rentnow_payments").document(pid).set(pay)
except Exception as e:
logger.error(f"create_payment: {e}")
_mem[f"pay:{pid}"] = pay
return pay
def confirm_payment_spoof(payment_id: str, ref_input: str = "") -> Optional[Dict]:
"""MVP spoof: user taps 'I have paid' and we confirm. Replace with webhook."""
pay = _mem.get(f"pay:{payment_id}")
if not pay and db:
try:
doc = db.collection("rentnow_payments").document(payment_id).get()
pay = doc.to_dict() if doc.exists else None
except Exception:
pay = None
if not pay or pay.get("status") == "confirmed":
return pay
pay["status"] = "confirmed"
pay["confirmed_at"] = _now()
pay["ref_input"] = ref_input
if db:
try:
db.collection("rentnow_payments").document(payment_id).set(pay, merge=True)
db.collection("rentnow_audit").add(
{"actor": pay["user_phone"], "action": "payment.confirmed",
"entity": "payment", "entity_id": payment_id, "ts": _now()})
except Exception as e:
logger.error(f"confirm_payment: {e}")
_mem[f"pay:{payment_id}"] = pay
return pay
def ecocash_instructions(pay: Dict) -> str:
return (
f"EcoCash (spoofed MVP):\n"
f"Amount: *{cents_fmt(pay['amount_cents'])}*\n"
f"Reference: *{pay['provider_ref']}*\n"
"Demo only. No money is charged. Reply 1 to simulate a successful EcoCash payment."
)
# ── Rental Pass ──────────────────────────────────────────────────────
def active_pass(phone: str) -> Optional[Dict]:
best = None
cands: List[Dict] = []
if db:
try:
for d in db.collection("rentnow_passes").where("tenant_phone", "==", phone).stream():
cands.append({**d.to_dict(), "id": d.id})
except Exception as e:
logger.error(f"active_pass: {e}")
cands += [v for k, v in _mem.items() if k.startswith("pass:") and v.get("tenant_phone") == phone]
now = datetime.now(timezone.utc)
for p in cands:
try:
exp = datetime.fromisoformat(p["expires_at"])
if p.get("status") == "active" and exp > now and p.get("credits_used", 0) < p.get("credits_total", 3):
if not best or p["expires_at"] > best["expires_at"]:
best = p
except Exception:
continue
return best
def issue_pass(phone: str, payment_id: str) -> Dict:
pid = f"PASS-{uuid.uuid4().hex[:6].upper()}"
exp = (datetime.now(timezone.utc) + timedelta(days=RENTAL_PASS_DAYS)).isoformat()
p = {"id": pid, "tenant_phone": phone, "amount_cents": RENTAL_PASS_CENTS,
"credits_total": RENTAL_PASS_CREDITS, "credits_used": 0, "status": "active",
"payment_id": payment_id, "created_at": _now(), "expires_at": exp}
if db:
try:
db.collection("rentnow_passes").document(pid).set(p)
except Exception as e:
logger.error(f"issue_pass: {e}")
_mem[f"pass:{pid}"] = p
return p
def is_unlocked(phone: str, listing_id: str) -> bool:
if db:
try:
q = (db.collection("rentnow_unlocks")
.where("tenant_phone", "==", phone).where("listing_id", "==", listing_id).limit(1).stream())
if any(True for _ in q):
return True
except Exception:
pass
return any(v.get("tenant_phone") == phone and v.get("listing_id") == listing_id
for k, v in _mem.items() if k.startswith("unlock:"))
def consume_unlock(phone: str, listing_id: str) -> Tuple[bool, str]:
if is_unlocked(phone, listing_id):
return True, "already unlocked"
p = active_pass(phone)
if not p:
return False, "no active pass"
p["credits_used"] = p.get("credits_used", 0) + 1
if p["credits_used"] >= p.get("credits_total", 3):
p["status"] = "expired"
uid = f"UNL-{uuid.uuid4().hex[:6].upper()}"
rec = {"id": uid, "pass_id": p["id"], "listing_id": listing_id,
"tenant_phone": phone, "unlocked_at": _now()}
if db:
try:
db.collection("rentnow_passes").document(p["id"]).set(p, merge=True)
db.collection("rentnow_unlocks").document(uid).set(rec)
except Exception as e:
logger.error(f"consume_unlock: {e}")
_mem[f"pass:{p['id']}"] = p
_mem[f"unlock:{uid}"] = rec
left = p.get("credits_total", 3) - p.get("credits_used", 0)
return True, f"{left} credit(s) left"
# ── Viewings ─────────────────────────────────────────────────────────
def create_viewing(tenant: str, listing_id: str, payment_id: str) -> Dict:
vid = f"V-{uuid.uuid4().hex[:6].upper()}"
lst = get_listing(listing_id) or {}
v = {"id": vid, "listing_id": listing_id, "tenant_phone": tenant,
"landlord_phone": lst.get("provider_phone", ""),
"fee_payment_id": payment_id, "status": "paid_appointment_pending",
"appointment_at": "", "note": "", "created_at": _now()}
if db:
try:
db.collection("rentnow_viewings").document(vid).set(v)
except Exception as e:
logger.error(f"create_viewing: {e}")
_mem[f"view:{vid}"] = v
return v
def get_viewing(vid: str) -> Optional[Dict]:
if f"view:{vid}" in _mem:
return _mem[f"view:{vid}"]
if db:
try:
doc = db.collection("rentnow_viewings").document(vid).get()
if doc.exists:
return {**doc.to_dict(), "id": doc.id}
except Exception:
pass
return None
def set_appointment(vid: str, when: str, note: str = "") -> Optional[Dict]:
v = get_viewing(vid)
if not v:
return None
v.update(status="appointment_set", appointment_at=when, note=note)
if db:
try:
db.collection("rentnow_viewings").document(vid).set(v, merge=True)
except Exception as e:
logger.error(f"set_appointment: {e}")
_mem[f"view:{vid}"] = v
return v
def complete_viewing(vid: str, outcome: str) -> Optional[Dict]:
v = get_viewing(vid)
if not v:
return None
v["status"] = outcome # completed | cancelled | no_show | disputed
if db:
try:
db.collection("rentnow_viewings").document(vid).set(v, merge=True)
except Exception as e:
logger.error(f"complete_viewing: {e}")
_mem[f"view:{vid}"] = v
if outcome == "completed":
credit_landlord(v["landlord_phone"], LANDLORD_SHARE_CENTS, vid)
return v
# ── Wallet (ledger-based) ────────────────────────────────────────────
def credit_landlord(phone: str, cents: int, viewing_id: str) -> Dict:
eid = f"LED-{uuid.uuid4().hex[:6].upper()}"
e = {"id": eid, "landlord_phone": phone, "kind": "earning", "amount_cents": cents,
"viewing_id": viewing_id, "status": "earned", "ref": viewing_id, "ts": _now()}
if db:
try:
db.collection("rentnow_wallet_ledger").document(eid).set(e)
except Exception as ex:
logger.error(f"credit_landlord: {ex}")
_mem[f"led:{eid}"] = e
return e
def wallet_balance(phone: str) -> int:
total = 0
rows: List[Dict] = []
if db:
try:
for d in db.collection("rentnow_wallet_ledger").where("landlord_phone", "==", phone).stream():
rows.append(d.to_dict())
except Exception:
pass
rows += [v for k, v in _mem.items() if k.startswith("led:") and v.get("landlord_phone") == phone]
for r in rows:
if r.get("kind") == "earning":
total += r.get("amount_cents", 0)
elif r.get("kind") in ("payout", "adjustment"):
total -= abs(r.get("amount_cents", 0))
return total
def payout_run(period: str) -> Dict:
bid = f"BATCH-{uuid.uuid4().hex[:6].upper()}"
batch = {"id": bid, "period": period, "status": "processed",
"approved_at": _now(), "processed_at": _now(), "payouts": []}
# collect landlords with positive balance (best effort from ledger)
phones = set()
if db:
try:
for d in db.collection("rentnow_wallet_ledger").stream():
phones.add(d.to_dict().get("landlord_phone", ""))
except Exception:
pass
phones |= {v.get("landlord_phone", "") for k, v in _mem.items() if k.startswith("led:")}
for ph in [p for p in phones if p]:
bal = wallet_balance(ph)
if bal > 0:
pid = f"PO-{uuid.uuid4().hex[:6].upper()}"
batch["payouts"].append({"id": pid, "provider_phone": ph, "amount_cents": bal})
adj = {"id": f"LED-{uuid.uuid4().hex[:6].upper()}", "landlord_phone": ph,
"kind": "payout", "amount_cents": bal, "payout_id": pid,
"status": "paid", "ref": bid, "ts": _now()}
if db:
try:
db.collection("rentnow_wallet_ledger").document(adj["id"]).set(adj)
except Exception:
pass
_mem[f"led:{adj['id']}"] = adj
if db:
try:
db.collection("rentnow_payout_batches").document(bid).set(batch)
except Exception:
pass
return batch
# ── Leases ───────────────────────────────────────────────────────────
def create_lease(listing_id: str, tenant: str, term_months: int = 12) -> Dict:
lst = get_listing(listing_id) or {}
lid = f"LEASE-{uuid.uuid4().hex[:6].upper()}"
lease = {"id": lid, "listing_id": listing_id, "tenant_phone": tenant,
"landlord_phone": lst.get("provider_phone", ""),
"rent_cents": lst.get("rent_cents", 0), "deposit_cents": lst.get("deposit_cents", 0),
"term_months": term_months, "status": "draft", "created_at": _now()}
if db:
try:
db.collection("rentnow_leases").document(lid).set(lease)
except Exception as e:
logger.error(f"create_lease: {e}")
_mem[f"lease:{lid}"] = lease
return lease
def lease_status(lid: str, status: str) -> Optional[Dict]:
lease = _mem.get(f"lease:{lid}")
if not lease and db:
try:
doc = db.collection("rentnow_leases").document(lid).get()
lease = {**doc.to_dict(), "id": doc.id} if doc.exists else None
except Exception:
lease = None
if not lease:
return None
lease["status"] = status
if db:
try:
db.collection("rentnow_leases").document(lid).set(lease, merge=True)
except Exception:
pass
_mem[f"lease:{lid}"] = lease
return lease
def generate_lease_pdf(lease: Dict, listing: Dict) -> Optional[str]:
try:
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
outdir = os.path.join(os.getcwd(), "leases")
os.makedirs(outdir, exist_ok=True)
path = os.path.join(outdir, f"{lease['id']}.pdf")
doc = SimpleDocTemplate(path, pagesize=A4, leftMargin=1.5 * cm, rightMargin=1.5 * cm)
styles = getSampleStyleSheet()
story = [Paragraph("RENTNOW β€” Residential Lease (DRAFT)", styles["Title"]),
Spacer(1, 0.3 * cm),
Paragraph("_Template pending Zimbabwean counsel approval. Do not sign as final._",
styles["Normal"]),
Spacer(1, 0.3 * cm)]
rows = [
f"Lease ID: {lease['id']}",
f"Property: {listing.get('title')} ({listing.get('suburb')}, {listing.get('city')})",
f"Type: {listing.get('type')}",
f"Landlord: {lease.get('landlord_phone')}",
f"Tenant: {lease.get('tenant_phone')}",
f"Rent: {cents_fmt(lease.get('rent_cents', 0))} per month",
f"Deposit: {cents_fmt(lease.get('deposit_cents', 0))}",
f"Term: {lease.get('term_months')} months",
f"Status: {lease.get('status')}",
f"Date: {lease.get('created_at')}",
]
for r in rows:
story.append(Paragraph(r, styles["Normal"]))
story.append(Spacer(1, 0.4 * cm))
story.append(Paragraph("Tenant signature: ___________________ Date: _______", styles["Normal"]))
story.append(Paragraph("Landlord signature: _________________ Date: _______", styles["Normal"]))
doc.build(story)
return path
except Exception as e:
logger.error(f"lease pdf: {e}")
return None
# ── Gemini helpers ───────────────────────────────────────────────────
def _safe_json(s: str, fallback: Any) -> Any:
try:
c = s
if "```json" in c:
c = c.split("```json")[1].split("```")[0]
elif "```" in c:
c = c.split("```")[1]
return json.loads(c.strip())
except Exception:
return fallback
def gemini_detect_intent(text: str) -> Dict[str, Any]:
if not _gemini:
return {"intent": "CASUAL", "criteria": {}}
prompt = (
"You route WhatsApp messages for RentNow, a Zimbabwe rental finder. "
"Return STRICT JSON only. "
'{"intent": "SEARCH|REGISTER_TENANT|REGISTER_LANDLORD|CREATE_LISTING|GET_PASS|MY_ACCOUNT|HELP|SUPPORT|LEASE|CASUAL", '
'"criteria": {"type": "", "suburb": "", "max_budget": 0, "amenities": []}} '
"Types, exact strings: single room, cottage, 1 bedroom, 2 bedroom. "
"Map 2 bed, two bedroom, 2bed to 2 bedroom. Map 1 bed to 1 bedroom. Map room, bedsitter to single room. "
"Extract suburb as written. Extract max monthly USD from under/below/budget/up to/$, else 0. "
"Amenities from: solar, borehole, parking, furnished, walled, tiled, security, water, electricity, wifi, fitted kitchen, pet friendly. "
"SEARCH covers any home request, even vague ones like looking, need a place, anything available. "
"SUPPORT covers help with a person, complaint, problem, agent. "
f"Message: {text}"
)
try:
resp = _gemini.models.generate_content(
model=GEMINI_MODEL, contents=prompt,
config=genai_types.GenerateContentConfig(response_mime_type="application/json"))
return _safe_json(resp.text, {"intent": "CASUAL", "criteria": {}})
except Exception as e:
logger.error(f"intent: {e}")
return {"intent": "CASUAL", "criteria": {}}
def gemini_chat(text: str, context: str = "") -> str:
"""Warm short rental assistant, Pricelyst style. Always ends with a next step."""
if not _gemini:
return ""
try:
resp = _gemini.models.generate_content(
model=GEMINI_MODEL,
contents=(
"You are RentNow, a friendly Zimbabwe rental assistant on WhatsApp. "
"Voice: warm, plain, confident. Two short paragraphs max. "
"Start by acknowledging what the user wants in one line. "
"Then give one concrete next step: search by type plus suburb plus budget, "
"unlock with the $10 pass, or book a $5 viewing. "
"Never invent listings, prices, or suburbs. No URLs except rentnow.co.zw. "
f"Context: {context}\nUser: {text}")
)
return (resp.text or "").strip()
except Exception:
return ""
def gemini_ack_search(crit: Dict, count: int) -> str:
"""One-line acknowledgment before results. Gemini when present, template fallback."""
bits = []
if crit.get("type"):
bits.append(crit["type"] + "s")
if crit.get("suburb"):
bits.append("in " + crit["suburb"])
if crit.get("max_budget"):
bits.append("up to $" + str(int(crit["max_budget"])) + "/month")
want = " ".join(bits) or "homes"
if _gemini:
try:
resp = _gemini.models.generate_content(
model=GEMINI_MODEL,
contents=(f"Write one warm WhatsApp line. User wants {want}. "
f"We found {count} matches. No prices, no listings, one line."))
line = (resp.text or "").strip()
if line:
return line
except Exception:
pass
return f"Perfect. Searching RentNow for {want}. Found {count} for you."
def parse_budget(text: str) -> Tuple[Optional[float], Optional[float]]:
nums = [float(n.replace(",", "")) for n in re.findall(r"\$?\s*(\d[\d,]*)", text or "")]
if not nums:
return None, None
if len(nums) == 1:
return None, nums[0]
return min(nums[:2]), max(nums[:2])
SUBURBS = ["westgate", "avondale", "borrowdale", "mbare", "kuwadzana", "glen view",
"hatcliffe", "epworth", "ruwa", "chitungwiza", "bulawayo", "cbd", "town",
"greendale", "mount pleasant", "mt pleasant", "chisipite", "highlands", "southerton"]
TYPE_KEYS = [
("2 bedroom", ["2 bedroom", "2 bedrooms", "2 bed", "two bedroom", "2bed"]),
("1 bedroom", ["1 bedroom", "1 bedrooms", "1 bed", "one bedroom", "1bed"]),
("cottage", ["cottage", "cottages"]),
("house", ["house", "townhouse"]),
("cottage", ["garden flat"]),
("single room", ["single room", "single rooms", "room", "rooms", "bedsitter"]),
]
AMEN_KEYS = ["solar", "borehole", "parking", "furnished", "walled", "tiled",
"secure", "security", "water", "electricity", "wifi", "fitted kitchen",
"pet friendly", "pets", "ensuite", "gated community"]
def parse_search(text: str) -> Dict:
"""Deterministic natural-language search. No AI needed.
Understands: '2 bedroom in Westgate under $400 with solar and parking'.
Returns criteria dict; empty values mean 'no constraint'.
"""
t = (text or "").lower()
crit: Dict = {"type": "", "suburb": "", "max_budget": 0, "amenities": []}
t = t.replace("mt pleasant", "mount pleasant")
for canon, keys in sorted(TYPE_KEYS, key=lambda pair: pair[0] != "cottage"):
if any(k in t for k in keys):
crit["type"] = canon
break
near = re.search(r"near\s+([a-z ]+?)(?:\s+with|\s+under|\s+below|\s+budget|\s+around|$)", t)
if near:
cand = near.group(1).strip()
hit = next((s for s in SUBURBS if s in cand or cand in s), "")
if hit:
crit["suburb"] = hit.title() if hit != "cbd" else "CBD"
if not crit["suburb"]:
hit = next((s for s in SUBURBS if s in t), "")
if hit:
crit["suburb"] = hit.title() if hit != "cbd" else "CBD"
m = re.search(r"(under|below|up to|upto|max|maximum|around|about|budget)\s*\$?\s*(\d[\d,]*)", t)
if m:
crit["max_budget"] = float(m.group(2).replace(",", ""))
else:
rng = re.search(r"(\d[\d,]*)\s*[-\u2013]\s*(\d[\d,]*)", t)
if rng:
crit["min_budget"] = float(rng.group(1).replace(",", ""))
crit["max_budget"] = float(rng.group(2).replace(",", ""))
else:
nums = re.findall(r"\$\s*(\d[\d,]*)", t)
if nums:
crit["max_budget"] = float(nums[-1].replace(",", ""))
else:
_, hi = parse_budget(t)
# Only treat a lone number as budget in a search context
if hi and any(w in t for w in ["budget", "rent", "month", "$", "usd"]):
crit["max_budget"] = hi
crit["amenities"] = [a for a in AMEN_KEYS if a in t]
return crit
def looks_like_search(text: str) -> bool:
"""True if free text carries any search signal at all."""
c = parse_search(text)
t = (text or "").lower()
return bool(c["type"] or c["suburb"] or c["max_budget"] or c["amenities"]
or any(w in t for w in ["bedroom", "cottage", "room", "rent", "house", "flat",
"looking", "need", "want", "find", "search", "place"]))