diff --git a/backend/app/api/pools.py b/backend/app/api/pools.py index ff493ef..f3db21e 100644 --- a/backend/app/api/pools.py +++ b/backend/app/api/pools.py @@ -5,7 +5,7 @@ import re from typing import Optional from app.core.database import get_db -from app.core.security import get_current_user, map_doc, map_docs +from app.core.security import get_current_user, get_optional_current_user, map_doc, map_docs router = APIRouter() @@ -16,6 +16,11 @@ MAX_NAME_CHARS = 80 MAX_DESCRIPTION_CHARS = 200 MAX_URL_CHARS = 2048 +MAX_ITEM_QUANTITY = 50 +PAYMENT_AMOUNT_TOLERANCE_PAISE = 500 +PAYMENT_OVERDUE_HOURS = 24 +PAYMENT_ESCALATED_HOURS = 48 +PAYMENT_ACTIVE_STATUSES = {"pending", "needs_review", "verified"} class PoolReq(BaseModel): wing_label: str @@ -51,13 +56,20 @@ class PoolItemReq(BaseModel): added_by_name: str item_description: str estimated_price: int + quantity: int = 1 + unit_price: Optional[int] = None product_url: Optional[str] = None class PoolItemUpdateReq(BaseModel): is_purchased: Optional[bool] = None estimated_price: Optional[int] = None + quantity: Optional[int] = None + unit_price: Optional[int] = None item_description: Optional[str] = None product_url: Optional[str] = None + cart_status: Optional[str] = None + substitute_note: Optional[str] = None + cart_status_reason: Optional[str] = None def utcnow() -> datetime.datetime: @@ -79,10 +91,222 @@ def clean_text(value: Optional[str], field_name: str, max_chars: int = MAX_NAME_ return cleaned +def clean_optional_text(value: Optional[str], field_name: str, max_chars: int = MAX_DESCRIPTION_CHARS) -> Optional[str]: + if value is None: + return None + cleaned = " ".join(value.strip().split()) + if not cleaned: + return None + if len(cleaned) > max_chars: + raise HTTPException(status_code=400, detail=f"{field_name} is too long") + return cleaned + + def name_key(value: Optional[str]) -> str: return " ".join((value or "").strip().split()).casefold() +def parse_datetime(value) -> Optional[datetime.datetime]: + if not value: + return None + if isinstance(value, datetime.datetime): + return to_utc_naive(value) + try: + return to_utc_naive(datetime.datetime.fromisoformat(str(value).replace("Z", "+00:00"))) + except (TypeError, ValueError): + return None + + +def hours_since(value, now: Optional[datetime.datetime] = None) -> Optional[float]: + parsed = parse_datetime(value) + if not parsed: + return None + return max(0.0, ((now or utcnow()) - parsed).total_seconds() / 3600.0) + + +def is_host_name(pool: dict, participant_name: Optional[str]) -> bool: + participant_key = name_key(participant_name) + return participant_key in {"you", "host"} or participant_key == name_key(pool.get("created_by_name")) + + +def build_host_android_status(host_profile: Optional[dict]) -> dict: + if not host_profile or not host_profile.get("companion_paired"): + return { + "state": "not_paired", + "label": "Manual verification", + "detail": "Host has not paired the Android connector, so incoming split credits cannot auto-verify.", + "can_auto_verify": False, + } + + if host_profile.get("companion_sync_enabled") is False: + return { + "state": "paused", + "label": "Auto-verification paused", + "detail": "Host Android connector is paired but sync is paused.", + "can_auto_verify": False, + } + + last_sync_hours = hours_since(host_profile.get("companion_last_sync")) + if last_sync_hours is None: + return { + "state": "paired_waiting", + "label": "Waiting for first sync", + "detail": "Host Android connector is paired, but no payment alert has synced yet.", + "can_auto_verify": False, + } + + if last_sync_hours > 24: + return { + "state": "stale", + "label": "Sync stale", + "detail": "Host Android connector has not synced in the last 24 hours.", + "can_auto_verify": False, + "last_sync_hours": round(last_sync_hours, 1), + } + + return { + "state": "active", + "label": "Auto-verification ready", + "detail": "Host Android connector is active and can match incoming split credits.", + "can_auto_verify": True, + "last_sync_hours": round(last_sync_hours, 1), + } + + +def build_payment_state( + pool: dict, + participant_name: str, + payment: Optional[dict], + expected_amount: int, + now: Optional[datetime.datetime] = None, +) -> dict: + now = now or utcnow() + if is_host_name(pool, participant_name): + return { + "status": "host", + "label": "Host share", + "tone": "success", + "detail": "Host share is settled by the checkout payment.", + "is_overdue": False, + "overdue_hours": 0, + } + + completed_at = parse_datetime(pool.get("completed_at")) + elapsed_hours = max(0.0, (now - completed_at).total_seconds() / 3600.0) if completed_at else 0.0 + status = (payment or {}).get("status") or "unpaid" + + if status == "verified": + source = (payment or {}).get("verification_source") or (payment or {}).get("settlement_mode") or "host_verified" + if source == "auto_host_credit": + detail = "Matched to an incoming host credit from Android sync." + elif source == "host_manual_review": + detail = "Host manually confirmed the incoming credit." + elif source == "host_settle_in_kind" or (payment or {}).get("settlement_mode") == "settle_in_kind": + detail = "Host settled this split outside cash transfer." + else: + detail = "Payment has been verified." + return { + "status": "verified", + "label": "Verified", + "tone": "success", + "detail": detail, + "is_overdue": False, + "overdue_hours": 0, + } + + if status == "pending": + return { + "status": "pending", + "label": "UTR pending", + "tone": "warning", + "detail": "Roommate submitted a UTR; waiting for host credit match or host review.", + "is_overdue": elapsed_hours >= PAYMENT_OVERDUE_HOURS, + "overdue_hours": round(elapsed_hours, 1) if elapsed_hours >= PAYMENT_OVERDUE_HOURS else 0, + } + + if status == "needs_review": + return { + "status": "needs_review", + "label": "Needs review", + "tone": "warning", + "detail": (payment or {}).get("review_reason") or "A possible payment matched, but PocketBuddy needs host confirmation before trusting it.", + "is_overdue": elapsed_hours >= PAYMENT_OVERDUE_HOURS, + "overdue_hours": round(elapsed_hours, 1) if elapsed_hours >= PAYMENT_OVERDUE_HOURS else 0, + } + + if status == "rejected": + return { + "status": "rejected", + "label": "Rejected", + "tone": "danger", + "detail": (payment or {}).get("rejection_reason") or "Host rejected this reference because no matching credit was found.", + "is_overdue": True, + "overdue_hours": round(elapsed_hours, 1), + } + + label = "Overdue" if elapsed_hours >= PAYMENT_OVERDUE_HOURS else "Unpaid" + detail = ( + f"No payment recorded for this Rs {expected_amount / 100:.2f} split after checkout." + if elapsed_hours >= PAYMENT_OVERDUE_HOURS + else f"Waiting for Rs {expected_amount / 100:.2f} split settlement." + ) + return { + "status": "unpaid", + "label": label, + "tone": "danger" if elapsed_hours >= PAYMENT_ESCALATED_HOURS else "warning", + "detail": detail, + "is_overdue": elapsed_hours >= PAYMENT_OVERDUE_HOURS, + "overdue_hours": round(elapsed_hours, 1) if elapsed_hours >= PAYMENT_OVERDUE_HOURS else 0, + } + + +def build_settlement_summary(split_breakdown: dict, host_android_status: dict) -> dict: + roommate_rows = [ + row for row in split_breakdown.values() + if row.get("payment_status") != "host" + ] + counts = { + "total_roommates": len(roommate_rows), + "verified": 0, + "pending": 0, + "needs_review": 0, + "rejected": 0, + "unpaid": 0, + "overdue": 0, + } + outstanding_total = 0 + review_total = 0 + + for row in roommate_rows: + status = row.get("payment_status") or "unpaid" + if status not in counts: + status = "unpaid" + counts[status] += 1 + if row.get("is_overdue"): + counts["overdue"] += 1 + if status != "verified": + outstanding_total += int(row.get("total") or 0) + if status in {"pending", "needs_review", "rejected"}: + review_total += int(row.get("total") or 0) + + if counts["needs_review"] or counts["pending"]: + next_action = "Review submitted UTRs against host credits." + elif counts["overdue"]: + next_action = "Nudge overdue roommates or settle in kind after confirming with them." + elif outstanding_total: + next_action = "Collect unpaid splits from roommates." + else: + next_action = "All roommate splits are settled." + + return { + **counts, + "outstanding_total": outstanding_total, + "review_total": review_total, + "next_action": next_action, + "host_android_status": host_android_status, + } + + def validate_paise_amount( value: int, field_name: str, @@ -126,6 +350,46 @@ def validate_product_url(value: Optional[str]) -> Optional[str]: return url +def validate_item_quantity(value: int) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise HTTPException(status_code=400, detail="Quantity must be an integer") + if value < 1 or value > MAX_ITEM_QUANTITY: + raise HTTPException(status_code=400, detail=f"Quantity must be between 1 and {MAX_ITEM_QUANTITY}") + return value + + +def validate_cart_status(value: Optional[str]) -> Optional[str]: + if value is None: + return None + status = value.strip().lower() + allowed = {"pending", "added", "substituted", "unavailable", "skipped"} + if status not in allowed: + raise HTTPException(status_code=400, detail="Invalid cart status") + return status + + +def default_cart_status_reason(status: Optional[str]) -> Optional[str]: + if status == "added": + return "Host added this product to the cart." + if status == "substituted": + return "Host added a substitute for this product." + if status == "unavailable": + return "This product was unavailable in the app." + if status == "skipped": + return "Host skipped this item before checkout." + if status == "pending": + return "Host moved this item back to the active cart." + return None + + +def item_edit_reason(is_host: bool, is_host_item: bool) -> Optional[str]: + if is_host and not is_host_item: + return "Host updated this request. Review the latest quantity, price, or link before checkout." + if not is_host: + return "Roommate updated this request. Host should review the latest details." + return None + + def parse_expires_at(value: str) -> datetime.datetime: try: parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) @@ -176,6 +440,7 @@ async def get_roommate_reliability(db, wing_label: str) -> dict: "total_pools": 0, "verified_pools": 0, "pending_pools": 0, + "late_pools": 0, "total_repayment_time_sec": 0.0, }) stats["total_pools"] += 1 @@ -185,12 +450,14 @@ async def get_roommate_reliability(db, wing_label: str) -> dict: if pm and pm.get("status") == "verified": stats["verified_pools"] += 1 - pay_time_str = pm.get("submitted_at") or pm.get("verified_at") + pay_time_str = pm.get("verified_at") or pm.get("submitted_at") if pay_time_str: try: pay_time = datetime.datetime.fromisoformat(pay_time_str.replace("Z", "+00:00")).replace(tzinfo=None) diff_sec = max(0.0, (pay_time - completed_at).total_seconds()) stats["total_repayment_time_sec"] += diff_sec + if diff_sec > PAYMENT_OVERDUE_HOURS * 3600: + stats["late_pools"] += 1 except: pass else: @@ -218,7 +485,7 @@ async def get_roommate_reliability(db, wing_label: str) -> dict: pm = next((p for p in payments if name_key(p["name"]) == r_key), None) if pm and pm.get("status") == "verified": - pay_time_str = pm.get("submitted_at") or pm.get("verified_at") + pay_time_str = pm.get("verified_at") or pm.get("submitted_at") if pay_time_str: try: pay_time = datetime.datetime.fromisoformat(pay_time_str.replace("Z", "+00:00")).replace(tzinfo=None) @@ -250,21 +517,31 @@ async def get_roommate_reliability(db, wing_label: str) -> dict: if avg_score >= 95: label = "Instant payer" color = "green" + explanation = "Usually settles within an hour after checkout." elif avg_score >= 85: label = "Pays in hours" color = "blue" + explanation = "Usually settles the same day after checkout." elif avg_score >= 70: label = "Usually next day" color = "yellow" + explanation = "Has paid before, but may need a reminder." else: label = "Needs reminder" color = "red" + explanation = "Often remains unpaid or takes more than a day to settle." reliability[stats["name"]] = { "name": stats["name"], "score": avg_score, "label": label, "color": color, + "explanation": explanation, + "signals": { + "completed_splits": stats["verified_pools"], + "unsettled_splits": stats["pending_pools"], + "late_splits": stats["late_pools"], + }, "total_pools": total_pools, "pending_pools": stats["pending_pools"], "avg_repayment_time_hours": round((stats["total_repayment_time_sec"] / stats["verified_pools"] / 3600.0) if stats["verified_pools"] > 0 else 0.0, 1) @@ -273,14 +550,155 @@ async def get_roommate_reliability(db, wing_label: str) -> dict: return reliability +def public_host_android_status() -> dict: + return { + "state": "private", + "label": "Host verification private", + "detail": "Host device sync status is visible only to the pool host.", + "can_auto_verify": False, + } + + +def pool_participant_key_set(items: list[dict], user_id: Optional[str]) -> set[str]: + if not user_id: + return set() + return { + name_key(item.get("added_by_name")) + for item in items + if item.get("added_by_user_id") == user_id and item.get("added_by_name") + } + + +async def resolve_pool_participant_user(db, pool: dict, items: list[dict], participant_name: str) -> Optional[dict]: + participant_key = name_key(participant_name) + user_ids = { + item.get("added_by_user_id") + for item in items + if name_key(item.get("added_by_name")) == participant_key and item.get("added_by_user_id") + } + if len(user_ids) == 1: + return await db.users.find_one({"_id": next(iter(user_ids))}) + + wing_label = pool.get("wing_label") + if not wing_label: + return None + + profiles_cursor = db.profiles.find({"wing_label": wing_label}) + profiles = await profiles_cursor.to_list(length=100) + wing_user_ids = [profile.get("_id") for profile in profiles if profile.get("_id")] + if not wing_user_ids: + return None + + users_cursor = db.users.find({"_id": {"$in": wing_user_ids}}) + users = await users_cursor.to_list(length=100) + exact_matches = [ + user for user in users + if name_key(user.get("full_name")) == participant_key + ] + return exact_matches[0] if len(exact_matches) == 1 else None + + +def sanitize_split_row(row: dict, *, can_view_contact: bool, can_view_settlement: bool) -> dict: + safe_row = dict(row) + if not can_view_contact: + safe_row["email"] = "" + if not can_view_settlement: + safe_row["utr"] = "" + safe_row["settlement_mode"] = None + safe_row["confidence"] = None + safe_row["verification_source"] = None + safe_row["review_reason"] = None + safe_row["matched_amount"] = None + return safe_row + + +def sanitize_payment_list(pool: dict, viewer_participant_keys: set[str], is_host_viewer: bool) -> list[dict]: + payments = pool.get("payments") or [] + if is_host_viewer: + return payments + if not viewer_participant_keys: + return [] + return [ + payment for payment in payments + if name_key(payment.get("name")) in viewer_participant_keys + ] + + +def sanitize_pool_item_for_viewer(item: dict, current_user_id: Optional[str], host_id: Optional[str]) -> dict: + safe_item = dict(item) + can_view_internal = bool( + current_user_id + and ( + current_user_id == host_id + or current_user_id == item.get("added_by_user_id") + ) + ) + if not can_view_internal: + for field in ("added_by_user_id", "item_updated_by", "cart_status_updated_by"): + safe_item.pop(field, None) + return safe_item + + +async def resolve_payment_claim_roommate_name( + db, + pool: dict, + items: list[dict], + user_id: Optional[str], + requested_roommate_name: str, +) -> str: + if not user_id: + raise HTTPException(status_code=401, detail="Log in before submitting a payment reference") + if pool.get("host_id") == user_id: + raise HTTPException(status_code=400, detail="Host should verify payments from the collection queue") + + active_items = [item for item in items if item.get("is_purchased", True)] + requested_key = name_key(requested_roommate_name) + participant_names = [item.get("added_by_name") for item in active_items if item.get("added_by_name")] + exact_requested_name = next( + (name for name in participant_names if name_key(name) == requested_key), + None, + ) + if not exact_requested_name: + raise HTTPException(status_code=400, detail="Roommate is not part of this pool") + if is_host_name(pool, exact_requested_name): + raise HTTPException(status_code=400, detail="Host share does not need roommate payment confirmation") + + owned_names = [ + item.get("added_by_name") + for item in active_items + if item.get("added_by_user_id") == user_id and item.get("added_by_name") + ] + if owned_names: + exact_owned_name = next( + (name for name in owned_names if name_key(name) == requested_key), + None, + ) + if exact_owned_name: + return exact_owned_name + raise HTTPException(status_code=403, detail="You can submit a payment reference only for your own split") + + user_doc = await db.users.find_one({"_id": user_id}) + user_name_key = name_key((user_doc or {}).get("full_name")) + if user_name_key and user_name_key == requested_key: + return exact_requested_name + + raise HTTPException(status_code=403, detail="You can submit a payment reference only for your own split") + + async def enrich_pool_document(db, p: dict, current_user_id: Optional[str] = None) -> dict: pool_id = p["_id"] if "_id" in p else p.get("id") host_id = p.get("host_id") + is_host_viewer = bool(current_user_id and host_id and current_user_id == host_id) host_user = await db.users.find_one({"_id": host_id}) if host_id else None - p["host_phone"] = host_user.get("phone_number", "") if host_user else "" + host_profile = await db.profiles.find_one({"_id": host_id}) if host_id else None + host_android_status = build_host_android_status(host_profile) + p["host_android_status"] = host_android_status items_cursor = db.cart_pool_items.find({"pool_id": pool_id}) items = await items_cursor.to_list(length=200) + viewer_participant_keys = pool_participant_key_set(items, current_user_id) + can_view_host_contact = is_host_viewer or bool(viewer_participant_keys) + p["host_phone"] = host_user.get("phone_number", "") if host_user and can_view_host_contact else "" grouped = {} for item in items: @@ -297,24 +715,41 @@ async def enrich_pool_document(db, p: dict, current_user_id: Optional[str] = Non for name in active_participants: p_items_total = sum(it["estimated_price"] for it in grouped[name] if it.get("is_purchased", True)) - payment = next((pay for pay in p.get("payments", []) if pay["name"].lower() == name.lower()), None) + payment = next((pay for pay in p.get("payments", []) if name_key(pay.get("name")) == name_key(name)), None) is_host = (name.lower() == "you" or name_key(name) == name_key(p.get("created_by_name"))) + expected_total = p_items_total + overhead_share + payment_state = build_payment_state(p, name, payment, expected_total) - usr = await db.users.find_one({"full_name": {"$regex": f"^{re.escape(name)}$", "$options": "i"}}) + usr = await resolve_pool_participant_user(db, p, grouped[name], name) r_email = usr.get("email", "") if usr else "" + can_view_own_settlement = name_key(name) in viewer_participant_keys - split_breakdown[name] = { + row = { "name": name, "email": r_email, "items_total": p_items_total, "share": overhead_share, - "total": p_items_total + overhead_share, + "total": expected_total, "paid": True if is_host else (payment["status"] == "verified" if payment else False), - "payment_status": "host" if is_host else (payment["status"] if payment else "unpaid"), + "payment_status": payment_state["status"], + "payment_label": payment_state["label"], + "payment_tone": payment_state["tone"], + "payment_detail": payment_state["detail"], + "is_overdue": payment_state["is_overdue"], + "overdue_hours": payment_state["overdue_hours"], "utr": payment["utr"] if payment else "", "settlement_mode": payment.get("settlement_mode") if payment else None, - "confidence": payment.get("confidence") if payment else None + "confidence": payment.get("confidence") if payment else None, + "verification_source": payment.get("verification_source") if payment else None, + "review_reason": payment.get("review_reason") if payment else None, + "expected_amount": payment.get("expected_amount", expected_total) if payment else expected_total, + "matched_amount": payment.get("matched_amount") if payment else None, } + split_breakdown[name] = sanitize_split_row( + row, + can_view_contact=is_host_viewer, + can_view_settlement=is_host_viewer or can_view_own_settlement, + ) else: num_people = len(participants) delivery_per_person = int(p.get("delivery_fee", 0) / num_people) if num_people > 0 else p.get("delivery_fee", 0) @@ -323,10 +758,10 @@ async def enrich_pool_document(db, p: dict, current_user_id: Optional[str] = Non p_items_total = sum(it["estimated_price"] for it in grouped[name]) is_host = (name.lower() == "you" or name_key(name) == name_key(p.get("created_by_name"))) - usr = await db.users.find_one({"full_name": {"$regex": f"^{re.escape(name)}$", "$options": "i"}}) + usr = await resolve_pool_participant_user(db, p, grouped[name], name) r_email = usr.get("email", "") if usr else "" - split_breakdown[name] = { + row = { "name": name, "email": r_email, "items_total": p_items_total, @@ -336,15 +771,40 @@ async def enrich_pool_document(db, p: dict, current_user_id: Optional[str] = Non "payment_status": "host" if is_host else "unpaid", "utr": "" } + split_breakdown[name] = sanitize_split_row( + row, + can_view_contact=is_host_viewer, + can_view_settlement=is_host_viewer or name_key(name) in viewer_participant_keys, + ) p["split_breakdown"] = split_breakdown - reliability_map = await get_roommate_reliability(db, p.get("wing_label", "")) - p["reliability_scores"] = {name: reliability_map.get(name, {"score": 90, "label": "New roommate", "color": "blue"}) for name in participants} + if is_host_viewer: + reliability_map = await get_roommate_reliability(db, p.get("wing_label", "")) + p["reliability_scores"] = { + name: reliability_map.get(name, { + "score": 60, + "label": "New roommate", + "color": "blue", + "explanation": "No completed split history yet; this is a neutral starting score.", + "signals": { + "completed_splits": 0, + "unsettled_splits": 0, + "late_splits": 0, + }, + }) + for name in participants + } + p["settlement_summary"] = build_settlement_summary(split_breakdown, host_android_status) + else: + p["host_android_status"] = public_host_android_status() + p["reliability_scores"] = {} + p["settlement_summary"] = {} + p["payments"] = sanitize_payment_list(p, viewer_participant_keys, is_host_viewer) wing_label = p.get("wing_label") wing_members = [] - if wing_label: + if wing_label and is_host_viewer: profiles_cursor = db.profiles.find({"wing_label": wing_label}) profiles = await profiles_cursor.to_list(length=100) uids = [prof["_id"] for prof in profiles] @@ -553,7 +1013,7 @@ async def create_cart_pool(req: PoolReq, user_id: str = Depends(get_current_user @router.get("/{pool_id}") -async def get_pool(pool_id: str): +async def get_pool(pool_id: str, user_id: Optional[str] = Depends(get_optional_current_user)): db = get_db() pool = await db.cart_pools.find_one({"_id": pool_id}) if not pool: @@ -566,7 +1026,7 @@ async def get_pool(pool_id: str): for k, v in list(pool.items()): pool[k] = _serialize_value(v) - pool = await enrich_pool_document(db, pool) + pool = await enrich_pool_document(db, pool, user_id) return pool @router.put("/{pool_id}") @@ -575,6 +1035,7 @@ async def update_pool(pool_id: str, req: PoolUpdateReq, user_id: str = Depends(g pool = await db.cart_pools.find_one({"_id": pool_id}) if not pool: raise HTTPException(status_code=404, detail="Pool not found") + pool = await expire_pool_if_needed(db, pool) # Security Guard: Only host can finalize checkout or cancel if pool.get("host_id") != user_id: @@ -588,6 +1049,8 @@ async def update_pool(pool_id: str, req: PoolUpdateReq, user_id: str = Depends(g updates["status"] = updates["status"].strip().lower() if updates["status"] not in ALLOWED_POOL_STATUSES: raise HTTPException(status_code=400, detail="Invalid pool status") + if updates["status"] in {"completed", "cancelled"} and pool.get("status") != "open": + raise HTTPException(status_code=400, detail="Only open pools can be finalized or cancelled") if "upi_id" in updates: updates["upi_id"] = validate_upi_id(updates["upi_id"]) if "final_overhead" in updates: @@ -600,6 +1063,16 @@ async def update_pool(pool_id: str, req: PoolUpdateReq, user_id: str = Depends(g ) if "created_by_name" in updates: updates["created_by_name"] = clean_text(updates["created_by_name"], "Host name") + if "checkout_notes" in updates: + updates["checkout_notes"] = clean_optional_text(updates["checkout_notes"], "Checkout notes", max_chars=MAX_DESCRIPTION_CHARS) + if "cancellation_reason" in updates: + updates["cancellation_reason"] = clean_text( + updates["cancellation_reason"], "Cancellation reason", max_chars=MAX_DESCRIPTION_CHARS + ) + if updates.get("status") == "cancelled" and not updates.get("cancellation_reason"): + raise HTTPException(status_code=400, detail="Cancellation reason is required") + if updates.get("status") == "completed" and not (updates.get("upi_id") or pool.get("upi_id")): + raise HTTPException(status_code=400, detail="Host UPI address is required for manual split collection") # Check if transitioning to 'completed' to auto-log host split transaction if updates.get("status") == "completed" and pool.get("status") != "completed": @@ -650,11 +1123,11 @@ async def update_pool(pool_id: str, req: PoolUpdateReq, user_id: str = Depends(g from app.core.security import _serialize_value for k, v in list(updated_pool.items()): updated_pool[k] = _serialize_value(v) - updated_pool = await enrich_pool_document(db, updated_pool) + updated_pool = await enrich_pool_document(db, updated_pool, user_id) return updated_pool @router.post("/{pool_id}/payment-confirm") -async def payment_confirm(pool_id: str, req: PaymentConfirmReq): +async def payment_confirm(pool_id: str, req: PaymentConfirmReq, user_id: Optional[str] = Depends(get_optional_current_user)): db = get_db() pool = await db.cart_pools.find_one({"_id": pool_id}) if not pool: @@ -669,21 +1142,71 @@ async def payment_confirm(pool_id: str, req: PaymentConfirmReq): items_cursor = db.cart_pool_items.find({"pool_id": pool_id}) items = await items_cursor.to_list(length=500) - participant_keys = {name_key(item.get("added_by_name")) for item in items if item.get("is_purchased", True)} - if participant_keys and name_key(roommate_name) not in participant_keys: - raise HTTPException(status_code=400, detail="Roommate is not part of this pool") + exact_roommate_name = await resolve_payment_claim_roommate_name( + db, + pool, + items, + user_id, + roommate_name, + ) - # Resolve exact casing of the roommate's name from active pool items to avoid case mismatches in db query - exact_roommate_name = next( - (item["added_by_name"] for item in items if name_key(item.get("added_by_name")) == name_key(roommate_name)), - roommate_name + payments = pool.get("payments", []) + existing_roommate_payment = next( + ( + payment for payment in payments + if name_key(payment.get("name")) == name_key(exact_roommate_name) + ), + None, ) + if existing_roommate_payment and existing_roommate_payment.get("status") == "verified": + raise HTTPException(status_code=409, detail="This split is already settled") + + same_pool_duplicate = next( + ( + payment for payment in payments + if payment.get("utr") == utr + and name_key(payment.get("name")) != name_key(exact_roommate_name) + and payment.get("status") in PAYMENT_ACTIVE_STATUSES + ), + None, + ) + if same_pool_duplicate: + raise HTTPException(status_code=409, detail="This UTR is already attached to another roommate split") + + duplicate_pool = await db.cart_pools.find_one({ + "_id": {"$ne": pool_id}, + "host_id": pool.get("host_id"), + "payments": { + "$elemMatch": { + "utr": utr, + "status": {"$in": list(PAYMENT_ACTIVE_STATUSES)} + } + } + }) + if duplicate_pool: + raise HTTPException(status_code=409, detail="This UTR was already used in another pool settlement") + + active_participants = [item for item in items if item.get("is_purchased", True)] + active_count = len({name_key(item.get("added_by_name")) for item in active_participants}) + net_overhead = (pool.get("final_overhead") or 0) - (pool.get("final_discount") or 0) + overhead_share = int(net_overhead / active_count) if active_count > 0 else 0 + roommate_items_total = sum( + item["estimated_price"] + for item in active_participants + if name_key(item.get("added_by_name")) == name_key(exact_roommate_name) + ) + expected_amount = roommate_items_total + overhead_share payment_entry = { "name": exact_roommate_name, "utr": utr, "status": "pending", - "submitted_at": utcnow().isoformat() + "submitted_at": utcnow().isoformat(), + "expected_amount": expected_amount, + "amount_tolerance": PAYMENT_AMOUNT_TOLERANCE_PAISE, + "confidence": "manual_claim", + "verification_source": "manual_utr", + "review_reason": "Manual UTR submitted; waiting for a matching host credit or host review.", } # Remove any existing payment record for this roommate @@ -704,7 +1227,7 @@ async def payment_confirm(pool_id: str, req: PaymentConfirmReq): from app.core.security import _serialize_value for k, v in list(updated.items()): updated[k] = _serialize_value(v) - updated = await enrich_pool_document(db, updated) + updated = await enrich_pool_document(db, updated, user_id) return updated @router.post("/{pool_id}/payment-verify") @@ -734,6 +1257,8 @@ async def payment_verify(pool_id: str, req: PaymentVerifyReq, user_id: str = Dep if action in ("verify", "settle_in_kind"): status = "verified" settlement_mode = "settle_in_kind" if action == "settle_in_kind" else "manual" + verification_source = "host_settle_in_kind" if action == "settle_in_kind" else "host_manual_review" + confidence = "host_confirmed" # Check if roommate already has a payment log has_payment = any(name_key(p["name"]) == name_key(exact_roommate_name) for p in payments) @@ -744,7 +1269,10 @@ async def payment_verify(pool_id: str, req: PaymentVerifyReq, user_id: str = Dep {"$set": { "payments.$.status": status, "payments.$.verified_at": utcnow().isoformat(), - "payments.$.settlement_mode": settlement_mode + "payments.$.settlement_mode": settlement_mode, + "payments.$.verification_source": verification_source, + "payments.$.confidence": confidence, + "payments.$.review_reason": None, }} ) if result.matched_count == 0: @@ -756,7 +1284,9 @@ async def payment_verify(pool_id: str, req: PaymentVerifyReq, user_id: str = Dep "status": status, "submitted_at": utcnow().isoformat(), "verified_at": utcnow().isoformat(), - "settlement_mode": settlement_mode + "settlement_mode": settlement_mode, + "verification_source": verification_source, + "confidence": confidence, } await db.cart_pools.update_one( {"_id": pool_id}, @@ -765,10 +1295,17 @@ async def payment_verify(pool_id: str, req: PaymentVerifyReq, user_id: str = Dep elif action == "reject": result = await db.cart_pools.update_one( - {"_id": pool_id}, - {"$pull": {"payments": {"name": exact_roommate_name}}} + {"_id": pool_id, "payments.name": exact_roommate_name}, + {"$set": { + "payments.$.status": "rejected", + "payments.$.rejected_at": utcnow().isoformat(), + "payments.$.verification_source": "host_rejected", + "payments.$.confidence": "rejected", + "payments.$.rejection_reason": "Host did not find a matching incoming credit for this reference.", + "payments.$.review_reason": "Rejected by host; roommate should recheck the UTR or pay again.", + }} ) - if result.modified_count == 0: + if result.matched_count == 0: raise HTTPException(status_code=404, detail="Payment confirmation not found") else: raise HTTPException(status_code=400, detail="Invalid action") @@ -779,15 +1316,22 @@ async def payment_verify(pool_id: str, req: PaymentVerifyReq, user_id: str = Dep from app.core.security import _serialize_value for k, v in list(updated.items()): updated[k] = _serialize_value(v) - updated = await enrich_pool_document(db, updated) + updated = await enrich_pool_document(db, updated, user_id) return updated @router.get("/{pool_id}/items") -async def get_pool_items(pool_id: str): +async def get_pool_items(pool_id: str, user_id: Optional[str] = Depends(get_optional_current_user)): db = get_db() + pool = await db.cart_pools.find_one({"_id": pool_id}) + if not pool: + raise HTTPException(status_code=404, detail="Pool not found") cursor = db.cart_pool_items.find({"pool_id": pool_id}).sort("created_at", 1) items = await cursor.to_list(length=500) - return map_docs(items) + safe_items = [ + sanitize_pool_item_for_viewer(item, user_id, pool.get("host_id")) + for item in items + ] + return map_docs(safe_items) async def match_wing_roommate(db, pool: dict, input_name: str) -> str: cleaned = clean_text(input_name, "Roommate name") @@ -811,7 +1355,7 @@ async def match_wing_roommate(db, pool: dict, input_name: str) -> str: return cleaned @router.post("/{pool_id}/items") -async def insert_pool_item(pool_id: str, req: PoolItemReq): +async def insert_pool_item(pool_id: str, req: PoolItemReq, user_id: str = Depends(get_current_user)): db = get_db() # Verify pool exists and is open @@ -827,20 +1371,32 @@ async def insert_pool_item(pool_id: str, req: PoolItemReq): # Robustness Limit Validation if req.estimated_price <= 0 or req.estimated_price > MAX_ITEM_PRICE_PAISE: raise HTTPException(status_code=400, detail="Estimated price must be between ₹1 and ₹5,000") + quantity = validate_item_quantity(req.quantity) + unit_price = req.unit_price if req.unit_price is not None else int(round(req.estimated_price / quantity)) + validate_paise_amount(unit_price, "Unit price", MAX_ITEM_PRICE_PAISE) + line_total = unit_price * quantity + validate_paise_amount(line_total, "Estimated price", MAX_ITEM_PRICE_PAISE) + + user_doc = await db.users.find_one({"_id": user_id}) + item_owner_name = user_doc.get("full_name") if user_doc and user_doc.get("full_name") else req.added_by_name # Match against registered wing roommates to avoid name collisions/typos - matched_name = await match_wing_roommate(db, pool, req.added_by_name) + matched_name = await match_wing_roommate(db, pool, item_owner_name) item_id = str(uuid.uuid4()) new_item = { "_id": item_id, "pool_id": pool_id, + "added_by_user_id": user_id, "added_by_name": matched_name, "item_description": clean_text(req.item_description, "Item description", max_chars=MAX_DESCRIPTION_CHARS), - "estimated_price": req.estimated_price, + "estimated_price": line_total, + "quantity": quantity, + "unit_price": unit_price, "product_url": validate_product_url(req.product_url), "is_purchased": True, + "cart_status": "pending", "created_at": utcnow() } @@ -848,7 +1404,7 @@ async def insert_pool_item(pool_id: str, req: PoolItemReq): return map_doc(new_item) @router.delete("/{pool_id}/items/{item_id}") -async def delete_pool_item(pool_id: str, item_id: str): +async def delete_pool_item(pool_id: str, item_id: str, user_id: str = Depends(get_current_user)): db = get_db() pool = await db.cart_pools.find_one({"_id": pool_id}) if not pool: @@ -857,13 +1413,41 @@ async def delete_pool_item(pool_id: str, item_id: str): if pool.get("status") != "open": raise HTTPException(status_code=400, detail="This pool is no longer editable.") + item = await db.cart_pool_items.find_one({"_id": item_id, "pool_id": pool_id}) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + + user_doc = await db.users.find_one({"_id": user_id}) + user_name = user_doc.get("full_name") if user_doc else "" + is_host = pool.get("host_id") == user_id + is_owner = item.get("added_by_user_id") == user_id or name_key(user_name) == name_key(item.get("added_by_name")) + host_aliases = {name_key(pool.get("created_by_name")), name_key(user_name), "host", "you"} + is_host_item = is_host and name_key(item.get("added_by_name")) in host_aliases + + if not is_host and not is_owner: + raise HTTPException(status_code=403, detail="Only the item owner or host can remove this item") + + if is_host and not is_host_item: + now = utcnow() + await db.cart_pool_items.update_one( + {"_id": item_id, "pool_id": pool_id}, + {"$set": { + "is_purchased": False, + "cart_status": "skipped", + "cart_status_reason": "Host removed this item from the cart.", + "cart_status_updated_at": now, + "cart_status_updated_by": "host", + }}, + ) + return {"success": True, "mode": "skipped"} + res = await db.cart_pool_items.delete_one({"_id": item_id, "pool_id": pool_id}) if res.deleted_count == 0: raise HTTPException(status_code=404, detail="Item not found") return {"success": True} @router.patch("/{pool_id}/items/{item_id}") -async def update_pool_item(pool_id: str, item_id: str, req: PoolItemUpdateReq): +async def update_pool_item(pool_id: str, item_id: str, req: PoolItemUpdateReq, user_id: str = Depends(get_current_user)): db = get_db() pool = await db.cart_pools.find_one({"_id": pool_id}) @@ -875,23 +1459,89 @@ async def update_pool_item(pool_id: str, item_id: str, req: PoolItemUpdateReq): if req.estimated_price is not None and (req.estimated_price <= 0 or req.estimated_price > MAX_ITEM_PRICE_PAISE): raise HTTPException(status_code=400, detail="Estimated price must be between ₹1 and ₹5,000") + if req.quantity is not None: + validate_item_quantity(req.quantity) + if req.unit_price is not None: + validate_paise_amount(req.unit_price, "Unit price", MAX_ITEM_PRICE_PAISE) item = await db.cart_pool_items.find_one({"_id": item_id, "pool_id": pool_id}) if not item: raise HTTPException(status_code=404, detail="Item not found") + user_doc = await db.users.find_one({"_id": user_id}) + user_name = user_doc.get("full_name") if user_doc else "" + is_host = pool.get("host_id") == user_id + is_owner = item.get("added_by_user_id") == user_id or name_key(user_name) == name_key(item.get("added_by_name")) + host_aliases = {name_key(pool.get("created_by_name")), name_key(user_name), "host", "you"} + is_host_item = is_host and name_key(item.get("added_by_name")) in host_aliases + # Build updates dict, handling booleans (False is a valid value, not None) + requested_keys = set(req.model_dump(exclude_unset=True).keys()) updates = {} + nullable_update_fields = {"product_url", "substitute_note", "cart_status_reason"} for k, v in req.model_dump(exclude_unset=True).items(): - if isinstance(v, bool) or v is not None: + if isinstance(v, bool) or v is not None or k in nullable_update_fields: updates[k] = v + host_only_fields = {"is_purchased", "cart_status", "cart_status_reason", "substitute_note"} + owner_edit_fields = {"estimated_price", "quantity", "unit_price", "item_description", "product_url"} + material_item_fields = owner_edit_fields.intersection(requested_keys) + if host_only_fields.intersection(updates.keys()) and not is_host: + raise HTTPException(status_code=403, detail="Only the pool host can update cart status") + if owner_edit_fields.intersection(updates.keys()) and not (is_host or is_owner): + raise HTTPException(status_code=403, detail="Only the item owner or host can edit this item") + if "item_description" in updates: updates["item_description"] = clean_text( updates["item_description"], "Item description", max_chars=MAX_DESCRIPTION_CHARS ) if "product_url" in updates: updates["product_url"] = validate_product_url(updates["product_url"]) + if "quantity" in updates: + updates["quantity"] = validate_item_quantity(updates["quantity"]) + if "unit_price" in updates: + updates["unit_price"] = validate_paise_amount(updates["unit_price"], "Unit price", MAX_ITEM_PRICE_PAISE) + if "quantity" in updates or "unit_price" in updates: + effective_quantity = validate_item_quantity(updates.get("quantity", item.get("quantity", 1))) + effective_unit_price = updates.get("unit_price", item.get("unit_price")) + if not effective_unit_price: + effective_unit_price = int(round(item.get("estimated_price", 0) / effective_quantity)) + effective_unit_price = validate_paise_amount(effective_unit_price, "Unit price", MAX_ITEM_PRICE_PAISE) + updates["quantity"] = effective_quantity + updates["unit_price"] = effective_unit_price + updates["estimated_price"] = validate_paise_amount( + effective_unit_price * effective_quantity, + "Estimated price", + MAX_ITEM_PRICE_PAISE, + ) + if "cart_status" in updates: + updates["cart_status"] = validate_cart_status(updates["cart_status"]) + if "substitute_note" in updates: + updates["substitute_note"] = clean_optional_text(updates["substitute_note"], "Substitute note", max_chars=MAX_DESCRIPTION_CHARS) + if "cart_status_reason" in updates: + updates["cart_status_reason"] = clean_optional_text( + updates["cart_status_reason"], "Cart status reason", max_chars=MAX_DESCRIPTION_CHARS + ) + + if material_item_fields and "cart_status" not in requested_keys and "is_purchased" not in requested_keys: + updates["is_purchased"] = True + updates["cart_status"] = "pending" + updates["cart_status_reason"] = None + + if material_item_fields: + now = utcnow() + updates["item_updated_at"] = now + updates["item_updated_by"] = "host" if is_host else "roommate" + updates["item_update_reason"] = item_edit_reason(is_host, is_host_item) + + if "is_purchased" in updates and "cart_status" not in updates: + updates["cart_status"] = "pending" if updates["is_purchased"] else "skipped" + + if "cart_status" in updates: + updates["cart_status_updated_at"] = utcnow() + updates["cart_status_updated_by"] = "host" if is_host else "roommate" + if not updates.get("cart_status_reason"): + updates["cart_status_reason"] = default_cart_status_reason(updates["cart_status"]) if updates: await db.cart_pool_items.update_one({"_id": item_id, "pool_id": pool_id}, {"$set": updates}) @@ -908,16 +1558,20 @@ async def nudge_roommate_api(pool_id: str, req: NudgeReq, user_id: str = Depends pool = await db.cart_pools.find_one({"_id": pool_id}) if not pool: raise HTTPException(status_code=404, detail="Pool not found") + if pool.get("host_id") != user_id: + raise HTTPException(status_code=403, detail="Only the pool host can nudge roommates") roommate_name = req.roommate_name - usr = await db.users.find_one({"full_name": {"$regex": f"^{re.escape(roommate_name)}$", "$options": "i"}}) + items_cursor = db.cart_pool_items.find({"pool_id": pool_id}) + items = await items_cursor.to_list(length=500) + usr = await resolve_pool_participant_user(db, pool, items, roommate_name) if not usr or not usr.get("phone_number"): raise HTTPException(status_code=400, detail=f"No registered phone number found for {roommate_name}. Please nudge manually.") phone = usr["phone_number"] platform = pool.get("platform", "delivery").replace("_", " ").title() - p = await enrich_pool_document(db, pool) + p = await enrich_pool_document(db, pool, user_id) details = p.get("split_breakdown", {}).get(roommate_name) if not details or details.get("paid"): return {"success": False, "mode": "fallback", "message": f"{roommate_name} has already paid."} @@ -1029,9 +1683,11 @@ async def cron_auto_nudge(user_id: str = Depends(get_current_user)): if elapsed_hours < interval_hours: continue - p = await enrich_pool_document(db, pool) + p = await enrich_pool_document(db, pool, user_id) platform = p.get("platform", "delivery").replace("_", " ").title() pool_url = f"{settings.FRONTEND_BASE_URL}/pool/{pool_id}" + items_cursor = db.cart_pool_items.find({"pool_id": pool_id}) + items = await items_cursor.to_list(length=500) for rName, details in p.get("split_breakdown", {}).items(): is_host = rName.lower() == "you" or name_key(rName) == name_key(p.get("created_by_name")) @@ -1051,7 +1707,7 @@ async def cron_auto_nudge(user_id: str = Depends(get_current_user)): except Exception: pass - usr = await db.users.find_one({"full_name": {"$regex": f"^{re.escape(rName)}$", "$options": "i"}}) + usr = await resolve_pool_participant_user(db, pool, items, rName) if not usr or not usr.get("phone_number"): continue @@ -1115,11 +1771,16 @@ async def create_amazon_checkout_session( pool = await db.cart_pools.find_one({"_id": pool_id}) if not pool: raise HTTPException(status_code=404, detail="Pool not found") + pool = await expire_pool_if_needed(db, pool) if pool.get("host_id") != user_id: raise HTTPException(status_code=403, detail="Only the pool host can initiate Amazon Pay checkout") if pool.get("status") != "open": raise HTTPException(status_code=400, detail="Amazon Pay sandbox checkout can only start while the pool is open") + final_overhead = validate_paise_amount(req.final_overhead, "Final overhead", MAX_FEE_PAISE, allow_zero=True) + final_discount = validate_paise_amount(req.final_discount, "Final discount", MAX_FEE_PAISE, allow_zero=True) + checkout_notes = clean_optional_text(req.checkout_notes, "Checkout notes", max_chars=MAX_DESCRIPTION_CHARS) + upi_id = validate_upi_id(req.upi_id) # Generate a local sandbox session ID in the same visual family as Amazon Pay. checkout_session_id = f"S01-{uuid.uuid4().hex[:14].upper()}" @@ -1128,7 +1789,7 @@ async def create_amazon_checkout_session( items_cursor = db.cart_pool_items.find({"pool_id": pool_id}) items = await items_cursor.to_list(length=1000) items_total = sum(it.get("estimated_price", 0) for it in items if it.get("is_purchased", True)) - net_total = max(0, items_total + req.final_overhead - req.final_discount) + net_total = max(0, items_total + final_overhead - final_discount) # Store a local sandbox session shaped like the Amazon Pay V2 contract. amazon_checkout = { @@ -1144,10 +1805,10 @@ async def create_amazon_checkout_session( "currencyCode": "INR" } }, - "final_overhead": req.final_overhead, - "final_discount": req.final_discount, - "checkout_notes": req.checkout_notes, - "upi_id": req.upi_id, + "final_overhead": final_overhead, + "final_discount": final_discount, + "checkout_notes": checkout_notes, + "upi_id": upi_id, "created_at": utcnow() } @@ -1172,6 +1833,7 @@ async def complete_amazon_checkout_session( pool = await db.cart_pools.find_one({"_id": pool_id}) if not pool: raise HTTPException(status_code=404, detail="Pool not found") + pool = await expire_pool_if_needed(db, pool) if pool.get("host_id") != user_id: raise HTTPException(status_code=403, detail="Only the pool host can complete Amazon Pay checkout") if pool.get("status") != "open": @@ -1247,7 +1909,7 @@ async def complete_amazon_checkout_session( from app.core.security import _serialize_value for k, v in list(updated.items()): updated[k] = _serialize_value(v) - return await enrich_pool_document(db, updated) + return await enrich_pool_document(db, updated, user_id) @router.post("/{pool_id}/amazon-charge-permission/roommate-reimburse") async def process_amazon_roommate_payment( @@ -1264,12 +1926,15 @@ async def process_amazon_roommate_payment( if pool.get("host_id") == user_id: raise HTTPException(status_code=400, detail="Host cannot settle their own pool as a roommate") - # Record a roommate sandbox settlement. This does not charge a live payment rail. - exact_roommate_name = await match_wing_roommate(db, pool, req.roommate_name) - user_doc = await db.users.find_one({"_id": user_id}) - user_name = name_key(user_doc.get("full_name")) if user_doc else "" - if not user_name or user_name != name_key(exact_roommate_name): - raise HTTPException(status_code=403, detail="You can settle only your own roommate split") + items_cursor = db.cart_pool_items.find({"pool_id": pool_id}) + items = await items_cursor.to_list(length=500) + exact_roommate_name = await resolve_payment_claim_roommate_name( + db, + pool, + items, + user_id, + req.roommate_name, + ) enriched_pool = await enrich_pool_document(db, {**pool, "id": pool.get("_id")}, current_user_id=user_id) roommate_split = (enriched_pool.get("split_breakdown") or {}).get(exact_roommate_name) @@ -1289,7 +1954,9 @@ async def process_amazon_roommate_payment( "submitted_at": utcnow().isoformat(), "verified_at": utcnow().isoformat(), "settlement_mode": "amazon_pay_sandbox", - "confidence": 1.0 + "confidence": "sandbox_confirmed", + "verification_source": "amazon_pay_sandbox", + "expected_amount": expected_amount, } # Pull old payment record and push new one diff --git a/backend/app/api/webhook.py b/backend/app/api/webhook.py index 1be985d..33684ff 100644 --- a/backend/app/api/webhook.py +++ b/backend/app/api/webhook.py @@ -567,15 +567,18 @@ async def try_auto_verify_pool_payment( amount_from_req: Optional[float] = None, utr_from_req: Optional[str] = None, direction_from_req: Optional[str] = None, -) -> bool: +) -> Optional[dict]: + text = text or "" text_lower = text.lower() # --- Step 1: Determine transaction direction --- - # If Android app tells us the direction, trust it completely. - if direction_from_req: - if direction_from_req.lower() == "debit": - return False # Host spent money — not a roommate paying in - is_credit = direction_from_req.lower() == "credit" + # Android direction is authoritative only for debit/credit polarity; credit + # notifications still need settlement-specific checks below. + direction = (direction_from_req or "").strip().lower() + if direction: + if direction == "debit": + return None # Host spent money; not a roommate paying in. + is_credit = direction == "credit" else: # Explicit debit keywords — bail out immediately debit_keywords = [ @@ -584,7 +587,7 @@ async def try_auto_verify_pool_payment( "debit", "dr ", "dr.", ] if any(kw in text_lower for kw in debit_keywords): - return False + return None # Credit indicators credit_keywords = [ @@ -598,16 +601,23 @@ async def try_auto_verify_pool_payment( is_credit = any(kw in text_lower for kw in credit_keywords) if not is_credit: - return False + return None + + non_settlement_credit_keywords = [ + "refund", "cashback", "reversal", "reversed", "chargeback", + "interest credited", "reward credited", "failed transaction refunded", + ] + if any(kw in text_lower for kw in non_settlement_credit_keywords): + return None # --- Step 2: Extract UTR --- - utr = utr_from_req or parse_transaction_id(text) + utr = (utr_from_req or parse_transaction_id(text) or "").strip() # --- Step 3: Parse amount --- # Prefer amount from Android app (already parsed), fall back to text parsing amount_paise = int(round(amount_from_req * 100)) if amount_from_req is not None else parse_amount(text) if not amount_paise: - return False + return None # --- Step 4: Extract sender name from notification text --- def local_name_key(v: Optional[str]) -> str: @@ -633,6 +643,66 @@ def local_name_key(v: Optional[str]) -> str: sender_name = raw break + def amount_matches(expected: int) -> bool: + return abs(expected - amount_paise) <= 500 + + async def record_pool_payment_candidate( + pool: dict, + roommate: str, + roommate_payment: Optional[dict], + total_owed: int, + status: str, + confidence: str, + review_reason: Optional[str], + verification_reason: str, + ) -> dict: + now = datetime.datetime.utcnow() + pool_id = pool["_id"] + payment_entry = { + "name": roommate, + "utr": utr or (roommate_payment.get("utr") if roommate_payment else "AUTO_VERIFIED"), + "status": status, + "submitted_at": roommate_payment.get("submitted_at") if roommate_payment else now.isoformat(), + "verified_at": now.isoformat() if status == "verified" else None, + "confidence": confidence, + "parsed_sender": sender_name, + "settlement_mode": "manual" if status == "verified" else None, + "verification_source": "auto_host_credit" if status == "verified" else "auto_host_credit_review", + "verification_reason": verification_reason, + "review_reason": review_reason, + "expected_amount": total_owed, + "matched_amount": amount_paise, + "amount_delta": amount_paise - total_owed, + "amount_tolerance": 500, + } + + await db.cart_pools.update_one( + {"_id": pool_id}, + {"$pull": {"payments": {"name": roommate}}} + ) + await db.cart_pools.update_one( + {"_id": pool_id}, + {"$push": {"payments": payment_entry}} + ) + + logger.info( + "Pool payment candidate for %s in pool %s: status=%s confidence=%s reason=%s", + roommate, + pool_id, + status, + confidence, + verification_reason, + ) + return { + "pool_id": pool_id, + "roommate_name": roommate, + "payment_status": status, + "processing_status": "pool_payment_verified" if status == "verified" else "pool_payment_review", + "reason": verification_reason, + "amount_paise": amount_paise, + "transaction_reference": utr or payment_entry["utr"], + } + # --- Step 5: Scan completed pools --- since = datetime.datetime.utcnow() - datetime.timedelta(days=7) pools_cursor = db.cart_pools.find({ @@ -642,6 +712,8 @@ def local_name_key(v: Optional[str]) -> str: }) pools = await pools_cursor.to_list(length=20) + amount_only_candidates = [] + sender_amount_candidates = [] for pool in pools: pool_id = pool["_id"] items_cursor = db.cart_pool_items.find({"pool_id": pool_id}) @@ -664,70 +736,87 @@ def local_name_key(v: Optional[str]) -> str: payments = pool.get("payments", []) roommate_payment = next((p for p in payments if local_name_key(p["name"]) == local_name_key(roommate)), None) + if roommate_payment and roommate_payment.get("status") == "verified": + continue - if not roommate_payment or roommate_payment.get("status") in ("pending", "needs_review"): - # Compare amount (allowing ±500 paise i.e. 5 rupee tolerance for rounding) - if abs(total_owed - amount_paise) <= 500: - confidence = "medium" - if sender_name: - r_key = local_name_key(roommate) - s_key = local_name_key(sender_name) - if r_key in s_key or s_key in r_key: - confidence = "high" - - status = "verified" if confidence == "high" else "needs_review" - - payment_entry = { - "name": roommate, - "utr": utr or (roommate_payment.get("utr") if roommate_payment else "AUTO_VERIFIED"), - "status": status, - "submitted_at": roommate_payment.get("submitted_at") if roommate_payment else datetime.datetime.utcnow().isoformat(), - "verified_at": datetime.datetime.utcnow().isoformat() if status == "verified" else None, - "confidence": confidence, - "parsed_sender": sender_name, - "settlement_mode": "manual" if status == "verified" else None - } - - # Remove existing payment record for this roommate and push the updated one - await db.cart_pools.update_one( - {"_id": pool_id}, - {"$pull": {"payments": {"name": roommate}}} - ) - await db.cart_pools.update_one( - {"_id": pool_id}, - {"$push": {"payments": payment_entry}} + submitted_utr = (roommate_payment or {}).get("utr") + utr_matches_claim = bool(utr and submitted_utr and local_name_key(utr) == local_name_key(submitted_utr)) + + if utr_matches_claim: + if amount_matches(total_owed): + return await record_pool_payment_candidate( + pool, + roommate, + roommate_payment, + total_owed, + "verified", + "high", + None, + "Incoming host credit matched submitted UTR and finalized split amount.", ) + return await record_pool_payment_candidate( + pool, + roommate, + roommate_payment, + total_owed, + "needs_review", + "low", + "Incoming UTR matched the submitted reference, but the amount does not match the finalized split.", + "Incoming UTR matched a manual claim with an amount mismatch.", + ) + + if not amount_matches(total_owed): + continue - logger.info(f"Auto-verified roommate split: {roommate} in pool {pool_id} with confidence {confidence}") - return True - - # --- Step 6: Fallback — UTR direct lookup (last 7 days only) --- - if utr: - since = datetime.datetime.utcnow() - datetime.timedelta(days=7) - pool = await db.cart_pools.find_one({ - "host_id": user_id, - "status": "completed", - "completed_at": {"$gte": since}, - "payments": { - "$elemMatch": { - "utr": utr, - "status": "pending" - } - } - }) - if pool: - res = await db.cart_pools.update_one( - {"_id": pool["_id"], "payments.utr": utr}, - {"$set": { - "payments.$.status": "verified", - "payments.$.verified_at": datetime.datetime.utcnow().isoformat() - }} - ) - if res.modified_count > 0: - logger.info(f"Matched manual UTR {utr} for pool {pool['_id']}") - return True - - return False + sender_matches = False + if sender_name: + r_key = local_name_key(roommate) + s_key = local_name_key(sender_name) + sender_matches = bool(r_key and s_key and (r_key in s_key or s_key in r_key)) + + if sender_matches: + sender_amount_candidates.append((pool, roommate, roommate_payment, total_owed)) + continue + + amount_only_candidates.append((pool, roommate, roommate_payment, total_owed)) + + if len(sender_amount_candidates) == 1: + pool, roommate, roommate_payment, total_owed = sender_amount_candidates[0] + return await record_pool_payment_candidate( + pool, + roommate, + roommate_payment, + total_owed, + "verified", + "high", + None, + "Incoming host credit matched roommate name and finalized split amount.", + ) + + if len(sender_amount_candidates) > 1: + return { + "payment_status": "needs_review", + "processing_status": "pool_payment_review", + "reason": "Incoming host credit matched multiple unsettled splits with the same sender and amount.", + "amount_paise": amount_paise, + "transaction_reference": utr or None, + "candidate_count": len(sender_amount_candidates), + } + + if len(amount_only_candidates) == 1: + pool, roommate, roommate_payment, total_owed = amount_only_candidates[0] + return await record_pool_payment_candidate( + pool, + roommate, + roommate_payment, + total_owed, + "needs_review", + "medium", + "Amount matches one unsettled split, but sender name or UTR did not confirm the roommate.", + "Amount-only host credit match requires manual review.", + ) + + return None @router.post("/") @@ -902,7 +991,7 @@ async def ingest_notification( return {"status": "paused", "reason": "sync_disabled_by_user"} # Intercept roommate split payments sent to the host via UPI - is_auto_verified = await try_auto_verify_pool_payment( + pool_payment_match = await try_auto_verify_pool_payment( db, user_id, raw_body, @@ -910,18 +999,23 @@ async def ingest_notification( utr_from_req=req.transactionId, direction_from_req=req.direction, ) - if is_auto_verified: - await db.companion_sync_log.update_one( - {"_id": log_id}, - { - "$set": { - "processing_status": "auto_verified", - "updated_at": datetime.datetime.utcnow(), - } - } + if pool_payment_match: + await mark_sync_log( + db, + log_id, + pool_payment_match.get("processing_status", "pool_payment_review"), + pool_payment_match.get("amount_paise"), + "UPI Pool Credit", + pool_payment_match.get("transaction_reference"), ) await update_profile_sync_state(db, user_id, req, now, device_id) - return {"status": "auto_verified", "reason": "verified_pool_payment"} + return { + "status": pool_payment_match.get("processing_status", "pool_payment_review"), + "reason": pool_payment_match.get("reason", "matched_pool_payment"), + "pool_id": pool_payment_match.get("pool_id"), + "roommate_name": pool_payment_match.get("roommate_name"), + "payment_status": pool_payment_match.get("payment_status"), + } direction = (req.direction or "").lower().strip() amount_paise, merchant, transaction_reference, merchant_was_missing = extract_connector_event_context( diff --git a/backend/app/core/security.py b/backend/app/core/security.py index f862341..54d9aa9 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -14,6 +14,16 @@ def get_current_user(authorization: Optional[str] = Header(None)) -> str: except jwt.PyJWTError: raise HTTPException(status_code=401, detail="Invalid token") +def get_optional_current_user(authorization: Optional[str] = Header(None)) -> Optional[str]: + if not authorization or not authorization.startswith("Bearer "): + return None + token = authorization.split(" ")[1] + try: + payload = jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"]) + return payload.get("userId") + except jwt.PyJWTError: + return None + def _serialize_value(v): """Ensure naive datetimes get a Z suffix so frontend interprets them as UTC.""" if isinstance(v, _dt.datetime): diff --git a/backend/tests/test_pooling_hardening.py b/backend/tests/test_pooling_hardening.py new file mode 100644 index 0000000..2fab223 --- /dev/null +++ b/backend/tests/test_pooling_hardening.py @@ -0,0 +1,545 @@ +import asyncio +import datetime +import os +import re +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from fastapi import HTTPException + +os.environ.setdefault("JWT_SECRET", "test-secret") +os.environ.setdefault("MONGO_URI", "mongodb://localhost:27017/pocketbuddy_test") + +from app.api.pools import ( + PaymentConfirmReq, + RoommateAmznPayReq, + build_payment_state, + enrich_pool_document, + payment_confirm, + process_amazon_roommate_payment, + sanitize_pool_item_for_viewer, +) +from app.api.webhook import try_auto_verify_pool_payment + + +class FakeCursor: + def __init__(self, docs): + self.docs = docs + + async def to_list(self, length=100): + return list(self.docs[:length]) + + +class FakeCartPools: + def __init__(self, pools): + self.pools = pools + + def find(self, query): + docs = [] + completed_after = (query.get("completed_at") or {}).get("$gte") + for pool in self.pools: + if query.get("host_id") and pool.get("host_id") != query["host_id"]: + continue + if query.get("status") and pool.get("status") != query["status"]: + continue + if completed_after and pool.get("completed_at") < completed_after: + continue + docs.append(pool) + return FakeCursor(docs) + + async def find_one(self, query): + for pool in self.pools: + if "_id" in query: + expected_id = query["_id"] + if isinstance(expected_id, dict): + if "$ne" in expected_id and pool.get("_id") == expected_id["$ne"]: + continue + elif pool.get("_id") != expected_id: + continue + if query.get("host_id") and pool.get("host_id") != query["host_id"]: + continue + payment_query = query.get("payments", {}).get("$elemMatch") + if payment_query: + utr = payment_query.get("utr") + statuses = set(payment_query.get("status", {}).get("$in", [])) + if not any( + payment.get("utr") == utr and payment.get("status") in statuses + for payment in pool.get("payments", []) + ): + continue + return pool + return None + + async def update_one(self, query, update): + pool = next((p for p in self.pools if p.get("_id") == query.get("_id")), None) + if not pool: + return SimpleNamespace(matched_count=0, modified_count=0) + + if "$pull" in update: + payment_filter = update["$pull"].get("payments") + if payment_filter and "name" in payment_filter: + pool["payments"] = [ + payment for payment in pool.get("payments", []) + if payment.get("name") != payment_filter["name"] + ] + + if "$push" in update: + pool.setdefault("payments", []).append(update["$push"]["payments"]) + + if "$set" in update: + for key, value in update["$set"].items(): + pool[key] = value + + return SimpleNamespace(matched_count=1, modified_count=1) + + +class FakeCartPoolItems: + def __init__(self, items): + self.items = items + + def find(self, query): + return FakeCursor([item for item in self.items if item.get("pool_id") == query.get("pool_id")]) + + +class FakeUsers: + def __init__(self, users=None): + self.users = users or [] + + async def find_one(self, query): + if "_id" in query: + for user in self.users: + if user.get("_id") == query["_id"]: + return user + return None + + full_name_query = query.get("full_name") + if isinstance(full_name_query, dict) and "$regex" in full_name_query: + pattern = re.compile(full_name_query["$regex"], re.IGNORECASE) + for user in self.users: + if pattern.match(user.get("full_name", "")): + return user + return None + + def find(self, query): + ids = set(query.get("_id", {}).get("$in", [])) + if ids: + return FakeCursor([user for user in self.users if user.get("_id") in ids]) + return FakeCursor(self.users) + + +class FakeProfiles: + def __init__(self, profiles=None): + self.profiles = profiles or [] + + async def find_one(self, query): + if "_id" in query: + for profile in self.profiles: + if profile.get("_id") == query["_id"]: + return profile + return None + + def find(self, query): + docs = self.profiles + if "wing_label" in query: + docs = [profile for profile in docs if profile.get("wing_label") == query["wing_label"]] + if "_id" in query and isinstance(query["_id"], dict): + ids = set(query["_id"].get("$in", [])) + docs = [profile for profile in docs if profile.get("_id") in ids] + return FakeCursor(docs) + + +class FakeTransactions: + def __init__(self): + self.docs = [] + + async def insert_one(self, doc): + self.docs.append(doc) + return SimpleNamespace(inserted_id=doc.get("_id")) + + +def make_db(pool, items, users=None, profiles=None): + pools = pool if isinstance(pool, list) else [pool] + return SimpleNamespace( + cart_pools=FakeCartPools(pools), + cart_pool_items=FakeCartPoolItems(items), + users=FakeUsers(users), + profiles=FakeProfiles(profiles), + transactions=FakeTransactions(), + ) + + +def make_completed_pool(payments=None, pool_id="pool-1"): + return { + "_id": pool_id, + "host_id": "host-1", + "status": "completed", + "created_by_name": "Host", + "wing_label": "BH-2 Wing B", + "completed_at": datetime.datetime.utcnow() - datetime.timedelta(hours=2), + "final_overhead": 0, + "final_discount": 0, + "payments": payments or [], + } + + +def make_items(pool_id="pool-1", price=10000, user_id="asha-right"): + return [ + { + "_id": "item-1", + "pool_id": pool_id, + "added_by_name": "Asha", + "added_by_user_id": user_id, + "estimated_price": price, + "is_purchased": True, + } + ] + + +class PoolingHardeningTests(unittest.TestCase): + def test_amount_only_host_credit_goes_to_review_not_verified(self): + pool = make_completed_pool() + db = make_db(pool, make_items()) + + result = asyncio.run( + try_auto_verify_pool_payment( + db, + "host-1", + "", + amount_from_req=100.0, + direction_from_req="credit", + ) + ) + + self.assertEqual(result["payment_status"], "needs_review") + self.assertEqual(pool["payments"][0]["status"], "needs_review") + self.assertEqual(pool["payments"][0]["verification_source"], "auto_host_credit_review") + self.assertIn("Amount-only", result["reason"]) + + def test_sender_and_amount_match_auto_verifies_split(self): + pool = make_completed_pool() + db = make_db(pool, make_items()) + + result = asyncio.run( + try_auto_verify_pool_payment( + db, + "host-1", + "Received from Asha via UPI Ref 123456789012", + amount_from_req=100.0, + direction_from_req="credit", + ) + ) + + self.assertEqual(result["payment_status"], "verified") + self.assertEqual(pool["payments"][0]["status"], "verified") + self.assertEqual(pool["payments"][0]["verification_source"], "auto_host_credit") + + def test_sender_amount_match_across_multiple_pools_requires_review(self): + pool_one = make_completed_pool(pool_id="pool-1") + pool_two = make_completed_pool(pool_id="pool-2") + db = make_db( + [pool_one, pool_two], + make_items(pool_id="pool-1") + make_items(pool_id="pool-2"), + ) + + result = asyncio.run( + try_auto_verify_pool_payment( + db, + "host-1", + "Received from Asha via UPI Ref 123456789012", + amount_from_req=100.0, + direction_from_req="credit", + ) + ) + + self.assertEqual(result["payment_status"], "needs_review") + self.assertIn("multiple unsettled splits", result["reason"]) + self.assertEqual(pool_one["payments"], []) + self.assertEqual(pool_two["payments"], []) + + def test_submitted_utr_requires_matching_amount(self): + pool = make_completed_pool([ + { + "name": "Asha", + "utr": "123456789012", + "status": "pending", + "submitted_at": datetime.datetime.utcnow().isoformat(), + } + ]) + db = make_db(pool, make_items()) + + result = asyncio.run( + try_auto_verify_pool_payment( + db, + "host-1", + "", + amount_from_req=90.0, + utr_from_req="123456789012", + direction_from_req="credit", + ) + ) + + self.assertEqual(result["payment_status"], "needs_review") + self.assertEqual(pool["payments"][0]["status"], "needs_review") + self.assertIn("amount does not match", pool["payments"][0]["review_reason"]) + + def test_roommate_cannot_replace_already_verified_payment(self): + pool = make_completed_pool([ + { + "name": "Asha", + "utr": "123456789012", + "status": "verified", + "submitted_at": datetime.datetime.utcnow().isoformat(), + "expected_amount": 10000, + } + ]) + db = make_db(pool, make_items()) + + with patch("app.api.pools.get_db", return_value=db): + with self.assertRaises(HTTPException) as error: + asyncio.run(payment_confirm( + "pool-1", + PaymentConfirmReq(roommate_name="Asha", utr="987654321098"), + user_id="asha-right", + )) + + self.assertEqual(error.exception.status_code, 409) + self.assertEqual(pool["payments"][0]["status"], "verified") + self.assertEqual(pool["payments"][0]["utr"], "123456789012") + + def test_anonymous_user_cannot_submit_pool_utr(self): + pool = make_completed_pool() + db = make_db(pool, make_items()) + + with patch("app.api.pools.get_db", return_value=db): + with self.assertRaises(HTTPException) as error: + asyncio.run(payment_confirm( + "pool-1", + PaymentConfirmReq(roommate_name="Asha", utr="123456789012"), + user_id=None, + )) + + self.assertEqual(error.exception.status_code, 401) + self.assertEqual(pool["payments"], []) + + def test_roommate_cannot_submit_utr_for_another_participant(self): + pool = make_completed_pool() + items = make_items() + [{ + "_id": "item-2", + "pool_id": "pool-1", + "added_by_name": "Rohan", + "added_by_user_id": "rohan-id", + "estimated_price": 12000, + "is_purchased": True, + }] + db = make_db( + pool, + items, + users=[ + {"_id": "asha-right", "full_name": "Asha"}, + {"_id": "rohan-id", "full_name": "Rohan"}, + ], + ) + + with patch("app.api.pools.get_db", return_value=db): + with self.assertRaises(HTTPException) as error: + asyncio.run(payment_confirm( + "pool-1", + PaymentConfirmReq(roommate_name="Rohan", utr="123456789012"), + user_id="asha-right", + )) + + self.assertEqual(error.exception.status_code, 403) + self.assertEqual(pool["payments"], []) + + def test_roommate_can_submit_utr_only_for_own_split(self): + pool = make_completed_pool() + db = make_db( + pool, + make_items(), + users=[ + {"_id": "asha-right", "full_name": "Asha"}, + ], + ) + + with patch("app.api.pools.get_db", return_value=db): + updated = asyncio.run(payment_confirm( + "pool-1", + PaymentConfirmReq(roommate_name="Asha", utr="123456789012"), + user_id="asha-right", + )) + + self.assertEqual(pool["payments"][0]["status"], "pending") + self.assertEqual(pool["payments"][0]["utr"], "123456789012") + + @patch("app.api.pools.get_db") + def test_roommate_cannot_sandbox_settle_another_participant(self, mock_get_db): + pool = make_completed_pool() + items = make_items() + [ + { + "_id": "item-rohan", + "pool_id": "pool-1", + "added_by_name": "Rohan", + "added_by_user_id": "rohan-id", + "estimated_price": 7000, + "is_purchased": True, + } + ] + db = make_db( + pool, + items, + users=[ + {"_id": "asha-right", "full_name": "Asha"}, + {"_id": "rohan-id", "full_name": "Rohan"}, + ], + ) + mock_get_db.return_value = db + + with self.assertRaises(HTTPException) as ctx: + asyncio.run(process_amazon_roommate_payment( + "pool-1", + RoommateAmznPayReq(roommate_name="Asha", amount=10000), + user_id="rohan-id", + )) + + self.assertEqual(ctx.exception.status_code, 403) + self.assertEqual(pool["payments"], []) + + @patch("app.api.pools.get_db") + def test_roommate_can_sandbox_settle_only_own_split(self, mock_get_db): + pool = make_completed_pool() + db = make_db( + pool, + make_items(), + users=[{"_id": "asha-right", "full_name": "Asha"}], + ) + mock_get_db.return_value = db + + response = asyncio.run(process_amazon_roommate_payment( + "pool-1", + RoommateAmznPayReq(roommate_name="Asha", amount=10000), + user_id="asha-right", + )) + + self.assertEqual(response["chargePermissionStatus"]["state"], "Chargeable") + self.assertEqual(pool["payments"][0]["status"], "verified") + self.assertEqual(pool["payments"][0]["settlement_mode"], "amazon_pay_sandbox") + self.assertTrue(pool["payments"][0]["utr"].startswith("B01-")) + self.assertEqual(db.transactions.docs[0]["user_id"], "asha-right") + + def test_refund_credit_is_not_treated_as_pool_payment(self): + pool = make_completed_pool() + db = make_db(pool, make_items()) + + result = asyncio.run( + try_auto_verify_pool_payment( + db, + "host-1", + "Refund credited Rs 100 UPI Ref 123456789012", + amount_from_req=100.0, + direction_from_req="credit", + ) + ) + + self.assertIsNone(result) + self.assertEqual(pool["payments"], []) + + def test_unpaid_split_after_checkout_is_overdue(self): + pool = { + "created_by_name": "Host", + "completed_at": datetime.datetime.utcnow() - datetime.timedelta(hours=30), + } + + state = build_payment_state(pool, "Asha", None, 10000) + + self.assertEqual(state["status"], "unpaid") + self.assertTrue(state["is_overdue"]) + self.assertEqual(state["label"], "Overdue") + + def test_public_pool_enrichment_hides_private_settlement_fields(self): + pool = make_completed_pool([ + { + "name": "Asha", + "utr": "123456789012", + "status": "pending", + "expected_amount": 10000, + "verification_source": "manual_utr", + } + ]) + db = make_db( + pool, + make_items(), + users=[ + {"_id": "host-1", "full_name": "Host", "phone_number": "9876543210", "email": "host@example.com"}, + {"_id": "asha-right", "full_name": "Asha", "phone_number": "9999999999", "email": "asha@example.com"}, + ], + profiles=[ + {"_id": "host-1", "wing_label": "BH-2 Wing B", "companion_paired": True}, + {"_id": "asha-right", "wing_label": "BH-2 Wing B"}, + ], + ) + + enriched = asyncio.run(enrich_pool_document(db, dict(pool), current_user_id=None)) + + self.assertEqual(enriched["host_phone"], "") + self.assertEqual(enriched["payments"], []) + self.assertEqual(enriched["reliability_scores"], {}) + self.assertEqual(enriched["settlement_summary"], {}) + self.assertEqual(enriched["wing_members"], []) + self.assertEqual(enriched["split_breakdown"]["Asha"]["email"], "") + self.assertEqual(enriched["split_breakdown"]["Asha"]["utr"], "") + self.assertIsNone(enriched["split_breakdown"]["Asha"]["verification_source"]) + + def test_host_pool_enrichment_uses_pool_user_id_for_participant_contact(self): + pool = make_completed_pool([ + { + "name": "Asha", + "utr": "123456789012", + "status": "pending", + "expected_amount": 10000, + "verification_source": "manual_utr", + } + ]) + db = make_db( + pool, + make_items(), + users=[ + {"_id": "asha-wrong", "full_name": "Asha", "phone_number": "1111111111", "email": "wrong-wing@example.com"}, + {"_id": "host-1", "full_name": "Host", "phone_number": "9876543210", "email": "host@example.com"}, + {"_id": "asha-right", "full_name": "Asha", "phone_number": "9999999999", "email": "asha@example.com"}, + ], + profiles=[ + {"_id": "host-1", "wing_label": "BH-2 Wing B", "companion_paired": True}, + {"_id": "asha-right", "wing_label": "BH-2 Wing B"}, + {"_id": "asha-wrong", "wing_label": "Other Wing"}, + ], + ) + + enriched = asyncio.run(enrich_pool_document(db, dict(pool), current_user_id="host-1")) + + self.assertEqual(enriched["host_phone"], "9876543210") + self.assertEqual(enriched["payments"][0]["utr"], "123456789012") + self.assertEqual(enriched["split_breakdown"]["Asha"]["email"], "asha@example.com") + self.assertEqual(enriched["split_breakdown"]["Asha"]["utr"], "123456789012") + + def test_public_pool_item_response_hides_internal_user_id(self): + item = { + "_id": "item-1", + "pool_id": "pool-1", + "added_by_name": "Asha", + "added_by_user_id": "asha-right", + "item_description": "Tea", + "estimated_price": 1000, + "item_updated_by": "roommate", + } + + sanitized = sanitize_pool_item_for_viewer(item, current_user_id=None, host_id="host-1") + + self.assertEqual(sanitized["added_by_name"], "Asha") + self.assertEqual(sanitized["item_description"], "Tea") + self.assertNotIn("added_by_user_id", sanitized) + self.assertNotIn("item_updated_by", sanitized) + + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/src/routes/_authenticated/pool/index.lazy.tsx b/frontend/src/routes/_authenticated/pool/index.lazy.tsx index fef9558..021757f 100644 --- a/frontend/src/routes/_authenticated/pool/index.lazy.tsx +++ b/frontend/src/routes/_authenticated/pool/index.lazy.tsx @@ -426,6 +426,16 @@ function PoolCard({ pool }: { pool: Pool }) { Verifying your split of {rupees(rSummary.myOwed)} (UTR submitted) + ) : rSummary.myStatus === "needs_review" ? ( +
+ + Your split of {rupees(rSummary.myOwed)} needs host review +
+ ) : rSummary.myStatus === "rejected" ? ( +
+ + Your last UTR was rejected. Settle {rupees(rSummary.myOwed)} with host +
) : (
@@ -456,7 +466,15 @@ function PoolCard({ pool }: { pool: Pool }) { } else if (status === "pending") { dotColor = "bg-amber-500 animate-pulse"; textColor = "text-amber-400"; - label = "Verifying"; + label = "UTR pending"; + } else if (status === "needs_review") { + dotColor = "bg-orange-500 animate-pulse"; + textColor = "text-orange-400"; + label = "Review"; + } else if (status === "rejected") { + dotColor = "bg-rose-500"; + textColor = "text-rose-400"; + label = "Rejected"; } return ( diff --git a/frontend/src/routes/pool.$id.lazy.tsx b/frontend/src/routes/pool.$id.lazy.tsx index 53c7934..166e175 100644 --- a/frontend/src/routes/pool.$id.lazy.tsx +++ b/frontend/src/routes/pool.$id.lazy.tsx @@ -11,7 +11,7 @@ import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; -import { ChevronLeft, Share2, Trash2, Check, X, AlertCircle, Sparkles, ExternalLink, User, ShoppingBag, Clock, Shield, Link as LinkIcon, Bell } from "lucide-react"; +import { ChevronLeft, Share2, Trash2, Check, X, AlertCircle, Sparkles, ExternalLink, User, ShoppingBag, Clock, Shield, Link as LinkIcon, Bell, Eye, EyeOff, Smartphone, Plus, Minus, Pencil, CheckCircle2, ClipboardList } from "lucide-react"; import { toast } from "sonner"; import { rupees, relativeTime } from "@/lib/format"; import { useAuth } from "@/lib/auth-context"; @@ -47,9 +47,18 @@ type SplitBreakdownEntry = { email?: string; paid: boolean; paymentStatus: string; + paymentLabel?: string; + paymentTone?: string; + paymentDetail?: string; + isOverdue?: boolean; + overdueHours?: number; utr: string; settlementMode?: string | null; confidence?: string | null; + verificationSource?: string | null; + reviewReason?: string | null; + expectedAmount?: number; + matchedAmount?: number | null; }; const BRAND_THEMES: Record = { @@ -117,12 +126,88 @@ function listActiveParticipants(itemsList: any[]) { ); } +function statusToneClass(tone?: string, status?: string) { + const key = tone || status; + if (key === "success" || status === "verified") return "bg-green-600/10 border-green-600/25 text-green-500"; + if (key === "danger" || status === "rejected") return "bg-destructive/10 border-destructive/25 text-destructive"; + if (status === "needs_review") return "bg-orange-500/10 border-orange-500/25 text-orange-400"; + return "bg-orange-600/10 border-orange-600/25 text-orange-600"; +} + +function paymentStatusLabel(status?: string) { + if (status === "verified") return "Verified"; + if (status === "pending") return "UTR pending"; + if (status === "needs_review") return "Needs review"; + if (status === "rejected") return "Rejected"; + if (status === "host") return "Host share"; + return "Unpaid"; +} + +function itemQuantity(it: any) { + const qty = Number(it?.quantity ?? 1); + return Number.isFinite(qty) && qty > 0 ? qty : 1; +} + +function itemUnitPrice(it: any) { + const unit = Number(it?.unit_price ?? 0); + if (Number.isFinite(unit) && unit > 0) return unit; + return Math.round(Number(it?.estimated_price ?? 0) / itemQuantity(it)); +} + +function cartStatusLabel(status?: string) { + if (status === "added") return "Added"; + if (status === "substituted") return "Substitute"; + if (status === "unavailable") return "Unavailable"; + if (status === "skipped") return "Skipped"; + if (status === "mixed") return "Mixed"; + return "Pending"; +} + +function cartStatusClass(status?: string) { + if (status === "added") return "border-green-600/25 bg-green-600/10 text-green-600"; + if (status === "substituted") return "border-blue-600/20 bg-blue-600/5 text-blue-600"; + if (status === "unavailable" || status === "skipped") return "border-destructive/25 bg-destructive/10 text-destructive"; + if (status === "mixed") return "border-border bg-muted/30 text-muted-foreground"; + return "border-border bg-muted/20 text-muted-foreground"; +} + +function cartStatusReason(status?: string) { + if (status === "substituted") return "Host added a substitute for this product."; + if (status === "unavailable") return "This product was unavailable in the app."; + if (status === "skipped") return "Host skipped this item before checkout."; + return "This item was updated by the host."; +} + +function needsRoommateAttention(it: any) { + return Boolean(it?.item_update_reason) || ["substituted", "unavailable", "skipped"].includes(it?.cart_status || "") || it?.is_purchased === false; +} + +function itemNoticeLabel(it: any) { + if (it?.item_update_reason && (!it?.cart_status || it.cart_status === "pending")) return "Updated"; + return cartStatusLabel(it?.cart_status || "skipped"); +} + +function itemNoticeReason(it: any) { + if (it?.item_update_reason) return it.item_update_reason; + if (it?.cart_status_reason) return it.cart_status_reason; + if (it?.is_purchased === false) return "This item is no longer part of the split."; + return cartStatusReason(it?.cart_status); +} + +function itemActivityTime(it: any) { + return new Date(it?.item_updated_at || it?.cart_status_updated_at || it?.created_at || 0).getTime(); +} + +function participantKey(value: any) { + return String(value ?? "").trim().toLowerCase(); +} + function isHostParticipant(pool: Pool | null | undefined, participantName: string, user: any) { if (!pool) return false; - const pName = participantName.trim().toLowerCase(); - const hostName = (pool.created_by_name ?? "").trim().toLowerCase(); + const pName = participantKey(participantName); + const hostName = participantKey(pool.created_by_name); if (pName === "host" || pName === hostName) { return true; @@ -136,7 +221,7 @@ function isHostParticipant(pool: Pool | null | undefined, participantName: strin } if (user && user.fullName) { - const userFull = user.fullName.trim().toLowerCase(); + const userFull = participantKey(user.fullName); if (pName === userFull && pool.host_id === user.id) { return true; } @@ -189,9 +274,18 @@ function PoolDetail() { ); const [item, setItem] = useState(""); const [price, setPrice] = useState(""); + const [itemQty, setItemQty] = useState(1); const [productUrl, setProductUrl] = useState(""); const [busy, setBusy] = useState(false); const [addItemOpen, setAddItemOpen] = useState(false); + const [expandedRoommates, setExpandedRoommates] = useState>({}); + const [cartBuilderExpanded, setCartBuilderExpanded] = useState(false); + const [editItemOpen, setEditItemOpen] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [editItemName, setEditItemName] = useState(""); + const [editPrice, setEditPrice] = useState(""); + const [editItemQty, setEditItemQty] = useState(1); + const [editProductUrl, setEditProductUrl] = useState(""); // Host checkout modal state const [checkoutOpen, setCheckoutOpen] = useState(false); @@ -200,9 +294,6 @@ function PoolDetail() { const [finalDiscount, setFinalDiscount] = useState(""); const [hostUpi, setHostUpi] = useState(""); - // Roommate selected identity for payment details - const [selectedPayeeName, setSelectedPayeeName] = useState(""); - // UTR confirmation state const [confirmUtrOpen, setConfirmUtrOpen] = useState(false); const [utrInput, setUtrInput] = useState(""); @@ -237,6 +328,7 @@ function PoolDetail() { const [authName, setAuthName] = useState(""); const [authEmail, setAuthEmail] = useState(""); const [authPassword, setAuthPassword] = useState(""); + const [showAuthPassword, setShowAuthPassword] = useState(false); const [authPhone, setAuthPhone] = useState(""); const [authBusy, setAuthBusy] = useState(false); @@ -273,7 +365,6 @@ function PoolDetail() { onboarding_completed: true, setup_completed: true, phone: cleanPhone, - wing_label: pool.wing_label // Auto-add to the host's wing for rating compatibility } }); toast.success("Joined pool successfully!"); @@ -418,7 +509,34 @@ function PoolDetail() { }; // Group items by roommate - const itemsWithLinks = allItems.filter((i: any) => i.product_url && i.is_purchased !== false); + const roommateRequestItems = allItems.filter((i: any) => !isHostParticipant(pool, i.added_by_name, user)); + const hostRunnerItems = roommateRequestItems.filter((i: any) => i.product_url); + const missingLinkItems = roommateRequestItems.filter((i: any) => !i.product_url && i.is_purchased !== false); + const cartRunnerGroups = Object.values( + hostRunnerItems.reduce((acc: Record, it: any) => { + const url = formatExternalUrl(it.product_url); + if (!url) return acc; + const key = url.toLowerCase(); + const group = acc[key] ??= { + key, + url, + title: it.item_description, + items: [], + totalQty: 0, + totalEstimate: 0, + }; + group.items.push(it); + group.totalQty += itemQuantity(it); + group.totalEstimate += Number(it.estimated_price ?? 0); + return acc; + }, {}), + ) as any[]; + const resolvedCartGroups = cartRunnerGroups.filter((group: any) => + group.items.every((it: any) => ["added", "substituted", "unavailable", "skipped"].includes(it.cart_status || "")), + ).length; + const pendingRoommateRequests = roommateRequestItems.filter((i: any) => i.is_purchased !== false && (!i.cart_status || i.cart_status === "pending")); + const visibleCartRunnerGroups = cartBuilderExpanded ? cartRunnerGroups : cartRunnerGroups.slice(0, 8); + const hiddenCartRunnerGroups = cartRunnerGroups.length - visibleCartRunnerGroups.length; const purchasedItems = allItems.filter((i: any) => i.is_purchased !== false); const cartTotal = purchasedItems.reduce((s: number, i: any) => s + i.estimated_price, 0); const cartPct = Math.min(100, Math.round((cartTotal / pool.min_cart_value) * 100)); @@ -447,6 +565,8 @@ function PoolDetail() { const payment = (pool.payments ?? []).find((pay: any) => pay.name.trim().toLowerCase() === p.trim().toLowerCase()); const isHostUser = isHostParticipant(pool, p, user); + const serverSplit = (pool.split_breakdown ?? {})[p] ?? {}; + const fallbackStatus = isHostUser ? "host" : (payment ? payment.status : "unpaid"); splitBreakdown[p] = { name: p, @@ -454,10 +574,19 @@ function PoolDetail() { share: overheadShare, total: pItemsTotal + overheadShare, paid: isHostUser ? true : (payment ? payment.status === "verified" : false), - paymentStatus: isHostUser ? "host" : (payment ? payment.status : "unpaid"), + paymentStatus: serverSplit.payment_status ?? fallbackStatus, + paymentLabel: serverSplit.payment_label ?? paymentStatusLabel(fallbackStatus), + paymentTone: serverSplit.payment_tone, + paymentDetail: serverSplit.payment_detail, + isOverdue: Boolean(serverSplit.is_overdue), + overdueHours: serverSplit.overdue_hours ?? 0, utr: payment ? payment.utr : "", settlementMode: payment?.settlement_mode ?? null, confidence: payment?.confidence ?? null, + verificationSource: payment?.verification_source ?? serverSplit.verification_source ?? null, + reviewReason: payment?.review_reason ?? serverSplit.review_reason ?? null, + expectedAmount: serverSplit.expected_amount ?? pItemsTotal + overheadShare, + matchedAmount: serverSplit.matched_amount ?? null, }; }); } else { @@ -479,13 +608,37 @@ function PoolDetail() { total: pItemsTotal + deliveryPerPerson, paid: isHostUser ? true : false, paymentStatus: isHostUser ? "host" : "unpaid", + paymentLabel: isHostUser ? "Host share" : "Unpaid", + paymentTone: isHostUser ? "success" : "warning", + paymentDetail: "", + isOverdue: false, + overdueHours: 0, utr: "", settlementMode: null, confidence: null, + verificationSource: null, + reviewReason: null, + expectedAmount: pItemsTotal + deliveryPerPerson, + matchedAmount: null, }; }); } + const selfPayeeName = ( + (user?.id + ? participants.find((p) => + allItems.some( + (it: any) => + it.added_by_user_id === user.id && + participantKey(it.added_by_name) === participantKey(p), + ), + ) + : "") || + participants.find((p) => participantKey(p) === participantKey(user?.fullName)) || + participants.find((p) => participantKey(p) === participantKey(name)) || + "" + ); + // Actions async function addItem() { if (!name.trim() || !item.trim() || !price.trim()) { @@ -495,7 +648,16 @@ function PoolDetail() { const numericPrice = parseFloat(price.trim()); if (isNaN(numericPrice) || numericPrice <= 0 || numericPrice > 5000) { - toast.error("Estimated price must be between ₹1 and ₹5,000"); + toast.error("Unit price must be between ₹1 and ₹5,000"); + return; + } + if (!Number.isInteger(itemQty) || itemQty < 1 || itemQty > 50) { + toast.error("Quantity must be between 1 and 50"); + return; + } + const totalPrice = numericPrice * itemQty; + if (totalPrice <= 0 || totalPrice > 5000) { + toast.error("Total item estimate must be between ₹1 and ₹5,000"); return; } @@ -515,12 +677,15 @@ function PoolDetail() { pool_id: id, added_by_name: name.trim(), item_description: item.trim(), - estimated_price: Math.round(numericPrice * 100), - product_url: productUrl.trim() || null, + estimated_price: Math.round(totalPrice * 100), + quantity: itemQty, + unit_price: Math.round(numericPrice * 100), + product_url: isHost ? null : productUrl.trim() || null, }, }); setItem(""); setPrice(""); + setItemQty(1); setProductUrl(""); setAddItemOpen(false); toast.success("Item added!"); @@ -532,8 +697,75 @@ function PoolDetail() { } } + function openEditItem(it: any) { + const unitPrice = itemUnitPrice(it) / 100; + setEditingItem(it); + setEditItemName(it.item_description || ""); + setEditPrice(Number.isInteger(unitPrice) ? String(unitPrice) : unitPrice.toFixed(2).replace(/\.?0+$/, "")); + setEditItemQty(itemQuantity(it)); + setEditProductUrl(it.product_url || ""); + setEditItemOpen(true); + } + + function resetEditItem() { + setEditingItem(null); + setEditItemName(""); + setEditPrice(""); + setEditItemQty(1); + setEditProductUrl(""); + setEditItemOpen(false); + } + + async function saveEditedItem() { + if (!editingItem) return; + if (!editItemName.trim()) { + toast.error("Please enter an item name"); + return; + } + + const numericPrice = parseFloat(editPrice.trim()); + if (isNaN(numericPrice) || numericPrice <= 0 || numericPrice > 5000) { + toast.error("Unit price must be between ₹1 and ₹5,000"); + return; + } + if (!Number.isInteger(editItemQty) || editItemQty < 1 || editItemQty > 50) { + toast.error("Quantity must be between 1 and 50"); + return; + } + + const totalPrice = numericPrice * editItemQty; + if (totalPrice <= 0 || totalPrice > 5000) { + toast.error("Total item estimate must be between ₹1 and ₹5,000"); + return; + } + + const editingHostItem = isHostParticipant(pool, editingItem.added_by_name, user); + setBusy(true); + try { + await updateCartPoolItem({ + pool_id: id, + item_id: editingItem.id, + data: { + item_description: editItemName.trim(), + estimated_price: Math.round(totalPrice * 100), + quantity: editItemQty, + unit_price: Math.round(numericPrice * 100), + product_url: editingHostItem ? null : editProductUrl.trim() || null, + }, + }); + toast.success("Item updated"); + resetEditItem(); + qc.invalidateQueries({ queryKey: ["pool-items", id] }); + qc.invalidateQueries({ queryKey: ["pool", id] }); + } catch (err: any) { + toast.error(err.message || "Failed to update item"); + } finally { + setBusy(false); + } + } + async function deleteItem(itemId: string) { - if (!confirm("Remove this item?")) return; + if (!confirm("Remove this item from your cart?")) return; try { await deleteCartPoolItem({ pool_id: id, item_id: itemId }); toast.success("Item removed"); @@ -544,19 +776,119 @@ function PoolDetail() { } async function toggleAvailability(itemId: string, currentStatus: boolean) { + const nextStatus = currentStatus ? "unavailable" : "pending"; try { await updateCartPoolItem({ pool_id: id, item_id: itemId, - data: { is_purchased: !currentStatus } + data: { + is_purchased: !currentStatus, + cart_status: nextStatus, + cart_status_reason: currentStatus + ? "Host marked this product unavailable in the delivery app." + : "Host moved this product back to the active cart.", + } }); - toast.success("Item updated"); + toast.success(currentStatus ? "Roommate will see this item as unavailable." : "Item restored to the active cart."); qc.invalidateQueries({ queryKey: ["pool-items", id] }); } catch (err: any) { toast.error("Failed to update item availability"); } } + async function skipRoommateItem(it: any) { + if (!confirm(`Skip "${it.item_description}" and notify ${it.added_by_name}?`)) return; + try { + await updateCartPoolItem({ + pool_id: id, + item_id: it.id, + data: { + is_purchased: false, + cart_status: "skipped", + cart_status_reason: "Host removed this item from the shared cart.", + }, + }); + toast.success(`${it.added_by_name} will see this item as skipped.`); + qc.invalidateQueries({ queryKey: ["pool-items", id] }); + qc.invalidateQueries({ queryKey: ["pool", id] }); + } catch (err: any) { + toast.error(err.message || "Failed to skip item"); + } + } + + async function updateCartRunnerGroup(groupItems: any[], status: "added" | "substituted" | "unavailable" | "skipped") { + const isPurchased = status === "added" || status === "substituted"; + const reason = + status === "added" + ? "Host added this product to the delivery cart." + : status === "substituted" + ? "Host added a substitute or equivalent product." + : status === "unavailable" + ? "This product was unavailable in the delivery app." + : "Host skipped this product before checkout."; + setBusy(true); + try { + await Promise.all(groupItems.map((it) => + updateCartPoolItem({ + pool_id: id, + item_id: it.id, + data: { + cart_status: status, + is_purchased: isPurchased, + cart_status_reason: reason, + }, + }), + )); + toast.success( + status === "added" + ? "Marked product as added." + : status === "substituted" + ? "Marked product as substituted. Roommates will see the update." + : status === "unavailable" + ? "Marked product unavailable and notified roommates in the cart." + : "Skipped product and notified roommates in the cart.", + ); + qc.invalidateQueries({ queryKey: ["pool-items", id] }); + qc.invalidateQueries({ queryKey: ["pool", id] }); + } catch (err: any) { + toast.error(err.message || "Failed to update cart runner"); + } finally { + setBusy(false); + } + } + + async function copyCartPlan() { + const linkedLines = cartRunnerGroups.map((group: any) => + `- ${group.title} x${group.totalQty} (${group.url})`, + ); + const missingLines = missingLinkItems.map((it: any) => + `- ${it.item_description} x${itemQuantity(it)} (${it.added_by_name}, no link)`, + ); + const text = [ + `${theme.name} Pool Cart Plan`, + linkedLines.length ? "Linked requests:" : "", + ...linkedLines, + missingLines.length ? "Manual search:" : "", + ...missingLines, + ].filter(Boolean).join("\n"); + + let copied = false; + if (navigator.clipboard && navigator.clipboard.writeText) { + try { + await navigator.clipboard.writeText(text); + copied = true; + } catch (err) {} + } + if (!copied) { + copied = await fallbackCopyText(text); + } + if (copied) { + toast.success("Cart plan copied."); + } else { + toast.error("Failed to copy cart plan."); + } + } + async function fallbackCopyText(text: string): Promise { const textArea = document.createElement("textarea"); textArea.value = text; @@ -610,10 +942,28 @@ function PoolDetail() { } async function completeCheckout() { + if (!hostUpi.trim()) { + toast.error("Enter your UPI address before manual split checkout."); + return; + } + + const deliveryAmount = parseFloat(finalDeliveryFee || "0"); + const surgeAmount = parseFloat(finalSurgeFee || "0"); + const discountAmount = parseFloat(finalDiscount || "0"); + if (![deliveryAmount, surgeAmount, discountAmount].every((value) => Number.isFinite(value) && value >= 0)) { + toast.error("Final fees and discounts must be valid non-negative amounts."); + return; + } + + if (pendingRoommateRequests.length > 0) { + const proceed = confirm(`${pendingRoommateRequests.length} roommate request${pendingRoommateRequests.length === 1 ? "" : "s"} still need host review. Finalize anyway?`); + if (!proceed) return; + } + setBusy(true); try { - const overheadValue = Math.round((parseFloat(finalDeliveryFee || "0") + parseFloat(finalSurgeFee || "0")) * 100); - const discountValue = Math.round(parseFloat(finalDiscount || "0") * 100); + const overheadValue = Math.round((deliveryAmount + surgeAmount) * 100); + const discountValue = Math.round(discountAmount * 100); await updateCartPool({ id, @@ -638,10 +988,23 @@ function PoolDetail() { } async function initiateAmazonCheckout() { + const deliveryAmount = parseFloat(finalDeliveryFee || "0"); + const surgeAmount = parseFloat(finalSurgeFee || "0"); + const discountAmount = parseFloat(finalDiscount || "0"); + if (![deliveryAmount, surgeAmount, discountAmount].every((value) => Number.isFinite(value) && value >= 0)) { + toast.error("Final fees and discounts must be valid non-negative amounts."); + return; + } + + if (pendingRoommateRequests.length > 0) { + const proceed = confirm(`${pendingRoommateRequests.length} roommate request${pendingRoommateRequests.length === 1 ? "" : "s"} still need host review. Continue to sandbox checkout?`); + if (!proceed) return; + } + setBusy(true); try { - const overheadValue = Math.round((parseFloat(finalDeliveryFee || "0") + parseFloat(finalSurgeFee || "0")) * 100); - const discountValue = Math.round(parseFloat(finalDiscount || "0") * 100); + const overheadValue = Math.round((deliveryAmount + surgeAmount) * 100); + const discountValue = Math.round(discountAmount * 100); const res = await createAmazonCheckoutSession({ pool_id: id, @@ -727,9 +1090,9 @@ function PoolDetail() { toast.error("Enter a valid 12-digit numeric UPI Ref / UTR number"); return; } - const targetRoommate = selectedPayeeName || name; + const targetRoommate = selfPayeeName; if (!targetRoommate) { - toast.error("Please select or type your roommate name first."); + toast.error("Your account is not attached to a payable split in this pool."); return; } setBusy(true); @@ -784,8 +1147,8 @@ function PoolDetail() { data: { roommate_name: roommateName } }); toast.dismiss(toastId); - if (res && res.success && res.mode === "automated") { - toast.success(`Automated WhatsApp nudge sent to ${roommateName}!`); + if (res?.success) { + toast.success(res.message || `WhatsApp nudge sent to ${roommateName}!`); return; } } catch (err: any) { @@ -806,7 +1169,7 @@ function PoolDetail() { } // Generate UPI pay deep link - const payeeDetails = splitBreakdown[selectedPayeeName || name]; + const payeeDetails = selfPayeeName ? splitBreakdown[selfPayeeName] : undefined; const upiPayUrl = pool.upi_id && payeeDetails ? `upi://pay?pa=${pool.upi_id}&pn=${encodeURIComponent(pool.created_by_name || "Host")}&am=${(payeeDetails.total / 100).toFixed(2)}&tn=${encodeURIComponent(`PocketBuddy ${theme.name} Pool Split`)}&cu=INR` : ""; @@ -814,6 +1177,9 @@ function PoolDetail() { const qrCodeUrl = upiPayUrl ? `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(upiPayUrl)}` : ""; + const settlementSummary = pool.settlement_summary ?? {}; + const hostAndroidStatus = pool.host_android_status ?? settlementSummary.host_android_status; + const editingHostItem = editingItem ? isHostParticipant(pool, editingItem.added_by_name, user) : false; if (!user) { return ( @@ -877,15 +1243,26 @@ function PoolDetail() {
- setAuthPassword(e.target.value)} - placeholder="••••••••" - className="bg-background text-sm h-10" - required - /> +
+ setAuthPassword(e.target.value)} + placeholder="********" + className="bg-background text-sm h-10 pr-10" + required + /> + +
- + Pooler @@ -969,10 +1346,7 @@ function PoolDetail() { jiomart: "border-t-[#0078AD]", amazon_now: "border-t-[#FF9900]", } as Record)[pool.platform] || "border-t-primary" - } px-6 py-8 text-foreground flex flex-col justify-between relative overflow-hidden rounded-2xl shadow-lg shadow-black/30`}> -
- -
+ } px-6 py-8 text-foreground flex flex-col justify-between relative overflow-hidden rounded-2xl shadow-sm`}>

Cart Pooling

@@ -1011,20 +1385,20 @@ function PoolDetail() {
{/* Status Callouts */} {pool.status === "completed" && isFullySettled && ( -
+
- +
-

Pool Fully Settled

-

- Congratulations! All roommate splits for this {theme.name} pool have been paid and verified. No outstanding balances remain. +

Pool Fully Settled

+

+ All roommate splits for this {theme.name} pool are paid and verified. No outstanding balances remain.

{pool.checkout_notes && ( -
- Host Note / Message -

+

+ Host Note / Message +

"{pool.checkout_notes}"

@@ -1076,6 +1450,20 @@ function PoolDetail() {
)} + {pool.status === "closed" && ( +
+
+ +
+

Pool Closed

+

+ The join window has expired. Items remain visible for reference, but checkout and payment collection are not active. +

+
+
+
+ )} + {/* Progress bar to target minimum */} {pool.status === "open" && ( @@ -1107,6 +1495,34 @@ function PoolDetail() { {pool.status === "open" ? (
+ {hostAndroidStatus && ( +
+ +
+

{hostAndroidStatus.label}

+

+ {hostAndroidStatus.can_auto_verify + ? "Incoming roommate credits can auto-match after checkout when sender or UTR is clear." + : "Pair the Android connector before checkout if you want repayment credits to auto-match."} +

+
+ {!hostAndroidStatus.can_auto_verify && ( + + )} +
+ )} +
- {itemsWithLinks.length > 0 && ( -
-

- - Roommate Item Links - {itemsWithLinks.length} -

-
- {itemsWithLinks.map((it: any) => ( - 0 || missingLinkItems.length > 0) && ( +
+ + + {cartRunnerGroups.length > 0 && ( +
+ {visibleCartRunnerGroups.map((group: any) => { + const statuses = Array.from(new Set(group.items.map((it: any) => it.cart_status || "pending"))); + const status = statuses.length === 1 ? String(statuses[0]) : "mixed"; + const roommateBreakdown = group.items + .map((it: any) => `${it.added_by_name} x${itemQuantity(it)}`) + .join(" / "); + + return ( +
+
+
+
+

+ {group.title} +

+ + {cartStatusLabel(status)} + +
+

+ Qty {group.totalQty} - Est. {rupees(group.totalEstimate)} - {roommateBreakdown} +

+
+ +
+ + Open + + + + + +
+
+
+ ); + })} + {cartRunnerGroups.length > 8 && ( +
+ +
+ )} +
+ )} + + {pendingRoommateRequests.length > 0 && ( +
+ {pendingRoommateRequests.length} active request{pendingRoommateRequests.length === 1 ? "" : "s"} pending host review.{" "} + Mark linked requests as added, substituted, unavailable, or skipped before checkout when possible. +
+ )} + + {missingLinkItems.length > 0 && ( +
+ {missingLinkItems.length} active item{missingLinkItems.length === 1 ? "" : "s"} missing links.{" "} + Ask roommates to add product links, or search them manually from the list below. +
+ )}
)}
) : pool.status === "completed" ? (
-

- Order split active. Mark credits only after the host has seen the incoming transfer: -

+
+
+

+ {isFullySettled ? "Settlement closed" : "Collection queue"} +

+

+ {isFullySettled + ? "All roommate splits are verified. This pool is now a receipt." + : "Review pending UTRs, nudge unpaid roommates, or close an agreed in-kind settlement."} +

+
+ {!isFullySettled && ( + + {settlementSummary.next_action || "Action needed"} + + )} +
+ + {!isFullySettled && hostAndroidStatus && ( +
+ +
+

{hostAndroidStatus.label}

+

{hostAndroidStatus.detail}

+
+ {!hostAndroidStatus.can_auto_verify && ( + + )} +
+ )} + + {settlementSummary.total_roommates > 0 && ( +
+
+

Outstanding

+

{rupees(settlementSummary.outstanding_total ?? 0)}

+
+
+

Review

+

{(settlementSummary.pending ?? 0) + (settlementSummary.needs_review ?? 0) + (settlementSummary.rejected ?? 0)}

+
+
+

Overdue

+

{settlementSummary.overdue ?? 0}

+
+
+ )} {/* Host Payment Verification Checklist */}
@@ -1176,7 +1775,13 @@ function PoolDetail() { return roommates.map((rName) => { const details = breakdown[rName]; - const rel = (pool.reliability_scores ?? {})[rName] ?? { score: 90, label: "New roommate", color: "blue" }; + const rel = (pool.reliability_scores ?? {})[rName] ?? { + score: 60, + label: "New roommate", + color: "blue", + explanation: "No completed split history yet.", + signals: { completed_splits: 0, unsettled_splits: 0, late_splits: 0 }, + }; let badgeColor = "bg-blue-500/10 text-blue-500 border-blue-500/20"; if (rel.color === "green") badgeColor = "bg-green-500/10 text-green-500 border-green-500/20"; @@ -1184,75 +1789,94 @@ function PoolDetail() { else if (rel.color === "red") badgeColor = "bg-red-500/10 text-red-500 border-red-500/20"; return ( -
-
-
-

- {rName} +

+
+
+
+ {rName} - {rel.label} ({rel.score}%) + {rel.label} ({rel.score ?? 60}%) + {!details.paid && ( + + {details.payment_label || paymentStatusLabel(details.payment_status)} + + )} + {details.is_overdue && ( + + Overdue {Math.round(details.overdue_hours || 0)}h + + )} +
+

{rupees(details.total)}

+

+ {rel.explanation}

+ {rel.signals && ( +

+ {rel.signals.completed_splits ?? 0} settled / {rel.signals.unsettled_splits ?? 0} open / {rel.signals.late_splits ?? 0} late +

+ )} {details.email && (

{details.email}

)} -

- Share: {rupees(details.total)} -

{details.utr && (

UTR: {details.utr}

)} + {details.payment_detail && !details.paid && ( +

+ {details.payment_detail} +

+ )}
-
+
{details.paid ? ( -
+
VERIFIED - {details.settlementMode === "settle_in_kind" && ( - + {(details.settlementMode === "settle_in_kind" || details.settlement_mode === "settle_in_kind") && ( + Settled In Kind )}
) : ( -
-
- - -
+
+ - {details.payment_status === "pending" && ( + + {(details.payment_status === "pending" || details.payment_status === "needs_review") && ( @@ -1299,9 +1923,13 @@ function PoolDetail() { ) : ( @@ -1322,9 +1950,15 @@ function PoolDetail() { {details.paymentStatus === "verified" ? ( Paid ) : details.paymentStatus === "pending" ? ( - Pending + UTR Pending + ) : details.paymentStatus === "needs_review" ? ( + Needs Review + ) : details.paymentStatus === "rejected" ? ( + Rejected ) : details.paymentStatus === "host" ? ( HOST (OWN SHARE) + ) : details.isOverdue ? ( + Overdue ) : ( Unpaid )} @@ -1351,36 +1985,45 @@ function PoolDetail() {

- UPI Split Settlement + UPI Split Settlement

VPA Direct
- - + +
+ +
+

+ {selfPayeeName || user?.fullName || name || "No split matched"} +

+

+ {selfPayeeName + ? "Locked to your logged-in account. You can submit a UTR only for this split." + : "This account has no payable split in the pool."} +

+
+
+ {hostAndroidStatus && ( +
+ +

+ {hostAndroidStatus.label}: + {hostAndroidStatus.can_auto_verify + ? "host credits can auto-match when sender or UTR is clear." + : "UTRs will wait for host review until the host Android connector is active."} +

+
+ )} + {payeeDetails ? (
@@ -1397,10 +2040,21 @@ function PoolDetail() {
) : payeeDetails.paymentStatus === "pending" ? (
-
+
Pending Verification

UTR: {payeeDetails.utr}

+ {payeeDetails.paymentDetail && ( +

{payeeDetails.paymentDetail}

+ )} +
+ ) : payeeDetails.paymentStatus === "needs_review" ? ( +
+
+ Needs Host Review +
+ {payeeDetails.utr &&

UTR: {payeeDetails.utr}

} +

{payeeDetails.paymentDetail || payeeDetails.reviewReason}

) : payeeDetails.paymentStatus === "host" ? (
@@ -1496,9 +2150,9 @@ function PoolDetail() { @@ -1511,7 +2165,7 @@ function PoolDetail() {
) : (
- Select your name in the dropdown list above to fetch splits, scan QR, and confirm transfer. + This account does not have a payable split in this pool. Add an item first, or ask the host to check the participant list.
)} @@ -1521,7 +2175,7 @@ function PoolDetail() { Settlement Checklist:

    -
  1. Select your roommate name from the dropdown menu.
  2. +
  3. Confirm the split shown above belongs to your logged-in account.
  4. Pay the split total to the host's QR code or VPA.
  5. Fetch the 12-digit UTR reference ID from your transaction receipt.
  6. Enter and submit the UTR to complete the verification checklist.
  7. @@ -1534,10 +2188,17 @@ function PoolDetail() { {/* List of items inside pool */}
    -

    - - Roommate Carts -

    +
    +

    + + Cart Requests +

    + {participants.length > 0 && ( +

    + {participants.length} roommate{participants.length === 1 ? "" : "s"} - {purchasedItems.length} active item{purchasedItems.length === 1 ? "" : "s"} +

    + )} +
    {pool.status === "open" && ( {isHost && ( )} @@ -1643,13 +2388,27 @@ function PoolDetail() { ); })}
    + {orderedItems.length > 8 && ( +
    + +
    + )} ); })}
- {/* Floating fast, registration-free item entry canvas form */} + {/* Floating quick item entry form */} {pool.status === "open" && (

Quick Add Item

- Registration Free + {isHost ? "Qty" : "Qty + Link"}
@@ -1691,16 +2450,46 @@ function PoolDetail() { />
-
- - setProductUrl(e.target.value)} - placeholder="Product Link (Optional)" - className="bg-background text-xs h-10 pl-9" - /> +
+ Quantity +
+ + + {itemQty} + + +
+ {!isHost && ( +
+ + setProductUrl(e.target.value)} + placeholder="Product link, optional" + className="bg-background text-xs h-10 pl-9" + /> +
+ )}
-
+
@@ -1774,35 +2563,211 @@ function PoolDetail() {
+ {!isHost && ( +
+ +
+ + setProductUrl(e.target.value)} + placeholder="Paste item URL" + className="h-10 bg-background pl-9 text-sm" + /> +
+
+ )} +
+ +
+
+

Quantity

+

+ Line total: {price ? rupees(Math.round((parseFloat(price) || 0) * itemQty * 100)) : "Not set"} +

+
+
+ + + {itemQty} + + +
+
+
+ + + + + +
+ + + + open ? setEditItemOpen(true) : resetEditItem()}> + +
{ + e.preventDefault(); + saveEditedItem(); + }} + className="space-y-4" + > + + Edit Item + +

+ Update the item before checkout. The split recalculates from quantity and unit price. +

+ +
+ {editingItem && ( +
+ +
+ + {editingItem.added_by_name} + + {editingHostItem && ( + + Host item + + )} +
+
+ )} + +
+ +
+ + setEditItemName(e.target.value)} + placeholder="Item name" + className="h-10 bg-background pl-9 text-sm" + /> +
+
+ +
-
+ + {!editingHostItem && ( +
+ +
+ + setEditProductUrl(e.target.value)} + placeholder="Paste item URL" + className="h-10 bg-background pl-9 text-sm" + /> +
+
+ )} +
+ +
+
+

Quantity

+

+ Line total: {editPrice ? rupees(Math.round((parseFloat(editPrice) || 0) * editItemQty * 100)) : "Not set"} +

+
+
+ + + {editItemQty} + + +
-
@@ -1819,6 +2784,18 @@ function PoolDetail() { Complete the checkout by entering final figures from the {theme.name} receipt.

+ {pendingRoommateRequests.length > 0 && ( +
+
+ +

+ {pendingRoommateRequests.length} roommate request{pendingRoommateRequests.length === 1 ? "" : "s"} still pending review.{" "} + You can still finalize, but marking unavailable or skipped items first keeps the split clearer. +

+
+
+ )} +
@@ -2000,28 +2977,28 @@ function PoolDetail() {
- {/* Settlement Complete Celebration Modal */} + {/* Settlement complete receipt modal */} - -
- + +
+
- - - Settlement Complete! + + + Settlement Complete -
-

- All roommates have paid their splits, and all payments are fully verified! +

+

+ All roommate splits are paid and verified.

- This pool is now fully settled. The transactions have been logged into your personal finance ledger. + This pool is now a settled receipt in your PocketBuddy ledger.

-