rairo commited on
Commit
be21bae
·
1 Parent(s): b1ae07a

Implement renter search, previews and simulated payment flows

Browse files
Files changed (5) hide show
  1. README.md +14 -0
  2. main.py +218 -117
  3. test_flows.py +204 -0
  4. utility.py +29 -11
  5. whatsapp_client.py +1 -1
README.md CHANGED
@@ -8,3 +8,17 @@ pinned: false
8
  ---
9
 
10
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  ---
9
 
10
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
11
+
12
+ ## Renter flows
13
+
14
+ The finder offers step-by-step and natural-language search. Four steps collect property type, area, monthly USD budget and optional features, followed by a search summary with editable requirements. Numbered text menus work with the existing Twilio transport; lists retain every option. Photo messages replace the mockup's embedded carousel and cards.
15
+
16
+ Search is free, with three numbered results per page and up to two free preview photos. A $10 Rental Pass supplies three distinct property unlocks for 14 days. Payment confirmation preserves the selected property and offers View property now or Continue browsing. Viewing an already unlocked home uses no additional credit. Full details include the gallery, optional `video_url`, `rental_requirements` and provider information. Missing video or requirements are handled without inventing content.
17
+
18
+ A $5 viewing request explains the $2 provider / $3 platform split. Successful simulated payment creates an appointment-pending request for operations to coordinate. It does not claim that a viewing time is booked. Provider contact is shared in that confirmation. Property questions enter the existing support collection.
19
+
20
+ Payments remain spoofed. The user explicitly confirms a demo payment; no EcoCash transfer or live payment integration occurs.
21
+
22
+ ## Verification
23
+
24
+ Run `python -m unittest test_flows -q`. The conversation tests use local memory and mocked outbound messages, with no Firebase, Gemini, WhatsApp or payment calls. Deploy bot changes together with the admin API and UI updates that persist optional gallery/video/requirements fields.
main.py CHANGED
@@ -105,11 +105,11 @@ WELCOME = (
105
 
106
  MAIN_OPTS = ["Find a place to rent", "List my property", "Talk to support"]
107
 
108
- FIND_TYPES = ["Single Room", "Cottage", "1 Bedroom", "2 Bedroom"]
109
- FIND_AREAS = ["Westgate", "Avondale", "Borrowdale", "Kuwadzana", "Glen View", "Hatcliffe", "Epworth", "Any area"]
110
  FIND_BUDGETS = [("Under $200", 200), ("$200 - $400", 400), ("$400 - $600", 600),
111
- ("$600 - $800", 800), ("Above $800", 0), ("Any budget", 0)]
112
- FIND_AMENS = ["Solar", "Borehole", "Parking", "Furnished", "Walled", "Pet friendly", "None of these"]
113
  PAGE = 3
114
 
115
 
@@ -142,74 +142,136 @@ def my_account_text(phone: str) -> str:
142
 
143
  # ── Finder + landlord + support flows (story order) ─────────────────
144
  def start_find(m: str, prefill: Optional[Dict] = None) -> None:
145
- """Guided search. Every step accepts a number OR free text."""
146
  d = {"type": "", "suburb": "", "max_budget": 0, "amenities": []}
147
- if prefill:
148
- d.update({k: v for k, v in prefill.items() if v})
149
- if not d.get("type"):
150
- wa.send_text_message(
151
- m, "Great. Let's find your next home.\n"
152
- "You can type your requirements naturally (e.g. 2 bedroom in Westgate under $400) "
153
- "or answer step by step.")
154
- wa.send_buttons_as_text(m, "What type of property are you looking for?", FIND_TYPES)
155
- set_s(m, {"menu": "find", "step": "type", "data": d})
156
  return
 
157
  _find_next(m, d)
158
 
159
 
160
  def _find_next(m: str, d: Dict) -> None:
161
- if not d.get("suburb"):
162
- wa.send_buttons_as_text(m, "Which area are you interested in? Type any suburb or pick one.", FIND_AREAS)
163
- set_s(m, {"menu": "find", "step": "suburb", "data": d})
164
- elif not d.get("max_budget"):
165
- wa.send_buttons_as_text(m, "Maximum monthly budget (USD)? Pick a range or type an amount.",
166
- [b[0] for b in FIND_BUDGETS])
167
- set_s(m, {"menu": "find", "step": "budget", "data": d})
 
168
  else:
169
- wa.send_buttons_as_text(
170
- m, "Any must-have features? Reply numbers together (e.g. 1,3), type a list, or pick None.",
171
- FIND_AMENS)
172
- set_s(m, {"menu": "find", "step": "amen", "data": d})
173
 
174
 
175
  def _finish_find(m: str, d: Dict) -> None:
176
- p = U.get_profile(m)
177
- prefs = {"type": d.get("type", ""), "suburb": d.get("suburb", ""),
178
- "max_budget": d.get("max_budget", 0), "amenities": d.get("amenities", [])}
179
- roles = sorted(set(p.get("roles", []) + ["seeker"]))
180
- U.save_profile(m, {"roles": roles, "prefs": prefs})
181
- show_results(m, prefs)
 
 
 
 
 
182
 
183
 
184
  def handle_find(m: str, text: str, s: Dict) -> bool:
185
- """Guided steps. Free text at any step jumps straight to results."""
186
- step, d = s.get("step", ""), s.get("data", {})
187
  t = text.strip()
188
- # Natural input escapes the rails anywhere
189
- if step != "amen" and U.looks_like_search(t) and len(t.split()) > 2:
190
- d.update({k: v for k, v in U.parse_search(t).items() if v})
191
- logger.info(f"STEP find-nl from={m} crit={d}")
192
- if d.get("type") and d.get("suburb") and d.get("max_budget"):
193
- _finish_find(m, d)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  else:
195
- start_find(m, prefill=d)
196
  return True
197
  if step == "type":
198
- d["type"] = _pick(t, FIND_TYPES, "type")
199
- _find_next(m, d)
 
 
 
200
  elif step == "suburb":
201
- d["suburb"] = "" if t.lower() in ("8", "any area", "any") else _pick(t, FIND_AREAS, "suburb")
202
- if d["suburb"] == "Any Area":
203
- d["suburb"] = ""
204
- _find_next(m, d)
 
205
  elif step == "budget":
206
- d["max_budget"] = _pick_budget(t)
207
- _find_next(m, d)
 
 
 
 
 
 
 
 
 
 
208
  elif step == "amen":
 
 
 
209
  d["amenities"] = _pick_multi(t, FIND_AMENS)
210
- _finish_find(m, d)
211
  else:
212
- return False
 
 
213
  return True
214
 
215
 
@@ -241,19 +303,19 @@ def _pick_multi(t: str, opts: list) -> list:
241
  if t.lower() in ("none", "none of these", "7", "skip"):
242
  return []
243
  out = []
244
- for part in re.split(r"[, ]+", t):
245
  part = part.strip()
246
  if part.isdigit() and 1 <= int(part) <= len(opts):
247
  o = opts[int(part) - 1]
248
- if o != "None of these" and o not in out:
249
  out.append(o)
250
  elif part:
251
  hit = next((o for o in opts if part.lower() in o.lower()), None)
252
- if hit and hit != "None of these" and hit not in out:
253
  out.append(hit)
254
  if not out and t.lower() not in ("none",):
255
  c = U.parse_search(t)
256
- out = [a.title() for a in c["amenities"]]
257
  return out
258
 
259
 
@@ -270,12 +332,14 @@ def start_create_listing(m: str) -> None:
270
  def story_results_text(res: list, shown: int) -> str:
271
  lines = []
272
  for i, r in enumerate(res[:shown], 1):
273
- lines.append(U.format_preview(r, i, r["_score"], r["_reasons"]))
 
 
274
  lines.append("")
275
  left = len(res) - shown
276
  if left > 0:
277
  lines.append(f"_View more properties ({left} remaining): reply MORE_")
278
- lines.append("Reply a number for details, UNLOCK n for full photos, NEW for a new search.")
279
  return "\n".join(lines)
280
 
281
 
@@ -286,40 +350,55 @@ def show_results(m: str, crit: Dict) -> None:
286
  set_s(m, {"menu": "browse", "results": [], "shown": 0, "criteria": crit})
287
  return
288
  wa.send_text_message(m, U.gemini_ack_search(crit, len(res)))
289
- exact = [r for r in res if r["_score"] >= 50]
290
- note = f"{len(exact)} exact matches" if exact else f"{len(res)} matches"
291
- near = len(res) - len(exact)
292
- if near > 0:
293
- note += f" + {near} nearby alternatives"
294
- wa.send_text_message(m, f"*{note}.*\nYour search: {crit.get('type','Any')} in {crit.get('suburb','Any area')}"
295
- + (f" up to ${int(crit['max_budget'])}" if crit.get("max_budget") else ""))
296
  shown = min(PAGE, len(res))
297
- wa.send_text_message(m, story_results_text(res, shown))
298
  set_s(m, {"menu": "browse", "results": [r["id"] for r in res], "shown": shown, "criteria": crit})
299
 
300
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  def show_detail(m: str, lid: str, n: int) -> None:
302
  item = U.get_listing(lid)
303
  if not item:
304
  wa.send_text_message(m, "That home just left the market. Reply MORE for others.")
305
  return
 
 
306
  if U.is_unlocked(m, lid):
307
  send_full_details(m, item, n)
308
  return
309
- wa.send_text_message(m, U.format_preview(item, n, None, None))
310
- wa.send_buttons_as_text(m, "Full photos, video and landlord contact are locked.",
311
- [f"Unlock {n}", "Back to results"])
 
 
 
312
 
313
 
314
  def send_full_details(m: str, item: Dict, n: int = 0) -> None:
315
- wa.send_text_message(m, f"Property unlocked.\n\n{U.format_full(item)}")
316
- for url in (item.get("media") or [])[:3]:
 
 
317
  wa.send_image_message(m, url, item.get("title", ""))
318
- wa.send_buttons_as_text(
319
- m, "Want to see it in person? $5. Our team coordinates with the landlord.",
320
- [f"Request Viewing - $5", "Back to results", "Save property"])
 
321
  s = get_s(m) or {}
322
- set_s(m, {**s, "menu": "browse", "detail": item["id"], "detail_n": n,
323
  "results": s.get("results", []), "shown": s.get("shown", PAGE),
324
  "criteria": s.get("criteria", {})})
325
 
@@ -364,6 +443,7 @@ def handle_need_name(m: str, text: str, s: Dict) -> bool:
364
  pend = s.get("pending", {})
365
  lid = pend.get("pending_unlock", "")
366
  if lid:
 
367
  buy_pass(m, pending_unlock=lid)
368
  return True
369
  set_s(m, pend if pend.get("menu") else {"menu": "main"})
@@ -500,42 +580,74 @@ def buy_pass(m: str, pending_unlock: str = "") -> None:
500
  wa.send_text_message(
501
  m, f"{head}"
502
  "Unlock 3 properties. Full photos, video and details. Valid for 14 days.\n"
503
- "Simple and secure payment via EcoCash.\n\n"
 
504
  f"{U.ecocash_instructions(pay)}")
505
- wa.send_buttons_as_text(m, "After paying:", ["Payment done", "Cancel"])
506
- st: Dict = {"menu": "await_pay", "payment_id": pay["id"], "kind": "rental_pass"}
507
  if pending_unlock:
508
  st["pending_unlock"] = pending_unlock
509
  set_s(m, st)
510
 
511
 
512
  def buy_viewing(m: str, listing_id: str) -> None:
 
 
 
513
  pay = U.create_payment(m, "viewing", U.VIEWING_FEE_CENTS, {"listing_id": listing_id})
514
- wa.send_text_message(m, f"*Physical viewing $5* for [{listing_id}].\n$2 goes to the landlord, $3 to the platform. Our team coordinates the appointment.\n\n{U.ecocash_instructions(pay)}")
515
- wa.send_buttons_as_text(m, "After paying:", ["Payment done", "Cancel"])
516
- set_s(m, {"menu": "await_pay", "payment_id": pay["id"], "kind": "viewing", "listing_id": listing_id})
517
 
518
 
519
  def handle_browse(m: str, text: str, s: Dict) -> bool:
520
  t = text.strip().lower()
 
 
 
 
521
  ids = s.get("results", [])
522
  shown = s.get("shown", PAGE)
523
  if t in ("new", "new search"):
524
  start_find(m)
525
  return True
526
  if t in ("more", "view more", "view more properties"):
527
- shown = min(len(ids), shown + PAGE)
528
- wa.send_text_message(m, story_results_text([U.get_listing(i) for i in ids], shown))
529
- set_s(m, {**s, "shown": shown})
530
  return True
531
  if t in ("back", "back to results"):
532
  wa.send_text_message(m, story_results_text([U.get_listing(i) for i in ids], shown))
533
- set_s(m, {**s, "detail": "", "detail_n": 0})
534
  return True
535
- if t in ("pass", "get rental pass", "rental pass", "payment done", "1"):
536
  buy_pass(m)
537
  return True
538
- # Button labels from detail cards (Twilio has no tap-to-action)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
  det_id = s.get("detail", "")
540
  if "request viewing" in t and det_id:
541
  if not U.is_unlocked(m, det_id):
@@ -607,19 +719,7 @@ def handle_browse(m: str, text: str, s: Dict) -> bool:
607
  n = int(t)
608
  if 1 <= n <= len(ids):
609
  show_detail(m, ids[n - 1], n)
610
- set_s(m, {**s, "detail": ids[n - 1], "detail_n": n})
611
  return True
612
- # number answers inside detail options map to option rows
613
- det = s.get("detail_n") or 0
614
- if det and n in (1, 2, 3):
615
- lid = ids[det - 1]
616
- if n == 1:
617
- return handle_browse(m, f"unlock {det}", s)
618
- if n == 2:
619
- return handle_browse(m, "back", s)
620
- if n == 3 and U.is_unlocked(m, lid):
621
- buy_viewing(m, lid)
622
- return True
623
  return False
624
 
625
 
@@ -652,6 +752,10 @@ def _process_text_inner(text: str, mobile: str) -> None:
652
  low = text.strip().lower()
653
  logger.info(f"STEP route from={mobile} session={(s or {}).get('menu')}/{str((s or {}).get('step'))}")
654
 
 
 
 
 
655
  if GREET.match(text.strip()) and (not s or s.get("menu") in ("main", "post_reg")):
656
  logger.info(f"STEP welcome from={mobile}")
657
  send_welcome(mobile)
@@ -707,7 +811,7 @@ def _process_text_inner(text: str, mobile: str) -> None:
707
  return
708
  if menu == "await_pay":
709
  logger.info(f"STEP pay-confirm from={mobile} kind={s.get('kind')} low={low!r}")
710
- if low in ("1", "payment done", "i have paid", "paid", "confirm", "pay"):
711
  pay = U.confirm_payment_spoof(s["payment_id"], text)
712
  logger.info(f"STEP pay-status from={mobile} pay={(pay or {}).get('id')} status={(pay or {}).get('status')}")
713
  if not pay or pay.get("status") != "confirmed":
@@ -716,29 +820,26 @@ def _process_text_inner(text: str, mobile: str) -> None:
716
  if s.get("kind") == "rental_pass":
717
  p = U.issue_pass(mobile, pay["id"])
718
  left = p["credits_total"] - p["credits_used"]
719
- wa.send_text_message(mobile, f"Great! Your Home Seeker Pass is active. You have {left} property unlocks remaining.")
720
- lid = s.get("pending_unlock", "")
721
- if lid and U.get_listing(lid):
722
- ok, msg = U.consume_unlock(mobile, lid)
723
- logger.info(f"STEP auto-unlock from={mobile} lid={lid} ok={ok}")
724
- if ok:
725
- item = U.get_listing(lid)
726
- n = _num_of(s, lid)
727
- send_full_details(mobile, item, n)
728
- wa.send_text_message(mobile, f"_{msg}_")
729
- return
730
- clear_s(mobile)
731
- start_find(mobile)
732
  else:
733
  v = U.create_viewing(mobile, s.get("listing_id", ""), pay["id"])
734
- clear_s(mobile)
735
- wa.send_text_message(mobile, f"Viewing booked [{v['id']}]. Our team will coordinate with the landlord and confirm the time here.")
 
 
736
  return
737
- if low in ("2", "cancel"):
738
- clear_s(mobile)
739
- wa.send_text_message(mobile, "Cancelled. Reply MENU anytime.")
 
740
  return
741
- logger.info(f"STEP pay-unmatched from={mobile}")
 
742
 
743
  # keyword shortcuts (no session)
744
  logger.info(f"STEP shortcuts from={mobile} low={low!r}")
 
105
 
106
  MAIN_OPTS = ["Find a place to rent", "List my property", "Talk to support"]
107
 
108
+ FIND_TYPES = ["Single Room", "Cottage / Garden Flat", "1 Bedroom", "2 Bedroom", "House / Townhouse"]
109
+ FIND_AREAS = ["Mount Pleasant", "Avondale", "Borrowdale", "Westgate", "Highlands", "Hatcliffe", "Other (type suburb)", "Any area"]
110
  FIND_BUDGETS = [("Under $200", 200), ("$200 - $400", 400), ("$400 - $600", 600),
111
+ ("$600 - $800", 800), ("Over $800", 0), ("Any (show all)", 0)]
112
+ FIND_AMENS = ["Borehole", "Solar", "Parking", "Ensuite", "Pet friendly", "Gated community", "None", "Other (type)"]
113
  PAGE = 3
114
 
115
 
 
142
 
143
  # ── Finder + landlord + support flows (story order) ─────────────────
144
  def start_find(m: str, prefill: Optional[Dict] = None) -> None:
 
145
  d = {"type": "", "suburb": "", "max_budget": 0, "amenities": []}
146
+ if prefill is None:
147
+ wa.send_buttons_as_text(m,
148
+ "Great! Let's find your next home.\nChoose step by step, or type naturally at any time.\n"
149
+ 'Example: "1 bed cottage in Mount Pleasant with borehole, solar and parking under $400".',
150
+ ["Step by step", "Type my preferences"])
151
+ set_s(m, {"menu": "find", "step": "mode", "data": d})
 
 
 
152
  return
153
+ d.update(prefill)
154
  _find_next(m, d)
155
 
156
 
157
  def _find_next(m: str, d: Dict) -> None:
158
+ if not d.get("type"):
159
+ step, prompt, options = "type", "Step 1 of 4: What type of home are you looking for?", FIND_TYPES
160
+ elif not d.get("suburb") and not d.get("area_chosen"):
161
+ step, prompt, options = "suburb", "Step 2 of 4: Which area? You can type any suburb.", FIND_AREAS
162
+ elif not d.get("max_budget") and not d.get("budget_chosen"):
163
+ step, prompt, options = "budget", "Step 3 of 4: What's your monthly budget (USD)? Or type an amount.", [b[0] for b in FIND_BUDGETS]
164
+ elif not d.get("features_chosen"):
165
+ step, prompt, options = "amen", "Step 4 of 4: Any preferred features? Reply 1,2,3 or 123, type features, or SKIP.", FIND_AMENS
166
  else:
167
+ _finish_find(m, d)
168
+ return
169
+ wa.send_buttons_as_text(m, prompt, options)
170
+ set_s(m, {"menu": "find", "step": step, "data": d})
171
 
172
 
173
  def _finish_find(m: str, d: Dict) -> None:
174
+ """Confirm our understanding before running a search."""
175
+ d = {**d, "features_chosen": True}
176
+ budget = (f"${d.get('min_budget', 0):g} - ${d['max_budget']:g}/month" if d.get("max_budget")
177
+ else "Over $800/month" if d.get("min_budget") else "Any budget")
178
+ wa.send_buttons_as_text(m,
179
+ "*Here's what I'm looking for:*\n"
180
+ f"Home: {d.get('type') or 'Any type'}\nArea: {d.get('suburb') or 'Any area'}\n"
181
+ f"Budget: {budget}\nFeatures: {', '.join(d.get('amenities', [])) or 'No preference'}\n\n"
182
+ "Shall I search for properties that match this? You can also type new preferences.",
183
+ ["Search now", "Change requirements"])
184
+ set_s(m, {"menu": "find", "step": "summary", "data": d})
185
 
186
 
187
  def handle_find(m: str, text: str, s: Dict) -> bool:
188
+ step, d = s.get("step", ""), dict(s.get("data", {}))
 
189
  t = text.strip()
190
+ low = t.lower()
191
+ if step == "summary":
192
+ if low in ("1", "search now", "search", "yes"):
193
+ prefs = {k: d.get(k, default) for k, default in
194
+ (("type", ""), ("suburb", ""), ("min_budget", 0), ("max_budget", 0), ("amenities", []))}
195
+ p = U.get_profile(m)
196
+ U.save_profile(m, {"roles": sorted(set(p.get("roles", []) + ["seeker"])), "prefs": prefs})
197
+ show_results(m, prefs)
198
+ return True
199
+ if low in ("2", "change requirements", "change", "edit"):
200
+ wa.send_buttons_as_text(m, "What would you like to change?", ["Property type", "Location", "Budget", "Features", "Start over"])
201
+ set_s(m, {"menu": "find", "step": "edit", "data": d})
202
+ return True
203
+ if step == "edit":
204
+ choice = _pick(t, ["Property type", "Location", "Budget", "Features", "Start over"], "edit")
205
+ if choice == "Start over":
206
+ start_find(m, {})
207
+ return True
208
+ keys = {"Property type": ("type",), "Location": ("suburb", "area_chosen"),
209
+ "Budget": ("min_budget", "max_budget", "budget_chosen"), "Features": ("features_chosen",)}
210
+ if choice in keys:
211
+ for key in keys[choice]:
212
+ d.pop(key, None)
213
+ _find_next(m, d)
214
+ return True
215
+ # Natural descriptions work in every finder step, including preferences and summary.
216
+ if not t.isdigit() and len(t.split()) > 2 and U.looks_like_search(t):
217
+ parsed = U.parse_search(t)
218
+ d.update({k: v for k, v in parsed.items() if v})
219
+ if parsed.get("max_budget"):
220
+ d["min_budget"] = parsed.get("min_budget", 0)
221
+ if parsed.get("amenities"):
222
+ d["features_chosen"] = True
223
+ _find_next(m, d)
224
+ return True
225
+ if step == "mode":
226
+ if low in ("1", "step by step"):
227
+ _find_next(m, d)
228
+ elif low in ("2", "type my preferences"):
229
+ wa.send_text_message(m, "Tell me the type, area, monthly budget and features you want. Reply BACK for step by step.")
230
+ set_s(m, {"menu": "find", "step": "natural", "data": d})
231
+ else:
232
+ wa.send_text_message(m, "Reply 1 for step by step, or type your preferences.")
233
+ return True
234
+ if step == "natural":
235
+ if low == "back":
236
+ _find_next(m, d)
237
  else:
238
+ start_find(m, U.parse_search(t))
239
  return True
240
  if step == "type":
241
+ chosen = _pick(t, FIND_TYPES, "type")
242
+ if t.isdigit() and not 1 <= int(t) <= len(FIND_TYPES):
243
+ wa.send_text_message(m, "Choose a property type from 1 to 5, or type a home type.")
244
+ return True
245
+ d["type"] = {"Cottage / Garden Flat": "cottage", "House / Townhouse": "house"}.get(chosen, chosen)
246
  elif step == "suburb":
247
+ if low in ("7", "other", "other (type suburb)"):
248
+ wa.send_text_message(m, "Type the suburb you'd like.")
249
+ return True
250
+ d["suburb"] = "" if low in ("8", "any area", "any") else _pick(t, FIND_AREAS, "suburb")
251
+ d["area_chosen"] = True
252
  elif step == "budget":
253
+ if low in ("6", "any", "any budget", "any (show all)"):
254
+ d.update(max_budget=0, min_budget=0)
255
+ elif low in ("5", "over $800", "above $800"):
256
+ d.update(max_budget=0, min_budget=800.01)
257
+ else:
258
+ choice = int(t)-1 if t.isdigit() and 1 <= int(t) <= 4 else None
259
+ lo, hi = U.parse_budget(t) if choice is None else (choice * 200, FIND_BUDGETS[choice][1])
260
+ if not hi or hi <= 0:
261
+ wa.send_text_message(m, "Choose a budget option or type a positive amount, e.g. 350.")
262
+ return True
263
+ d.update(max_budget=hi, min_budget=lo or 0)
264
+ d["budget_chosen"] = True
265
  elif step == "amen":
266
+ if low in ("8", "other", "other (type)"):
267
+ wa.send_text_message(m, "Type your preferred features, or SKIP.")
268
+ return True
269
  d["amenities"] = _pick_multi(t, FIND_AMENS)
270
+ d["features_chosen"] = True
271
  else:
272
+ wa.send_text_message(m, "Reply with an option above or type your new preferences.")
273
+ return True
274
+ _find_next(m, d)
275
  return True
276
 
277
 
 
303
  if t.lower() in ("none", "none of these", "7", "skip"):
304
  return []
305
  out = []
306
+ for part in (list(t) if t.isdigit() and len(t) > 1 else re.split(r"[, ]+", t)):
307
  part = part.strip()
308
  if part.isdigit() and 1 <= int(part) <= len(opts):
309
  o = opts[int(part) - 1]
310
+ if o not in ("None", "None of these", "Other (type)") and o not in out:
311
  out.append(o)
312
  elif part:
313
  hit = next((o for o in opts if part.lower() in o.lower()), None)
314
+ if hit and hit not in ("None", "None of these", "Other (type)") and hit not in out:
315
  out.append(hit)
316
  if not out and t.lower() not in ("none",):
317
  c = U.parse_search(t)
318
+ out = [a.title() for a in c["amenities"]] or [t]
319
  return out
320
 
321
 
 
332
  def story_results_text(res: list, shown: int) -> str:
333
  lines = []
334
  for i, r in enumerate(res[:shown], 1):
335
+ if not r:
336
+ continue
337
+ lines.append(U.format_preview(r, i, r.get("_score"), r.get("_reasons")))
338
  lines.append("")
339
  left = len(res) - shown
340
  if left > 0:
341
  lines.append(f"_View more properties ({left} remaining): reply MORE_")
342
+ lines.append("Reply a property number for a free preview, UNLOCK n to unlock, MORE for more homes, or NEW to search.")
343
  return "\n".join(lines)
344
 
345
 
 
350
  set_s(m, {"menu": "browse", "results": [], "shown": 0, "criteria": crit})
351
  return
352
  wa.send_text_message(m, U.gemini_ack_search(crit, len(res)))
353
+ wa.send_text_message(m, f"I found {len(res)} matching properties. Here are the first {min(PAGE, len(res))}.")
 
 
 
 
 
 
354
  shown = min(PAGE, len(res))
355
+ send_result_page(m, res, 0, shown)
356
  set_s(m, {"menu": "browse", "results": [r["id"] for r in res], "shown": shown, "criteria": crit})
357
 
358
 
359
+ def send_result_page(m: str, results: list, start: int, end: int) -> None:
360
+ for index in range(start, end):
361
+ item = results[index]
362
+ if not item:
363
+ continue
364
+ caption = U.format_preview(item, index + 1)
365
+ photos = item.get("media") or []
366
+ if not photos or not wa.send_image_message(m, photos[0], caption):
367
+ wa.send_text_message(m, caption)
368
+ wa.send_text_message(m, "Reply a property number for a free preview. UNLOCK n for full details with a $10 Rental Pass.\n"
369
+ + ("MORE for more homes. " if end < len(results) else "") + "NEW for a new search.")
370
+
371
+
372
  def show_detail(m: str, lid: str, n: int) -> None:
373
  item = U.get_listing(lid)
374
  if not item:
375
  wa.send_text_message(m, "That home just left the market. Reply MORE for others.")
376
  return
377
+ s = get_s(m) or {}
378
+ set_s(m, {**s, "menu": "browse", "detail": lid, "detail_n": n, "screen": "preview"})
379
  if U.is_unlocked(m, lid):
380
  send_full_details(m, item, n)
381
  return
382
+ wa.send_text_message(m, U.format_preview(item, n))
383
+ for url in (item.get("media") or [])[:2]:
384
+ wa.send_image_message(m, url, "Free preview")
385
+ wa.send_buttons_as_text(m,
386
+ "More available after unlock: full photo gallery, video (where available), description, rental requirements and provider information.",
387
+ ["Unlock property", "Back to results"])
388
 
389
 
390
  def send_full_details(m: str, item: Dict, n: int = 0) -> None:
391
+ ap = U.active_pass(m)
392
+ left = ap["credits_total"] - ap["credits_used"] if ap else 0
393
+ wa.send_text_message(m, f"*{left} of 3 unlocks remaining*\n\n{U.format_full(item)}")
394
+ for url in (item.get("media") or []):
395
  wa.send_image_message(m, url, item.get("title", ""))
396
+ if item.get("video_url"):
397
+ wa.send_document_message(m, doc_url=item["video_url"], caption="Video walkthrough")
398
+ wa.send_buttons_as_text(m, "Want to see it in person? RentNow coordinates your viewing.",
399
+ ["Request viewing - $5", "Back to results", "Ask a question", "Save property"])
400
  s = get_s(m) or {}
401
+ set_s(m, {**s, "menu": "browse", "detail": item["id"], "detail_n": n, "screen": "full",
402
  "results": s.get("results", []), "shown": s.get("shown", PAGE),
403
  "criteria": s.get("criteria", {})})
404
 
 
443
  pend = s.get("pending", {})
444
  lid = pend.get("pending_unlock", "")
445
  if lid:
446
+ set_s(m, pend)
447
  buy_pass(m, pending_unlock=lid)
448
  return True
449
  set_s(m, pend if pend.get("menu") else {"menu": "main"})
 
580
  wa.send_text_message(
581
  m, f"{head}"
582
  "Unlock 3 properties. Full photos, video and details. Valid for 14 days.\n"
583
+ "Includes rental requirements and provider information. Use unlocks on any properties.\n"
584
+ "One-off pass. No subscription. You can continue browsing free.\n\n"
585
  f"{U.ecocash_instructions(pay)}")
586
+ wa.send_buttons_as_text(m, "Demo payment. No money is charged.", ["Pay with EcoCash (demo)", "Continue browsing"])
587
+ st: Dict = {**(get_s(m) or {}), "menu": "await_pay", "payment_id": pay["id"], "kind": "rental_pass"}
588
  if pending_unlock:
589
  st["pending_unlock"] = pending_unlock
590
  set_s(m, st)
591
 
592
 
593
  def buy_viewing(m: str, listing_id: str) -> None:
594
+ if not U.is_unlocked(m, listing_id):
595
+ wa.send_text_message(m, "Unlock this property before requesting a viewing.")
596
+ return
597
  pay = U.create_payment(m, "viewing", U.VIEWING_FEE_CENTS, {"listing_id": listing_id})
598
+ wa.send_text_message(m, f"*Physical viewing $5* for [{listing_id}].\n$2 goes to the landlord, $3 to the platform. Our team confirms availability, sends the provider your request and coordinates the date and time. Provider contact details are shared after payment.\n\n{U.ecocash_instructions(pay)}")
599
+ wa.send_buttons_as_text(m, "Demo payment. No money is charged.", ["Pay with EcoCash (demo)", "Continue browsing"])
600
+ set_s(m, {**(get_s(m) or {}), "menu": "await_pay", "payment_id": pay["id"], "kind": "viewing", "listing_id": listing_id})
601
 
602
 
603
  def handle_browse(m: str, text: str, s: Dict) -> bool:
604
  t = text.strip().lower()
605
+ if t.isdigit() and s.get("screen") in ("preview", "full"):
606
+ options = ["unlock property", "back to results"] if s["screen"] == "preview" else ["request viewing", "back to results", "ask a question", "save property"]
607
+ if 1 <= int(t) <= len(options):
608
+ t = options[int(t) - 1]
609
  ids = s.get("results", [])
610
  shown = s.get("shown", PAGE)
611
  if t in ("new", "new search"):
612
  start_find(m)
613
  return True
614
  if t in ("more", "view more", "view more properties"):
615
+ end = min(len(ids), shown + PAGE)
616
+ send_result_page(m, [U.get_listing(i) for i in ids], shown, end)
617
+ set_s(m, {**s, "shown": end, "screen": "results"})
618
  return True
619
  if t in ("back", "back to results"):
620
  wa.send_text_message(m, story_results_text([U.get_listing(i) for i in ids], shown))
621
+ set_s(m, {**s, "detail": "", "detail_n": 0, "screen": "results"})
622
  return True
623
+ if t in ("pass", "get rental pass", "rental pass", "payment done"):
624
  buy_pass(m)
625
  return True
626
+ if t in ("view property now", "continue browsing"):
627
+ lid = s.get("pending_unlock")
628
+ if t == "view property now" and lid:
629
+ return handle_browse(m, f"unlock {_num_of(s, lid)}", {**s, "screen": "results"})
630
+ wa.send_text_message(m, story_results_text([U.get_listing(i) for i in ids], shown))
631
+ set_s(m, {**s, "screen": "results"})
632
+ return True
633
+ if s.get("screen") == "pass_ready" and t in ("1", "2"):
634
+ return handle_browse(m, "view property now" if t == "1" else "continue browsing", s)
635
+ if t in ("ask a question", "back to property") and s.get("detail"):
636
+ if t == "back to property":
637
+ show_detail(m, s["detail"], s.get("detail_n", 0))
638
+ else:
639
+ wa.send_text_message(m, "Type your question. Our team will help with this property.")
640
+ set_s(m, {**s, "screen": "question"})
641
+ return True
642
+ if s.get("screen") == "question":
643
+ if U.db:
644
+ U.db.collection("rentnow_support").add({"phone": m, "listing_id": s["detail"], "issue": text, "ts": U._now(), "status": "open"})
645
+ else:
646
+ U._mem[f"question:{uuid.uuid4().hex}"] = {"phone": m, "listing_id": s["detail"], "issue": text}
647
+ wa.send_text_message(m, "Question received. Our team will reply here. Reply BACK TO PROPERTY to return.")
648
+ set_s(m, {**s, "screen": "full"})
649
+ return True
650
+ # Resolve numbered text-menu actions within the current property screen.
651
  det_id = s.get("detail", "")
652
  if "request viewing" in t and det_id:
653
  if not U.is_unlocked(m, det_id):
 
719
  n = int(t)
720
  if 1 <= n <= len(ids):
721
  show_detail(m, ids[n - 1], n)
 
722
  return True
 
 
 
 
 
 
 
 
 
 
 
723
  return False
724
 
725
 
 
752
  low = text.strip().lower()
753
  logger.info(f"STEP route from={mobile} session={(s or {}).get('menu')}/{str((s or {}).get('step'))}")
754
 
755
+ if low in ("menu", "start"):
756
+ send_welcome(mobile)
757
+ return
758
+
759
  if GREET.match(text.strip()) and (not s or s.get("menu") in ("main", "post_reg")):
760
  logger.info(f"STEP welcome from={mobile}")
761
  send_welcome(mobile)
 
811
  return
812
  if menu == "await_pay":
813
  logger.info(f"STEP pay-confirm from={mobile} kind={s.get('kind')} low={low!r}")
814
+ if low in ("1", "payment done", "i have paid", "paid", "confirm", "pay", "pay with ecocash (demo)"):
815
  pay = U.confirm_payment_spoof(s["payment_id"], text)
816
  logger.info(f"STEP pay-status from={mobile} pay={(pay or {}).get('id')} status={(pay or {}).get('status')}")
817
  if not pay or pay.get("status") != "confirmed":
 
820
  if s.get("kind") == "rental_pass":
821
  p = U.issue_pass(mobile, pay["id"])
822
  left = p["credits_total"] - p["credits_used"]
823
+ wa.send_text_message(mobile, f"Payment successful (demo). Your $10 Rental Pass is active.\n{left} property unlocks available, valid for 14 days.")
824
+ set_s(mobile, {**s, "menu": "browse", "screen": "pass_ready"})
825
+ if s.get("pending_unlock"):
826
+ wa.send_buttons_as_text(mobile, "View the property you selected, or keep browsing free.", ["View property now", "Continue browsing"])
827
+ else:
828
+ wa.send_text_message(mobile, "Reply CONTINUE BROWSING or NEW to find a property.")
 
 
 
 
 
 
 
829
  else:
830
  v = U.create_viewing(mobile, s.get("listing_id", ""), pay["id"])
831
+ item = U.get_listing(s.get("listing_id", "")) or {}
832
+ set_s(mobile, {**s, "menu": "browse", "screen": "full"})
833
+ wa.send_text_message(mobile, f"Viewing request paid (demo) [{v['id']}]. Our team will send your request to the provider and confirm the appointment date and time here.\n"
834
+ f"Provider: {item.get('provider_name') or 'Contact RentNow'} {item.get('provider_phone') or ''}\nReply BACK TO PROPERTY or ASK A QUESTION.")
835
  return
836
+ if low in ("2", "cancel", "continue browsing"):
837
+ set_s(mobile, {**s, "menu": "browse", "screen": "results"})
838
+ wa.send_text_message(mobile, "Payment cancelled. You can keep browsing free.")
839
+ wa.send_text_message(mobile, story_results_text([U.get_listing(i) for i in s.get("results", [])], s.get("shown", PAGE)))
840
  return
841
+ wa.send_text_message(mobile, "Reply 1 to simulate payment or 2 to continue browsing free.")
842
+ return
843
 
844
  # keyword shortcuts (no session)
845
  logger.info(f"STEP shortcuts from={mobile} low={low!r}")
test_flows.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conversation regressions. No credentials, network calls or real payments."""
2
+ import os
3
+ import unittest
4
+ from unittest.mock import patch
5
+
6
+ os.environ.pop('FIREBASE', None)
7
+ os.environ.pop('GOOGLE_API_KEY', None)
8
+ with patch('dotenv.load_dotenv'):
9
+ import main as bot
10
+ import utility as U
11
+ import whatsapp_client as wa
12
+
13
+
14
+ class RentalFlows(unittest.TestCase):
15
+ def setUp(self):
16
+ self.phone = '+263770000099'
17
+ U.db = None
18
+ U._mem.clear()
19
+ self.messages = []
20
+ self.images = []
21
+ self.patches = [
22
+ patch.object(wa, 'send_text_message', side_effect=lambda m, text: self.messages.append(text) or True),
23
+ patch.object(wa, 'send_image_message', side_effect=lambda m, url, caption='': self.images.append((url, caption)) or True),
24
+ patch.object(wa, 'send_document_message', return_value=True),
25
+ patch.object(U, 'gemini_ack_search', return_value='Matching homes'),
26
+ patch.object(U, 'gemini_detect_intent', return_value={'intent': 'SEARCH', 'criteria': {}}),
27
+ ]
28
+ bot.logger.disabled = True
29
+ for p in self.patches:
30
+ p.start()
31
+ self.addCleanup(p.stop)
32
+
33
+ def say(self, text):
34
+ bot._process_text_inner(text, self.phone)
35
+ return bot.get_s(self.phone)
36
+
37
+ def browse(self):
38
+ U.save_profile(self.phone, {'name': 'Test Renter'})
39
+ bot.show_results(self.phone, {})
40
+ return bot.get_s(self.phone)
41
+
42
+ def test_all_text_menu_options_visible(self):
43
+ wa.send_buttons_as_text(self.phone, 'Features', bot.FIND_AMENS)
44
+ self.assertIn('8. Other (type)', self.messages[-1])
45
+
46
+ def test_guided_any_choices_reach_summary(self):
47
+ self.say('menu')
48
+ self.say('1')
49
+ self.assertEqual(self.say('1')['step'], 'type')
50
+ self.assertEqual(self.say('2')['step'], 'suburb')
51
+ self.assertEqual(self.say('8')['step'], 'budget')
52
+ self.assertEqual(self.say('6')['step'], 'amen')
53
+ state = self.say('skip')
54
+ self.assertEqual(state['step'], 'summary')
55
+ self.assertEqual(state['data']['type'], 'cottage')
56
+ self.assertEqual(self.say('1')['menu'], 'browse')
57
+
58
+ def test_budget_range_preserves_lower_bound(self):
59
+ bot.start_find(self.phone, {'type': 'cottage', 'suburb': 'Avondale'})
60
+ self.say('2')
61
+ state = self.say('7')
62
+ self.assertEqual(state['data']['min_budget'], 200)
63
+ self.assertEqual(state['data']['max_budget'], 400)
64
+
65
+ def test_over_800_has_no_upper_bound(self):
66
+ bot.start_find(self.phone, {'type': 'house', 'suburb': 'Harare'})
67
+ self.assertEqual(self.say('5')['step'], 'amen')
68
+ state = self.say('7')
69
+ self.assertGreater(state['data']['min_budget'], 800)
70
+ self.assertEqual(state['data']['max_budget'], 0)
71
+
72
+ def test_compact_features(self):
73
+ self.assertEqual(bot._pick_multi('123', bot.FIND_AMENS), ['Borehole', 'Solar', 'Parking'])
74
+ self.assertEqual(bot._pick_multi('7', bot.FIND_AMENS), [])
75
+
76
+ def test_natural_input_from_feature_step(self):
77
+ bot.start_find(self.phone, {'type': 'cottage', 'suburb': 'Westgate', 'max_budget': 400})
78
+ state = self.say('1 bed cottage in Mount Pleasant with borehole solar parking under $400')
79
+ self.assertEqual(state['step'], 'summary')
80
+ self.assertEqual(state['data']['type'], 'cottage')
81
+ self.assertEqual(state['data']['suburb'], 'Mount Pleasant')
82
+ self.assertEqual(len(state['data']['amenities']), 3)
83
+
84
+ def test_edit_budget_returns_to_summary(self):
85
+ bot._finish_find(self.phone, {'type': 'cottage', 'suburb': 'Avondale', 'max_budget': 400, 'features_chosen': True})
86
+ self.say('2')
87
+ self.assertEqual(self.say('3')['step'], 'budget')
88
+ state = self.say('350')
89
+ self.assertEqual(state['step'], 'summary')
90
+ self.assertEqual(state['data']['max_budget'], 350)
91
+
92
+ def test_first_result_is_free_preview(self):
93
+ self.browse()
94
+ state = self.say('1')
95
+ self.assertEqual(state['screen'], 'preview')
96
+ self.assertFalse(any(k.startswith('pay:') for k in U._mem))
97
+ self.assertTrue(self.images)
98
+
99
+ def test_payment_then_selected_unlock_and_repeat_preview(self):
100
+ original = self.browse()
101
+ lid = original['results'][0]
102
+ self.say('1')
103
+ state = self.say('1')
104
+ self.assertEqual(state['menu'], 'await_pay')
105
+ self.assertEqual(state['results'], original['results'])
106
+ state = self.say('1')
107
+ self.assertEqual(state['screen'], 'pass_ready')
108
+ self.assertEqual(U.active_pass(self.phone)['credits_used'], 0)
109
+ state = self.say('1')
110
+ self.assertEqual(state['screen'], 'full')
111
+ self.assertTrue(U.is_unlocked(self.phone, lid))
112
+ self.assertEqual(U.active_pass(self.phone)['credits_used'], 1)
113
+ self.say('back to results')
114
+ self.say('1')
115
+ self.assertEqual(U.active_pass(self.phone)['credits_used'], 1)
116
+
117
+ def test_account_capture_retains_selected_property(self):
118
+ bot.show_results(self.phone, {})
119
+ original = bot.get_s(self.phone)
120
+ self.assertEqual(self.say('unlock 2')['menu'], 'need_name')
121
+ state = self.say('Test Renter')
122
+ self.assertEqual(state['menu'], 'await_pay')
123
+ self.assertEqual(state['results'], original['results'])
124
+ self.assertEqual(state['pending_unlock'], original['results'][1])
125
+
126
+ def test_cancel_preserves_results_without_pass(self):
127
+ original = self.browse()
128
+ self.say('unlock 2')
129
+ state = self.say('2')
130
+ self.assertEqual(state['menu'], 'browse')
131
+ self.assertEqual(state['results'], original['results'])
132
+ self.assertIsNone(U.active_pass(self.phone))
133
+
134
+ def test_continue_after_pass_does_not_consume_credit(self):
135
+ self.browse()
136
+ self.say('unlock 2')
137
+ self.say('1')
138
+ state = self.say('2')
139
+ self.assertEqual(state['screen'], 'results')
140
+ self.assertEqual(U.active_pass(self.phone)['credits_used'], 0)
141
+
142
+ def test_full_gallery_and_viewing_request(self):
143
+ original = self.browse()
144
+ lid = original['results'][0]
145
+ self.say('unlock 1')
146
+ self.say('1')
147
+ self.say('1')
148
+ state = self.say('1')
149
+ self.assertEqual(state['kind'], 'viewing')
150
+ self.assertEqual(U._mem['pay:' + state['payment_id']]['amount_cents'], 500)
151
+ self.assertIn('$2 goes to the landlord', '\n'.join(self.messages))
152
+ state = self.say('1')
153
+ self.assertEqual(state['menu'], 'browse')
154
+ viewings = [v for k, v in U._mem.items() if k.startswith('view:')]
155
+ self.assertEqual(len(viewings), 1)
156
+ self.assertEqual(viewings[0]['status'], 'paid_appointment_pending')
157
+ self.assertEqual(viewings[0]['listing_id'], lid)
158
+
159
+ def test_locked_property_cannot_buy_viewing(self):
160
+ self.browse()
161
+ self.say('1')
162
+ self.say('request viewing')
163
+ self.assertFalse(any(k.startswith('pay:') for k in U._mem))
164
+
165
+ def test_more_handles_listing_without_ranking(self):
166
+ self.browse()
167
+ state = self.say('more')
168
+ self.assertEqual(state['shown'], 6)
169
+ self.say('back')
170
+ self.assertIn('NEW', self.messages[-1])
171
+
172
+ def test_constraints_filter_all_required_features_and_minimum(self):
173
+ fixture = [dict(id='a', type='cottage', suburb='Mt Pleasant', rent_cents=35000, amenities=['solar', 'parking']),
174
+ dict(id='b', type='cottage', suburb='Mount Pleasant', rent_cents=15000, amenities=['solar']),
175
+ dict(id='c', type='cottage', suburb='Mount Pleasant', rent_cents=85000, amenities=['solar', 'parking'])]
176
+ with patch.object(U, 'get_listings', return_value=fixture):
177
+ rows = U.search_listings({'suburb': 'Mount Pleasant', 'min_budget': 200, 'max_budget': 400, 'amenities': ['solar', 'parking']})
178
+ self.assertEqual([r['id'] for r in rows], ['a'])
179
+
180
+ def test_unknown_suburb_and_other_features(self):
181
+ bot.start_find(self.phone, {'type': 'cottage'})
182
+ self.say('7')
183
+ self.say('Chishawasha')
184
+ self.say('400')
185
+ self.say('8')
186
+ state = self.say('swimming pool')
187
+ self.assertEqual(state['step'], 'summary')
188
+ self.assertEqual(state['data']['amenities'], ['swimming pool'])
189
+
190
+ def test_pass_expires_and_exhausts(self):
191
+ from datetime import datetime, timezone, timedelta
192
+ p = U.issue_pass(self.phone, 'test-payment')
193
+ p['expires_at'] = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
194
+ self.assertIsNone(U.active_pass(self.phone))
195
+ p['expires_at'] = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()
196
+ for lid in ['a', 'b', 'c']:
197
+ self.assertTrue(U.consume_unlock(self.phone, lid)[0])
198
+ self.assertIsNone(U.active_pass(self.phone))
199
+ self.assertFalse(U.consume_unlock(self.phone, 'd')[0])
200
+ self.assertTrue(U.consume_unlock(self.phone, 'a')[0])
201
+
202
+
203
+ if __name__ == '__main__':
204
+ unittest.main()
utility.py CHANGED
@@ -79,7 +79,7 @@ VIEWING_FEE_CENTS = 500 # $5
79
  LANDLORD_SHARE_CENTS = 200 # $2
80
  PLATFORM_SHARE_CENTS = 300 # $3
81
 
82
- PROPERTY_TYPES = ["single room", "cottage", "1 bedroom", "2 bedroom"]
83
  AMENITIES = ["solar", "borehole", "tiled floors", "fitted kitchen", "parking",
84
  "security", "water", "electricity", "furnished", "wifi"]
85
  CITIES = ["Harare", "Bulawayo", "Mutare", "Gweru", "Victoria Falls"]
@@ -355,6 +355,8 @@ def admin_create_listing(data: Dict, reviewer: str = "admin") -> Dict:
355
  else [a.strip() for a in (data.get("amenities") or "").split(",") if a.strip()],
356
  "media": data.get("media", []) if isinstance(data.get("media"), list)
357
  else [u.strip() for u in (data.get("media") or "").split(",") if u.strip()],
 
 
358
  "status": "active", "verification": "verified",
359
  "created_by": reviewer, "created_at": _now(), "updated_at": _now(),
360
  }
@@ -396,7 +398,7 @@ def match_score(item: Dict, crit: Dict) -> Tuple[int, List[str]]:
396
  if want_type and want_type in (item.get("type") or "").lower():
397
  score += 30
398
  reasons.append("correct type")
399
- suburb = (crit.get("suburb") or "").lower()
400
  if suburb and suburb in (item.get("suburb") or "").lower():
401
  score += 25
402
  reasons.append("correct suburb")
@@ -423,19 +425,26 @@ def match_score(item: Dict, crit: Dict) -> Tuple[int, List[str]]:
423
  def search_listings(crit: Dict, limit: int = 5) -> List[Dict]:
424
  items = get_listings(active_only=True)
425
  want_type = (crit.get("type") or "").lower()
426
- suburb = (crit.get("suburb") or "").lower()
427
  try:
428
  max_b = float(crit.get("max_budget") or 0)
429
  except Exception:
430
  max_b = 0
 
431
  ranked = []
432
  for it in items:
433
  if want_type and want_type not in (it.get("type") or "").lower():
434
  continue
435
- if suburb and suburb not in (it.get("suburb") or "").lower():
436
  continue
437
  if max_b and (it.get("rent_cents") or 0) / 100 > max_b:
438
  continue
 
 
 
 
 
 
439
  s, reasons = match_score(it, crit)
440
  ranked.append({**it, "_score": s, "_reasons": reasons})
441
  ranked.sort(key=lambda x: (-x["_score"], x.get("rent_cents", 0)))
@@ -451,7 +460,10 @@ def format_preview(item: Dict, idx: int = 0, score: Optional[int] = None,
451
  f" {', '.join(item.get('amenities', [])[:5])}"]
452
  if score is not None:
453
  lines.append(f" _{score}% match — {', '.join((reasons or [])[:4])}_")
454
- lines.append(" _Full photos locked. Unlock with Rental Pass._")
 
 
 
455
  return "\n".join(lines)
456
 
457
 
@@ -463,7 +475,10 @@ def format_full(item: Dict) -> str:
463
  f"{item.get('description')}",
464
  f"Amenities: {', '.join(item.get('amenities', []))}",
465
  f"Verified: {'yes' if item.get('verification') == 'verified' else 'pending'}",
466
- f"Photos: {len(item.get('media', []))} unlocked"]
 
 
 
467
  return "\n".join(lines)
468
 
469
 
@@ -516,8 +531,7 @@ def ecocash_instructions(pay: Dict) -> str:
516
  f"EcoCash (spoofed MVP):\n"
517
  f"Amount: *{cents_fmt(pay['amount_cents'])}*\n"
518
  f"Reference: *{pay['provider_ref']}*\n"
519
- f"Dial *151*2*1*Merchant*Amount*Ref# on your EcoCash line.\n"
520
- f"_Demo mode: tap 1 (I have paid). Wiring comes later._"
521
  )
522
 
523
 
@@ -895,18 +909,20 @@ def parse_budget(text: str) -> Tuple[Optional[float], Optional[float]]:
895
 
896
  SUBURBS = ["westgate", "avondale", "borrowdale", "mbare", "kuwadzana", "glen view",
897
  "hatcliffe", "epworth", "ruwa", "chitungwiza", "bulawayo", "cbd", "town",
898
- "greendale", "mt pleasant", "chisipite", "highlands", "southerton"]
899
 
900
  TYPE_KEYS = [
901
  ("2 bedroom", ["2 bedroom", "2 bedrooms", "2 bed", "two bedroom", "2bed"]),
902
  ("1 bedroom", ["1 bedroom", "1 bedrooms", "1 bed", "one bedroom", "1bed"]),
903
  ("cottage", ["cottage", "cottages"]),
 
 
904
  ("single room", ["single room", "single rooms", "room", "rooms", "bedsitter"]),
905
  ]
906
 
907
  AMEN_KEYS = ["solar", "borehole", "parking", "furnished", "walled", "tiled",
908
  "secure", "security", "water", "electricity", "wifi", "fitted kitchen",
909
- "pet friendly", "pets"]
910
 
911
 
912
  def parse_search(text: str) -> Dict:
@@ -918,7 +934,8 @@ def parse_search(text: str) -> Dict:
918
  t = (text or "").lower()
919
  crit: Dict = {"type": "", "suburb": "", "max_budget": 0, "amenities": []}
920
 
921
- for canon, keys in TYPE_KEYS:
 
922
  if any(k in t for k in keys):
923
  crit["type"] = canon
924
  break
@@ -940,6 +957,7 @@ def parse_search(text: str) -> Dict:
940
  else:
941
  rng = re.search(r"(\d[\d,]*)\s*[-\u2013]\s*(\d[\d,]*)", t)
942
  if rng:
 
943
  crit["max_budget"] = float(rng.group(2).replace(",", ""))
944
  else:
945
  nums = re.findall(r"\$\s*(\d[\d,]*)", t)
 
79
  LANDLORD_SHARE_CENTS = 200 # $2
80
  PLATFORM_SHARE_CENTS = 300 # $3
81
 
82
+ PROPERTY_TYPES = ["single room", "cottage", "1 bedroom", "2 bedroom", "house"]
83
  AMENITIES = ["solar", "borehole", "tiled floors", "fitted kitchen", "parking",
84
  "security", "water", "electricity", "furnished", "wifi"]
85
  CITIES = ["Harare", "Bulawayo", "Mutare", "Gweru", "Victoria Falls"]
 
355
  else [a.strip() for a in (data.get("amenities") or "").split(",") if a.strip()],
356
  "media": data.get("media", []) if isinstance(data.get("media"), list)
357
  else [u.strip() for u in (data.get("media") or "").split(",") if u.strip()],
358
+ "video_url": data.get("video_url", ""),
359
+ "rental_requirements": data.get("rental_requirements", ""),
360
  "status": "active", "verification": "verified",
361
  "created_by": reviewer, "created_at": _now(), "updated_at": _now(),
362
  }
 
398
  if want_type and want_type in (item.get("type") or "").lower():
399
  score += 30
400
  reasons.append("correct type")
401
+ suburb = (crit.get("suburb") or "").lower().replace("mt pleasant", "mount pleasant")
402
  if suburb and suburb in (item.get("suburb") or "").lower():
403
  score += 25
404
  reasons.append("correct suburb")
 
425
  def search_listings(crit: Dict, limit: int = 5) -> List[Dict]:
426
  items = get_listings(active_only=True)
427
  want_type = (crit.get("type") or "").lower()
428
+ suburb = (crit.get("suburb") or "").lower().replace("mt pleasant", "mount pleasant")
429
  try:
430
  max_b = float(crit.get("max_budget") or 0)
431
  except Exception:
432
  max_b = 0
433
+ min_b = float(crit.get("min_budget") or 0)
434
  ranked = []
435
  for it in items:
436
  if want_type and want_type not in (it.get("type") or "").lower():
437
  continue
438
+ if suburb and suburb not in (it.get("suburb") or "").lower().replace("mt pleasant", "mount pleasant"):
439
  continue
440
  if max_b and (it.get("rent_cents") or 0) / 100 > max_b:
441
  continue
442
+ if min_b and (it.get("rent_cents") or 0) / 100 < min_b:
443
+ continue
444
+ wants = [a.lower() for a in crit.get("amenities", [])]
445
+ haves = [a.lower() for a in it.get("amenities", [])]
446
+ if any(not any(a in h for h in haves) for a in wants):
447
+ continue
448
  s, reasons = match_score(it, crit)
449
  ranked.append({**it, "_score": s, "_reasons": reasons})
450
  ranked.sort(key=lambda x: (-x["_score"], x.get("rent_cents", 0)))
 
460
  f" {', '.join(item.get('amenities', [])[:5])}"]
461
  if score is not None:
462
  lines.append(f" _{score}% match — {', '.join((reasons or [])[:4])}_")
463
+ lines.append(" Property: " + ("Verified" if item.get("verification") == "verified" else "Verification pending"))
464
+ provider = get_profile(item.get("provider_phone", ""))
465
+ lines.append(" Provider: " + ("Verified" if (provider.get("landlord") or {}).get("verification") == "verified" else "Verification pending"))
466
+ lines.append(" _Free preview. Full details available with a $10 Rental Pass._")
467
  return "\n".join(lines)
468
 
469
 
 
475
  f"{item.get('description')}",
476
  f"Amenities: {', '.join(item.get('amenities', []))}",
477
  f"Verified: {'yes' if item.get('verification') == 'verified' else 'pending'}",
478
+ f"Photos: {len(item.get('media', []))} unlocked",
479
+ f"Rental requirements: {item.get('rental_requirements') or item.get('requirements') or 'Ask RentNow for the provider requirements.'}",
480
+ f"Provider: {item.get('provider_name') or 'RentNow coordinates with the provider'}",
481
+ "Provider contact details are shared after the $5 viewing payment."]
482
  return "\n".join(lines)
483
 
484
 
 
531
  f"EcoCash (spoofed MVP):\n"
532
  f"Amount: *{cents_fmt(pay['amount_cents'])}*\n"
533
  f"Reference: *{pay['provider_ref']}*\n"
534
+ "Demo only. No money is charged. Reply 1 to simulate a successful EcoCash payment."
 
535
  )
536
 
537
 
 
909
 
910
  SUBURBS = ["westgate", "avondale", "borrowdale", "mbare", "kuwadzana", "glen view",
911
  "hatcliffe", "epworth", "ruwa", "chitungwiza", "bulawayo", "cbd", "town",
912
+ "greendale", "mount pleasant", "mt pleasant", "chisipite", "highlands", "southerton"]
913
 
914
  TYPE_KEYS = [
915
  ("2 bedroom", ["2 bedroom", "2 bedrooms", "2 bed", "two bedroom", "2bed"]),
916
  ("1 bedroom", ["1 bedroom", "1 bedrooms", "1 bed", "one bedroom", "1bed"]),
917
  ("cottage", ["cottage", "cottages"]),
918
+ ("house", ["house", "townhouse"]),
919
+ ("cottage", ["garden flat"]),
920
  ("single room", ["single room", "single rooms", "room", "rooms", "bedsitter"]),
921
  ]
922
 
923
  AMEN_KEYS = ["solar", "borehole", "parking", "furnished", "walled", "tiled",
924
  "secure", "security", "water", "electricity", "wifi", "fitted kitchen",
925
+ "pet friendly", "pets", "ensuite", "gated community"]
926
 
927
 
928
  def parse_search(text: str) -> Dict:
 
934
  t = (text or "").lower()
935
  crit: Dict = {"type": "", "suburb": "", "max_budget": 0, "amenities": []}
936
 
937
+ t = t.replace("mt pleasant", "mount pleasant")
938
+ for canon, keys in sorted(TYPE_KEYS, key=lambda pair: pair[0] != "cottage"):
939
  if any(k in t for k in keys):
940
  crit["type"] = canon
941
  break
 
957
  else:
958
  rng = re.search(r"(\d[\d,]*)\s*[-\u2013]\s*(\d[\d,]*)", t)
959
  if rng:
960
+ crit["min_budget"] = float(rng.group(1).replace(",", ""))
961
  crit["max_budget"] = float(rng.group(2).replace(",", ""))
962
  else:
963
  nums = re.findall(r"\$\s*(\d[\d,]*)", t)
whatsapp_client.py CHANGED
@@ -152,7 +152,7 @@ def send_audio_message(recipient_id: str, audio_url: str) -> bool:
152
 
153
 
154
  def send_buttons_as_text(recipient_id: str, body: str, options: list) -> bool:
155
- safe = [str(o) for o in (options or []) if str(o).strip()][:3]
156
  lines = [body, ""]
157
  for i, opt in enumerate(safe, 1):
158
  lines.append(f"{i}. {opt}")
 
152
 
153
 
154
  def send_buttons_as_text(recipient_id: str, body: str, options: list) -> bool:
155
+ safe = [str(o) for o in (options or []) if str(o).strip()]
156
  lines = [body, ""]
157
  for i, opt in enumerate(safe, 1):
158
  lines.append(f"{i}. {opt}")