Feature 3: abandoned-cart recovery (opt-in)
Browse files- app/carts.py +218 -0
- app/models.py +40 -0
- app/orchestrator.py +10 -0
- app/portal_ui/index.html +8 -1
- app/routes/chat.py +1 -0
- app/routes/portal.py +2 -0
- app/scheduler.py +17 -0
- app/tenancy.py +4 -0
- app/tools/cart_tool.py +20 -0
- app/tools/registry.py +20 -0
- app/tools/save_cart_tool.py +32 -0
- migrations/versions/0029_abandoned_carts.py +61 -0
- tests/test_carts.py +136 -0
- tests/tools/test_tools.py +39 -0
app/carts.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Abandoned-cart recovery.
|
| 2 |
+
|
| 3 |
+
When the bot builds a Shopify cart permalink in chat but the visitor leaves without
|
| 4 |
+
checking out, we capture the cart (one open row per chat session) and — if we have an
|
| 5 |
+
email — a background job sends ONE friendly recovery email with the link, skipping
|
| 6 |
+
anyone who already completed an order. Strictly per-tenant: every cart row is scoped to
|
| 7 |
+
one tenant_id, so no store ever nudges another's shoppers.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import logging
|
| 13 |
+
from datetime import UTC, datetime, timedelta
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
from sqlalchemy import select
|
| 17 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 18 |
+
|
| 19 |
+
from app import mailer
|
| 20 |
+
from app.models import AbandonedCart, Tenant
|
| 21 |
+
|
| 22 |
+
log = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
# A cart is "abandoned" once it has sat this long without checkout; we don't chase
|
| 25 |
+
# anything older than the max age (too stale to feel relevant / risks annoyance).
|
| 26 |
+
RECOVERY_DELAY = timedelta(hours=4)
|
| 27 |
+
RECOVERY_MAX_AGE = timedelta(hours=48)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
async def _open_cart_for_session(
|
| 31 |
+
db: AsyncSession, tenant_id: int, session_id: str
|
| 32 |
+
) -> AbandonedCart | None:
|
| 33 |
+
"""The session's current cart that hasn't been recovered yet (one per session)."""
|
| 34 |
+
return (
|
| 35 |
+
await db.execute(
|
| 36 |
+
select(AbandonedCart)
|
| 37 |
+
.where(
|
| 38 |
+
AbandonedCart.tenant_id == tenant_id,
|
| 39 |
+
AbandonedCart.session_id == session_id,
|
| 40 |
+
AbandonedCart.recovered.is_(False),
|
| 41 |
+
)
|
| 42 |
+
.order_by(AbandonedCart.id.desc())
|
| 43 |
+
)
|
| 44 |
+
).scalars().first()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
async def capture(
|
| 48 |
+
db: AsyncSession,
|
| 49 |
+
tenant_id: int,
|
| 50 |
+
*,
|
| 51 |
+
session_id: str,
|
| 52 |
+
checkout_url: str,
|
| 53 |
+
items_text: str = "",
|
| 54 |
+
lang: str | None = None,
|
| 55 |
+
email: str | None = None,
|
| 56 |
+
name: str | None = None,
|
| 57 |
+
) -> AbandonedCart:
|
| 58 |
+
"""Record (or refresh) the open cart the bot just built for this session. A new
|
| 59 |
+
cart link in the same session updates the existing row instead of duplicating, so
|
| 60 |
+
we only ever nudge the LATEST cart. Email/name are filled in when newly known and
|
| 61 |
+
never overwritten by a blank."""
|
| 62 |
+
em = (email or "").strip().lower() or None
|
| 63 |
+
nm = (name or "").strip()
|
| 64 |
+
cart = await _open_cart_for_session(db, tenant_id, session_id)
|
| 65 |
+
if cart is None:
|
| 66 |
+
cart = AbandonedCart(tenant_id=tenant_id, session_id=session_id)
|
| 67 |
+
db.add(cart)
|
| 68 |
+
cart.checkout_url = checkout_url or cart.checkout_url
|
| 69 |
+
if items_text:
|
| 70 |
+
cart.items_text = items_text[:500]
|
| 71 |
+
if lang:
|
| 72 |
+
cart.lang = lang
|
| 73 |
+
# A fresh cart link means the shopper is active again: reset the nudge so the
|
| 74 |
+
# background job re-evaluates it (don't keep a stale "already emailed" flag).
|
| 75 |
+
cart.nudged_at = None
|
| 76 |
+
if em and not cart.email:
|
| 77 |
+
cart.email = em
|
| 78 |
+
if nm and not cart.name:
|
| 79 |
+
cart.name = nm
|
| 80 |
+
await db.flush()
|
| 81 |
+
return cart
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
async def attach_contact(
|
| 85 |
+
db: AsyncSession,
|
| 86 |
+
tenant_id: int,
|
| 87 |
+
*,
|
| 88 |
+
session_id: str,
|
| 89 |
+
email: str,
|
| 90 |
+
name: str | None = None,
|
| 91 |
+
) -> AbandonedCart | None:
|
| 92 |
+
"""Attach the visitor's email (so we can email a reminder) to this session's open
|
| 93 |
+
cart. Returns the cart, or None if the bot hasn't built a cart in this session
|
| 94 |
+
yet (nothing to save)."""
|
| 95 |
+
em = (email or "").strip().lower()
|
| 96 |
+
if not em:
|
| 97 |
+
return None
|
| 98 |
+
cart = await _open_cart_for_session(db, tenant_id, session_id)
|
| 99 |
+
if cart is None:
|
| 100 |
+
return None
|
| 101 |
+
cart.email = em
|
| 102 |
+
nm = (name or "").strip()
|
| 103 |
+
if nm and not cart.name:
|
| 104 |
+
cart.name = nm
|
| 105 |
+
await db.flush()
|
| 106 |
+
return cart
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _recovery_email(cart: AbandonedCart, brand: str) -> tuple[str, str]:
|
| 110 |
+
"""(subject, body) for the recovery nudge, in the shopper's language (ES default,
|
| 111 |
+
EN when the chat was in English). Honest + light — a saved cart, not a hard sell."""
|
| 112 |
+
who = (cart.name or "").strip().split(" ")[0] if cart.name else ""
|
| 113 |
+
en = (cart.lang or "").lower().startswith("en")
|
| 114 |
+
if en:
|
| 115 |
+
hi = f"Hi {who}," if who else "Hi,"
|
| 116 |
+
subject = f"Your cart is saved at {brand} 🛒"
|
| 117 |
+
body = (
|
| 118 |
+
f"{hi}\n\n"
|
| 119 |
+
f"We saved the cart you were putting together at {brand}. You can pick up "
|
| 120 |
+
f"right where you left off here:\n\n{cart.checkout_url}\n\n"
|
| 121 |
+
f"If you have any questions before checking out, just reply to this email "
|
| 122 |
+
f"and we'll help.\n\n— {brand}"
|
| 123 |
+
)
|
| 124 |
+
return subject, body
|
| 125 |
+
hi = f"¡Hola {who}!" if who else "¡Hola!"
|
| 126 |
+
subject = f"Te he guardado el carrito en {brand} 🛒"
|
| 127 |
+
body = (
|
| 128 |
+
f"{hi}\n\n"
|
| 129 |
+
f"Te hemos guardado el carrito que estabas preparando en {brand}. Puedes "
|
| 130 |
+
f"retomarlo justo donde lo dejaste aquí:\n\n{cart.checkout_url}\n\n"
|
| 131 |
+
f"Si tienes cualquier duda antes de finalizar, responde a este correo y te "
|
| 132 |
+
f"ayudamos.\n\nUn saludo,\n{brand}"
|
| 133 |
+
)
|
| 134 |
+
return subject, body
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _aware(dt: datetime) -> datetime:
|
| 138 |
+
"""Treat a naive datetime as UTC. SQLite round-trips TIMESTAMPTZ as naive, so we
|
| 139 |
+
normalize before comparing to avoid 'can't compare naive and aware' TypeErrors."""
|
| 140 |
+
return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _completed_after(orders: list[dict[str, Any]], since: datetime) -> bool:
|
| 144 |
+
"""True if any of the shopper's orders was placed after the cart was built —
|
| 145 |
+
i.e. they already bought, so we must NOT chase the sale."""
|
| 146 |
+
floor = _aware(since)
|
| 147 |
+
for o in orders:
|
| 148 |
+
raw = o.get("created_at")
|
| 149 |
+
if not raw:
|
| 150 |
+
continue
|
| 151 |
+
try:
|
| 152 |
+
when = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
| 153 |
+
except ValueError:
|
| 154 |
+
continue
|
| 155 |
+
if _aware(when) >= floor:
|
| 156 |
+
return True
|
| 157 |
+
return False
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
async def recover_due_carts(db: AsyncSession, settings: Any, *, now: datetime) -> int:
|
| 161 |
+
"""Send ONE recovery email for every due cart of an opted-in, active tenant —
|
| 162 |
+
skipping (and marking recovered) anyone who already placed an order after building
|
| 163 |
+
the cart. Returns how many emails were sent. Best-effort per cart: one failure
|
| 164 |
+
never blocks the rest. Caller commits."""
|
| 165 |
+
from app.shopify.orders import find_orders_by_email
|
| 166 |
+
from app.tenancy import tenant_shopify_client
|
| 167 |
+
|
| 168 |
+
tenants = (
|
| 169 |
+
await db.execute(
|
| 170 |
+
select(Tenant).where(
|
| 171 |
+
Tenant.allow_cart_recovery.is_(True),
|
| 172 |
+
Tenant.chatbot_enabled.is_(True),
|
| 173 |
+
Tenant.is_demo.is_(False),
|
| 174 |
+
)
|
| 175 |
+
)
|
| 176 |
+
).scalars().all()
|
| 177 |
+
sent = 0
|
| 178 |
+
for tenant in tenants:
|
| 179 |
+
due = (
|
| 180 |
+
await db.execute(
|
| 181 |
+
select(AbandonedCart).where(
|
| 182 |
+
AbandonedCart.tenant_id == tenant.id,
|
| 183 |
+
AbandonedCart.email.is_not(None),
|
| 184 |
+
AbandonedCart.nudged_at.is_(None),
|
| 185 |
+
AbandonedCart.recovered.is_(False),
|
| 186 |
+
AbandonedCart.checkout_url != "",
|
| 187 |
+
AbandonedCart.created_at <= now - RECOVERY_DELAY,
|
| 188 |
+
AbandonedCart.created_at >= now - RECOVERY_MAX_AGE,
|
| 189 |
+
)
|
| 190 |
+
)
|
| 191 |
+
).scalars().all()
|
| 192 |
+
if not due:
|
| 193 |
+
continue
|
| 194 |
+
client = tenant_shopify_client(tenant)
|
| 195 |
+
brand = tenant.brand_name or tenant.name or "la tienda"
|
| 196 |
+
for cart in due:
|
| 197 |
+
try:
|
| 198 |
+
if client is not None:
|
| 199 |
+
orders = await find_orders_by_email(client, cart.email or "")
|
| 200 |
+
if _completed_after(orders, cart.created_at):
|
| 201 |
+
cart.recovered = True # they bought — don't chase it
|
| 202 |
+
continue
|
| 203 |
+
ok = await mailer.send_email(
|
| 204 |
+
settings,
|
| 205 |
+
cart.email,
|
| 206 |
+
*_recovery_email(cart, brand),
|
| 207 |
+
reply_to=tenant.support_email or None,
|
| 208 |
+
sender_name=brand,
|
| 209 |
+
)
|
| 210 |
+
# Stamp it nudged either way: a transient send failure must not cause
|
| 211 |
+
# the same shopper to be emailed every tick.
|
| 212 |
+
cart.nudged_at = now
|
| 213 |
+
if ok:
|
| 214 |
+
sent += 1
|
| 215 |
+
except Exception: # noqa: BLE001 - one bad cart never blocks the rest
|
| 216 |
+
log.warning("cart recovery failed tenant=%s cart=%s", tenant.id, cart.id,
|
| 217 |
+
exc_info=True)
|
| 218 |
+
return sent
|
app/models.py
CHANGED
|
@@ -86,6 +86,12 @@ class Tenant(Base):
|
|
| 86 |
# Web search — opt-in per store: lets the bot find/cite REAL external links
|
| 87 |
# (e.g. a marketplace listing) when the answer isn't in the store knowledge.
|
| 88 |
allow_web_search: Mapped[bool] = mapped_column(default=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
# Master switch: admin can turn a client's chatbot off (e.g. non-payment)
|
| 90 |
# without deleting the account. The widget hides and the chat stops replying.
|
| 91 |
chatbot_enabled: Mapped[bool] = mapped_column(default=True)
|
|
@@ -287,6 +293,40 @@ class Customer(Base):
|
|
| 287 |
)
|
| 288 |
|
| 289 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
class Event(Base):
|
| 291 |
"""Per-tenant analytics event (chat -> sales funnel)."""
|
| 292 |
|
|
|
|
| 86 |
# Web search — opt-in per store: lets the bot find/cite REAL external links
|
| 87 |
# (e.g. a marketplace listing) when the answer isn't in the store knowledge.
|
| 88 |
allow_web_search: Mapped[bool] = mapped_column(default=False)
|
| 89 |
+
# Abandoned-cart recovery — opt-in per store: when a visitor builds a cart but
|
| 90 |
+
# leaves without buying, the bot offers to save it + email a reminder, and a
|
| 91 |
+
# background job sends ONE recovery email (skipping anyone who already bought).
|
| 92 |
+
allow_cart_recovery: Mapped[bool] = mapped_column(
|
| 93 |
+
default=False, server_default=false()
|
| 94 |
+
)
|
| 95 |
# Master switch: admin can turn a client's chatbot off (e.g. non-payment)
|
| 96 |
# without deleting the account. The widget hides and the chat stops replying.
|
| 97 |
chatbot_enabled: Mapped[bool] = mapped_column(default=True)
|
|
|
|
| 293 |
)
|
| 294 |
|
| 295 |
|
| 296 |
+
class AbandonedCart(Base):
|
| 297 |
+
"""A cart the bot helped build in chat that wasn't (yet) checked out — captured
|
| 298 |
+
so we can email a recovery nudge. Strictly tenant-scoped. One open cart per chat
|
| 299 |
+
session (the latest one the bot built); the email is attached when the visitor
|
| 300 |
+
gives it (or is already known for a logged-in customer)."""
|
| 301 |
+
|
| 302 |
+
__tablename__ = "abandoned_carts"
|
| 303 |
+
|
| 304 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 305 |
+
tenant_id: Mapped[int | None] = mapped_column(
|
| 306 |
+
ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True
|
| 307 |
+
)
|
| 308 |
+
# The chat session this cart belongs to (one open cart per session). Cleared if
|
| 309 |
+
# the session is purged, but the cart row survives so a pending nudge still fires.
|
| 310 |
+
session_id: Mapped[str | None] = mapped_column(
|
| 311 |
+
ForeignKey("chat_sessions.id", ondelete="SET NULL"), nullable=True, index=True
|
| 312 |
+
)
|
| 313 |
+
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
| 314 |
+
name: Mapped[str] = mapped_column(String(160), default="")
|
| 315 |
+
lang: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
| 316 |
+
checkout_url: Mapped[str] = mapped_column(Text, default="")
|
| 317 |
+
items_text: Mapped[str] = mapped_column(String(500), default="")
|
| 318 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
| 319 |
+
updated_at: Mapped[datetime] = mapped_column(
|
| 320 |
+
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
|
| 321 |
+
)
|
| 322 |
+
# When the single recovery email was sent (None = not yet). Set once so a cart is
|
| 323 |
+
# never emailed twice.
|
| 324 |
+
nudged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
| 325 |
+
# True once we detect the customer completed an order after building the cart, so
|
| 326 |
+
# we never chase a sale that already closed.
|
| 327 |
+
recovered: Mapped[bool] = mapped_column(default=False, server_default=false())
|
| 328 |
+
|
| 329 |
+
|
| 330 |
class Event(Base):
|
| 331 |
"""Per-tenant analytics event (chat -> sales funnel)."""
|
| 332 |
|
app/orchestrator.py
CHANGED
|
@@ -258,6 +258,16 @@ async def run_turn(
|
|
| 258 |
# revealed here — identity verification still gates that).
|
| 259 |
if getattr(ctx, "customer_note", ""):
|
| 260 |
messages.append({"role": "system", "content": ctx.customer_note})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
# Deterministic anti-hallucination: asked what the business is/sells/offers
|
| 262 |
# or to recommend, weak providers invent a fake store from memory. Force a
|
| 263 |
# grounded answer by requiring a tool lookup first.
|
|
|
|
| 258 |
# revealed here — identity verification still gates that).
|
| 259 |
if getattr(ctx, "customer_note", ""):
|
| 260 |
messages.append({"role": "system", "content": ctx.customer_note})
|
| 261 |
+
# Abandoned-cart recovery (opt-in): once the bot has built a cart, let it offer to
|
| 262 |
+
# save it and remind the customer by email if they don't finish — only if they
|
| 263 |
+
# want it (then call save_cart with their email). Never pushy.
|
| 264 |
+
if getattr(ctx, "allow_cart_recovery", False):
|
| 265 |
+
messages.append({"role": "system", "content": (
|
| 266 |
+
"Si has creado un enlace de carrito y el cliente no termina de comprar (duda, "
|
| 267 |
+
"se va a pensar, o dice que luego), puedes ofrecerle UNA vez guardárselo y "
|
| 268 |
+
"avisarle por email para retomarlo cuando quiera. Si acepta, pídele el email "
|
| 269 |
+
"y llama a save_cart con ese email. No insistas si dice que no."
|
| 270 |
+
)})
|
| 271 |
# Deterministic anti-hallucination: asked what the business is/sells/offers
|
| 272 |
# or to recommend, weak providers invent a fake store from memory. Force a
|
| 273 |
# grounded answer by requiring a tool lookup first.
|
app/portal_ui/index.html
CHANGED
|
@@ -170,6 +170,11 @@
|
|
| 170 |
Permitir búsqueda en internet
|
| 171 |
</label>
|
| 172 |
<p class="muted">Si lo activas, cuando un visitante pregunte por algo externo (p. ej. dónde comprar tu producto en un marketplace), el asistente buscará en la web y dará el enlace real. Desactivado, responde solo con tu conocimiento.</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
<div class="row" style="margin-top:14px;"><button onclick="saveBusiness()">Guardar</button><span id="biz-state" class="ok"></span></div>
|
| 174 |
</div>
|
| 175 |
</section>
|
|
@@ -426,6 +431,7 @@
|
|
| 426 |
$("b-starters").value = (c.starters || []).join(", ");
|
| 427 |
$("b-instructions").value = c.custom_instructions || "";
|
| 428 |
$("b-websearch").checked = !!c.allow_web_search;
|
|
|
|
| 429 |
syncColorPick();
|
| 430 |
$("sh-shop").value = c.shopify_shop || "";
|
| 431 |
$("sh-id").value = c.shopify_client_id || "";
|
|
@@ -501,7 +507,8 @@
|
|
| 501 |
avatar_url: $("b-avatar").value.trim(), support_email: $("b-email").value.trim(),
|
| 502 |
widget_mode: $("b-mode").value, starters: starters,
|
| 503 |
custom_instructions: $("b-instructions").value.trim(),
|
| 504 |
-
allow_web_search: $("b-websearch").checked
|
|
|
|
| 505 |
}
|
| 506 |
function saveShopify(){
|
| 507 |
var body = { shopify_shop: $("sh-shop").value.trim(), shopify_client_id: $("sh-id").value.trim() };
|
|
|
|
| 170 |
Permitir búsqueda en internet
|
| 171 |
</label>
|
| 172 |
<p class="muted">Si lo activas, cuando un visitante pregunte por algo externo (p. ej. dónde comprar tu producto en un marketplace), el asistente buscará en la web y dará el enlace real. Desactivado, responde solo con tu conocimiento.</p>
|
| 173 |
+
<label style="display:flex;align-items:center;gap:8px;font-weight:600;">
|
| 174 |
+
<input id="b-cartrecovery" type="checkbox" style="width:auto;" />
|
| 175 |
+
Recuperar carritos abandonados
|
| 176 |
+
</label>
|
| 177 |
+
<p class="muted">Si lo activas, cuando un visitante prepare un carrito en el chat pero no termine de comprar, el asistente le ofrecerá guardárselo y le enviará UN email recordatorio con el enlace para retomarlo (saltando a quien ya haya comprado). Necesita tu email de soporte configurado.</p>
|
| 178 |
<div class="row" style="margin-top:14px;"><button onclick="saveBusiness()">Guardar</button><span id="biz-state" class="ok"></span></div>
|
| 179 |
</div>
|
| 180 |
</section>
|
|
|
|
| 431 |
$("b-starters").value = (c.starters || []).join(", ");
|
| 432 |
$("b-instructions").value = c.custom_instructions || "";
|
| 433 |
$("b-websearch").checked = !!c.allow_web_search;
|
| 434 |
+
$("b-cartrecovery").checked = !!c.allow_cart_recovery;
|
| 435 |
syncColorPick();
|
| 436 |
$("sh-shop").value = c.shopify_shop || "";
|
| 437 |
$("sh-id").value = c.shopify_client_id || "";
|
|
|
|
| 507 |
avatar_url: $("b-avatar").value.trim(), support_email: $("b-email").value.trim(),
|
| 508 |
widget_mode: $("b-mode").value, starters: starters,
|
| 509 |
custom_instructions: $("b-instructions").value.trim(),
|
| 510 |
+
allow_web_search: $("b-websearch").checked,
|
| 511 |
+
allow_cart_recovery: $("b-cartrecovery").checked }, "biz-state");
|
| 512 |
}
|
| 513 |
function saveShopify(){
|
| 514 |
var body = { shopify_shop: $("sh-shop").value.trim(), shopify_client_id: $("sh-id").value.trim() };
|
app/routes/chat.py
CHANGED
|
@@ -211,6 +211,7 @@ async def _make_context(
|
|
| 211 |
allow_address_change=tenant.allow_address_change,
|
| 212 |
allow_returns=tenant.allow_returns,
|
| 213 |
allow_web_search=tenant.allow_web_search,
|
|
|
|
| 214 |
# tenant's OWN email first (so the output guard's allow-list + any
|
| 215 |
# per-tenant use reflects THIS store, not the platform default)
|
| 216 |
support_email=tenant.support_email or settings.support_email,
|
|
|
|
| 211 |
allow_address_change=tenant.allow_address_change,
|
| 212 |
allow_returns=tenant.allow_returns,
|
| 213 |
allow_web_search=tenant.allow_web_search,
|
| 214 |
+
allow_cart_recovery=tenant.allow_cart_recovery,
|
| 215 |
# tenant's OWN email first (so the output guard's allow-list + any
|
| 216 |
# per-tenant use reflects THIS store, not the platform default)
|
| 217 |
support_email=tenant.support_email or settings.support_email,
|
app/routes/portal.py
CHANGED
|
@@ -53,6 +53,7 @@ class PortalConfigIn(BaseModel):
|
|
| 53 |
starters: list[str] | None = None
|
| 54 |
custom_instructions: str | None = None
|
| 55 |
allow_web_search: bool | None = None
|
|
|
|
| 56 |
support_email: str | None = None
|
| 57 |
shopify_shop: ShopDomain = None
|
| 58 |
shopify_client_id: str | None = None
|
|
@@ -124,6 +125,7 @@ def _config(tenant: Tenant) -> dict:
|
|
| 124 |
"starters": tenant.starters or [],
|
| 125 |
"custom_instructions": tenant.custom_instructions or "",
|
| 126 |
"allow_web_search": tenant.allow_web_search,
|
|
|
|
| 127 |
"support_email": tenant.support_email,
|
| 128 |
"shopify_shop": tenant.shopify_shop,
|
| 129 |
"shopify_client_id": tenant.shopify_client_id,
|
|
|
|
| 53 |
starters: list[str] | None = None
|
| 54 |
custom_instructions: str | None = None
|
| 55 |
allow_web_search: bool | None = None
|
| 56 |
+
allow_cart_recovery: bool | None = None
|
| 57 |
support_email: str | None = None
|
| 58 |
shopify_shop: ShopDomain = None
|
| 59 |
shopify_client_id: str | None = None
|
|
|
|
| 125 |
"starters": tenant.starters or [],
|
| 126 |
"custom_instructions": tenant.custom_instructions or "",
|
| 127 |
"allow_web_search": tenant.allow_web_search,
|
| 128 |
+
"allow_cart_recovery": tenant.allow_cart_recovery,
|
| 129 |
"support_email": tenant.support_email,
|
| 130 |
"shopify_shop": tenant.shopify_shop,
|
| 131 |
"shopify_client_id": tenant.shopify_client_id,
|
app/scheduler.py
CHANGED
|
@@ -30,12 +30,14 @@ _PRODUCT_KEY = "last_product_sync"
|
|
| 30 |
_BILLING_KEY = "last_billing_reconcile"
|
| 31 |
_DEMO_PURGE_KEY = "last_demo_purge"
|
| 32 |
_TRIAL_KEY = "last_trial_sweep"
|
|
|
|
| 33 |
REINDEX_EVERY = timedelta(days=7)
|
| 34 |
PRODUCT_SYNC_EVERY = timedelta(days=1) # catalog text changes more often than docs
|
| 35 |
BILLING_RECONCILE_EVERY = timedelta(days=1)
|
| 36 |
DEMO_PURGE_EVERY = timedelta(hours=6) # sweep every tick so demos never pile up
|
| 37 |
DEMO_MAX_AGE = timedelta(hours=72) # a demo is deleted 72h after creation, whatever its TTL
|
| 38 |
TRIAL_SWEEP_EVERY = timedelta(hours=12) # pause trials promptly once they expire
|
|
|
|
| 39 |
# 29 EUR/mo self-serve link (mirrors app/routes/demo.py MONTHLY_BUY_LINK).
|
| 40 |
TRIAL_PAY_LINK = "https://buy.stripe.com/eVq5kC5h6cQS9qa5ar5wI04"
|
| 41 |
CHECK_EVERY_SECONDS = 6 * 3600
|
|
@@ -288,6 +290,20 @@ async def _maybe_pause_trials() -> None:
|
|
| 288 |
log.info("trial sweep paused %d expired trial(s)", n)
|
| 289 |
|
| 290 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
async def run_periodic_reindex() -> None:
|
| 292 |
await asyncio.sleep(FIRST_DELAY_SECONDS)
|
| 293 |
while True:
|
|
@@ -298,6 +314,7 @@ async def run_periodic_reindex() -> None:
|
|
| 298 |
_maybe_reconcile_billing,
|
| 299 |
_maybe_pause_trials,
|
| 300 |
_maybe_purge_demos,
|
|
|
|
| 301 |
_maybe_reindex,
|
| 302 |
_maybe_sync_products,
|
| 303 |
):
|
|
|
|
| 30 |
_BILLING_KEY = "last_billing_reconcile"
|
| 31 |
_DEMO_PURGE_KEY = "last_demo_purge"
|
| 32 |
_TRIAL_KEY = "last_trial_sweep"
|
| 33 |
+
_CART_KEY = "last_cart_recovery"
|
| 34 |
REINDEX_EVERY = timedelta(days=7)
|
| 35 |
PRODUCT_SYNC_EVERY = timedelta(days=1) # catalog text changes more often than docs
|
| 36 |
BILLING_RECONCILE_EVERY = timedelta(days=1)
|
| 37 |
DEMO_PURGE_EVERY = timedelta(hours=6) # sweep every tick so demos never pile up
|
| 38 |
DEMO_MAX_AGE = timedelta(hours=72) # a demo is deleted 72h after creation, whatever its TTL
|
| 39 |
TRIAL_SWEEP_EVERY = timedelta(hours=12) # pause trials promptly once they expire
|
| 40 |
+
CART_RECOVERY_EVERY = timedelta(hours=3) # send recovery nudges a few hours after abandonment
|
| 41 |
# 29 EUR/mo self-serve link (mirrors app/routes/demo.py MONTHLY_BUY_LINK).
|
| 42 |
TRIAL_PAY_LINK = "https://buy.stripe.com/eVq5kC5h6cQS9qa5ar5wI04"
|
| 43 |
CHECK_EVERY_SECONDS = 6 * 3600
|
|
|
|
| 290 |
log.info("trial sweep paused %d expired trial(s)", n)
|
| 291 |
|
| 292 |
|
| 293 |
+
async def _maybe_recover_carts() -> None:
|
| 294 |
+
async with get_sessionmaker()() as db:
|
| 295 |
+
now = datetime.now(UTC)
|
| 296 |
+
if not _due(await get_config(db, _CART_KEY) or {}, CART_RECOVERY_EVERY, now):
|
| 297 |
+
return
|
| 298 |
+
from app import carts
|
| 299 |
+
|
| 300 |
+
n = await carts.recover_due_carts(db, get_settings(), now=now)
|
| 301 |
+
await upsert_config(db, _CART_KEY, {"at": now.isoformat()})
|
| 302 |
+
await db.commit()
|
| 303 |
+
if n:
|
| 304 |
+
log.info("cart recovery sent %d nudge(s)", n)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
async def run_periodic_reindex() -> None:
|
| 308 |
await asyncio.sleep(FIRST_DELAY_SECONDS)
|
| 309 |
while True:
|
|
|
|
| 314 |
_maybe_reconcile_billing,
|
| 315 |
_maybe_pause_trials,
|
| 316 |
_maybe_purge_demos,
|
| 317 |
+
_maybe_recover_carts,
|
| 318 |
_maybe_reindex,
|
| 319 |
_maybe_sync_products,
|
| 320 |
):
|
app/tenancy.py
CHANGED
|
@@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
| 15 |
|
| 16 |
from app.crypto import decrypt, encrypt
|
| 17 |
from app.models import (
|
|
|
|
| 18 |
Attachment,
|
| 19 |
ChatMessage,
|
| 20 |
ChatSession,
|
|
@@ -62,6 +63,7 @@ _TENANT_FIELDS = {
|
|
| 62 |
"allow_address_change",
|
| 63 |
"allow_returns",
|
| 64 |
"allow_web_search",
|
|
|
|
| 65 |
"chatbot_enabled",
|
| 66 |
"brand_name",
|
| 67 |
"brand_color",
|
|
@@ -167,6 +169,8 @@ async def purge_tenant(db: AsyncSession, tenant: Tenant) -> None:
|
|
| 167 |
))
|
| 168 |
# Visitor uploads (already text-only) — covers admin delete AND demo expiry.
|
| 169 |
await db.execute(delete(Attachment).where(Attachment.tenant_id == tenant.id))
|
|
|
|
|
|
|
| 170 |
await db.execute(delete(ChatSession).where(ChatSession.tenant_id == tenant.id))
|
| 171 |
await db.execute(delete(Customer).where(Customer.tenant_id == tenant.id))
|
| 172 |
await db.execute(delete(HandoffRequest).where(HandoffRequest.tenant_id == tenant.id))
|
|
|
|
| 15 |
|
| 16 |
from app.crypto import decrypt, encrypt
|
| 17 |
from app.models import (
|
| 18 |
+
AbandonedCart,
|
| 19 |
Attachment,
|
| 20 |
ChatMessage,
|
| 21 |
ChatSession,
|
|
|
|
| 63 |
"allow_address_change",
|
| 64 |
"allow_returns",
|
| 65 |
"allow_web_search",
|
| 66 |
+
"allow_cart_recovery",
|
| 67 |
"chatbot_enabled",
|
| 68 |
"brand_name",
|
| 69 |
"brand_color",
|
|
|
|
| 169 |
))
|
| 170 |
# Visitor uploads (already text-only) — covers admin delete AND demo expiry.
|
| 171 |
await db.execute(delete(Attachment).where(Attachment.tenant_id == tenant.id))
|
| 172 |
+
# Carts reference sessions (SET NULL) — remove them before/with the sessions.
|
| 173 |
+
await db.execute(delete(AbandonedCart).where(AbandonedCart.tenant_id == tenant.id))
|
| 174 |
await db.execute(delete(ChatSession).where(ChatSession.tenant_id == tenant.id))
|
| 175 |
await db.execute(delete(Customer).where(Customer.tenant_id == tenant.id))
|
| 176 |
await db.execute(delete(HandoffRequest).where(HandoffRequest.tenant_id == tenant.id))
|
app/tools/cart_tool.py
CHANGED
|
@@ -46,4 +46,24 @@ async def run(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
| 46 |
ctx.db, ctx.tenant_id, "cart_link", session_id=ctx.session.id,
|
| 47 |
meta={"items": len(parts), "discount": bool(discount)},
|
| 48 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
return {"status": "ok", "checkout_url": url, "items": len(parts)}
|
|
|
|
| 46 |
ctx.db, ctx.tenant_id, "cart_link", session_id=ctx.session.id,
|
| 47 |
meta={"items": len(parts), "discount": bool(discount)},
|
| 48 |
)
|
| 49 |
+
# Abandoned-cart recovery (opt-in): remember this cart so we can nudge later.
|
| 50 |
+
# Auto-attaches the email if we already know it (a logged-in/returning customer);
|
| 51 |
+
# otherwise the bot can ask + call save_cart. Best-effort: never break the cart.
|
| 52 |
+
if getattr(ctx, "allow_cart_recovery", False):
|
| 53 |
+
try:
|
| 54 |
+
from app import carts
|
| 55 |
+
|
| 56 |
+
known_email = getattr(getattr(ctx, "customer", None), "email", None)
|
| 57 |
+
known_name = getattr(getattr(ctx, "customer", None), "name", None)
|
| 58 |
+
await carts.capture(
|
| 59 |
+
ctx.db, ctx.tenant_id,
|
| 60 |
+
session_id=ctx.session.id,
|
| 61 |
+
checkout_url=url,
|
| 62 |
+
items_text=f"{len(parts)} artículo(s)",
|
| 63 |
+
lang=ctx.session.lang,
|
| 64 |
+
email=known_email,
|
| 65 |
+
name=known_name,
|
| 66 |
+
)
|
| 67 |
+
except Exception: # noqa: BLE001 - capture is best-effort
|
| 68 |
+
pass
|
| 69 |
return {"status": "ok", "checkout_url": url, "items": len(parts)}
|
app/tools/registry.py
CHANGED
|
@@ -18,6 +18,7 @@ from app.tools import (
|
|
| 18 |
knowledge_tool,
|
| 19 |
order_tool,
|
| 20 |
products_tool,
|
|
|
|
| 21 |
shipping_tool,
|
| 22 |
stock_tool,
|
| 23 |
web_search_tool,
|
|
@@ -42,6 +43,7 @@ class ToolContext:
|
|
| 42 |
channel: str = "web" # "web" | "whatsapp" — affects the human-handoff UX
|
| 43 |
handoff: bool = False # set by escalate_to_human to make the widget show its form
|
| 44 |
allow_web_search: bool = False # per-tenant opt-in for the web_search tool
|
|
|
|
| 45 |
web_urls: list[str] = field(default_factory=list) # URLs found this turn (link-guard pass)
|
| 46 |
customer: Any | None = None # Customer profile (returning-customer memory), if known
|
| 47 |
customer_note: str = "" # system note injected when this is a RETURNING customer
|
|
@@ -114,6 +116,23 @@ SPECS: list[ToolSpec] = [
|
|
| 114 |
"required": ["items"],
|
| 115 |
},
|
| 116 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
ToolSpec(
|
| 118 |
name="reorder",
|
| 119 |
description=(
|
|
@@ -262,6 +281,7 @@ _HANDLERS: dict[str, Callable[[dict[str, Any], ToolContext], Awaitable[dict[str,
|
|
| 262 |
"search_knowledge": knowledge_tool.run,
|
| 263 |
"search_products": products_tool.run,
|
| 264 |
"create_cart_link": cart_tool.run,
|
|
|
|
| 265 |
"watch_stock": stock_tool.run,
|
| 266 |
"lookup_order": order_tool.run,
|
| 267 |
"reorder": actions_tool.reorder,
|
|
|
|
| 18 |
knowledge_tool,
|
| 19 |
order_tool,
|
| 20 |
products_tool,
|
| 21 |
+
save_cart_tool,
|
| 22 |
shipping_tool,
|
| 23 |
stock_tool,
|
| 24 |
web_search_tool,
|
|
|
|
| 43 |
channel: str = "web" # "web" | "whatsapp" — affects the human-handoff UX
|
| 44 |
handoff: bool = False # set by escalate_to_human to make the widget show its form
|
| 45 |
allow_web_search: bool = False # per-tenant opt-in for the web_search tool
|
| 46 |
+
allow_cart_recovery: bool = False # per-tenant opt-in for abandoned-cart recovery
|
| 47 |
web_urls: list[str] = field(default_factory=list) # URLs found this turn (link-guard pass)
|
| 48 |
customer: Any | None = None # Customer profile (returning-customer memory), if known
|
| 49 |
customer_note: str = "" # system note injected when this is a RETURNING customer
|
|
|
|
| 116 |
"required": ["items"],
|
| 117 |
},
|
| 118 |
),
|
| 119 |
+
ToolSpec(
|
| 120 |
+
name="save_cart",
|
| 121 |
+
description=(
|
| 122 |
+
"Guarda el carrito que has creado con create_cart_link junto al email del "
|
| 123 |
+
"cliente para enviarle un recordatorio si no termina la compra. Úsalo SOLO "
|
| 124 |
+
"cuando el cliente acepte que le avises por email (p. ej. 'sí, guárdamelo' / "
|
| 125 |
+
"'mándamelo al correo'). Requiere su email; pásalo tal cual lo dé."
|
| 126 |
+
),
|
| 127 |
+
parameters={
|
| 128 |
+
"type": "object",
|
| 129 |
+
"properties": {
|
| 130 |
+
"email": {"type": "string", "description": "Email del cliente"},
|
| 131 |
+
"name": {"type": "string", "description": "Nombre del cliente (opcional)"},
|
| 132 |
+
},
|
| 133 |
+
"required": ["email"],
|
| 134 |
+
},
|
| 135 |
+
),
|
| 136 |
ToolSpec(
|
| 137 |
name="reorder",
|
| 138 |
description=(
|
|
|
|
| 281 |
"search_knowledge": knowledge_tool.run,
|
| 282 |
"search_products": products_tool.run,
|
| 283 |
"create_cart_link": cart_tool.run,
|
| 284 |
+
"save_cart": save_cart_tool.run,
|
| 285 |
"watch_stock": stock_tool.run,
|
| 286 |
"lookup_order": order_tool.run,
|
| 287 |
"reorder": actions_tool.reorder,
|
app/tools/save_cart_tool.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tool: save the visitor's in-chat cart + email so we can send a recovery nudge.
|
| 2 |
+
|
| 3 |
+
The bot calls this when it has built a cart (create_cart_link) and the visitor agrees
|
| 4 |
+
to be reminded by email. Opt-in per store (allow_cart_recovery); a no-op otherwise.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from typing import TYPE_CHECKING, Any
|
| 10 |
+
|
| 11 |
+
if TYPE_CHECKING:
|
| 12 |
+
from app.tools.registry import ToolContext
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def run(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
| 16 |
+
if not getattr(ctx, "allow_cart_recovery", False):
|
| 17 |
+
return {"status": "unavailable"}
|
| 18 |
+
email = str(args.get("email") or "").strip()
|
| 19 |
+
if "@" not in email:
|
| 20 |
+
return {"status": "need_info", "need": ["email"]}
|
| 21 |
+
from app import carts
|
| 22 |
+
|
| 23 |
+
cart = await carts.attach_contact(
|
| 24 |
+
ctx.db, ctx.tenant_id,
|
| 25 |
+
session_id=ctx.session.id,
|
| 26 |
+
email=email,
|
| 27 |
+
name=str(args.get("name") or "").strip() or None,
|
| 28 |
+
)
|
| 29 |
+
if cart is None:
|
| 30 |
+
# No cart built in this session yet — tell the model to create one first.
|
| 31 |
+
return {"status": "no_cart"}
|
| 32 |
+
return {"status": "ok", "email": cart.email}
|
migrations/versions/0029_abandoned_carts.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Abandoned-cart recovery.
|
| 2 |
+
|
| 3 |
+
A per-tenant `abandoned_carts` table (one open cart per chat session, with the
|
| 4 |
+
checkout permalink + an optional captured email) plus a `tenants.allow_cart_recovery`
|
| 5 |
+
opt-in flag, so the bot can save a cart the visitor didn't finish and a background
|
| 6 |
+
job can email a single recovery nudge.
|
| 7 |
+
|
| 8 |
+
Postgres-only, additive + idempotent. No-op elsewhere (tests/dev use create_all).
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from alembic import op
|
| 14 |
+
|
| 15 |
+
revision = "0029_abandoned_carts"
|
| 16 |
+
down_revision = "0028_customers"
|
| 17 |
+
branch_labels = None
|
| 18 |
+
depends_on = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
if op.get_bind().dialect.name != "postgresql":
|
| 23 |
+
return
|
| 24 |
+
op.execute(
|
| 25 |
+
"ALTER TABLE tenants ADD COLUMN IF NOT EXISTS allow_cart_recovery "
|
| 26 |
+
"BOOLEAN NOT NULL DEFAULT false"
|
| 27 |
+
)
|
| 28 |
+
op.execute(
|
| 29 |
+
"""
|
| 30 |
+
CREATE TABLE IF NOT EXISTS abandoned_carts (
|
| 31 |
+
id SERIAL PRIMARY KEY,
|
| 32 |
+
tenant_id INTEGER REFERENCES tenants(id) ON DELETE CASCADE,
|
| 33 |
+
session_id VARCHAR(40) REFERENCES chat_sessions(id) ON DELETE SET NULL,
|
| 34 |
+
email VARCHAR(255),
|
| 35 |
+
name VARCHAR(160) NOT NULL DEFAULT '',
|
| 36 |
+
lang VARCHAR(10),
|
| 37 |
+
checkout_url TEXT NOT NULL DEFAULT '',
|
| 38 |
+
items_text VARCHAR(500) NOT NULL DEFAULT '',
|
| 39 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
| 40 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
| 41 |
+
nudged_at TIMESTAMPTZ,
|
| 42 |
+
recovered BOOLEAN NOT NULL DEFAULT false
|
| 43 |
+
)
|
| 44 |
+
"""
|
| 45 |
+
)
|
| 46 |
+
op.execute(
|
| 47 |
+
"CREATE INDEX IF NOT EXISTS ix_abandoned_carts_tenant_id ON abandoned_carts (tenant_id)"
|
| 48 |
+
)
|
| 49 |
+
op.execute(
|
| 50 |
+
"CREATE INDEX IF NOT EXISTS ix_abandoned_carts_session_id ON abandoned_carts (session_id)"
|
| 51 |
+
)
|
| 52 |
+
op.execute(
|
| 53 |
+
"CREATE INDEX IF NOT EXISTS ix_abandoned_carts_email ON abandoned_carts (email)"
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def downgrade() -> None:
|
| 58 |
+
if op.get_bind().dialect.name != "postgresql":
|
| 59 |
+
return
|
| 60 |
+
op.execute("DROP TABLE IF EXISTS abandoned_carts")
|
| 61 |
+
op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS allow_cart_recovery")
|
tests/test_carts.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import UTC, datetime, timedelta
|
| 4 |
+
|
| 5 |
+
from sqlalchemy import select
|
| 6 |
+
|
| 7 |
+
from app import carts, mailer
|
| 8 |
+
from app.models import AbandonedCart, ChatSession, Tenant
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def _session(db, tenant_id, lang="es"):
|
| 12 |
+
s = ChatSession(tenant_id=tenant_id, lang=lang)
|
| 13 |
+
db.add(s)
|
| 14 |
+
await db.flush()
|
| 15 |
+
return s
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
async def test_capture_one_per_session_and_updates(db_session):
|
| 19 |
+
s = await _session(db_session, 1)
|
| 20 |
+
c1 = await carts.capture(
|
| 21 |
+
db_session, 1, session_id=s.id, checkout_url="https://x/cart/1:1", items_text="1 art."
|
| 22 |
+
)
|
| 23 |
+
# same session -> SAME row updated (no duplicate), latest link wins
|
| 24 |
+
c2 = await carts.capture(
|
| 25 |
+
db_session, 1, session_id=s.id, checkout_url="https://x/cart/2:1", items_text="2 art."
|
| 26 |
+
)
|
| 27 |
+
assert c2.id == c1.id
|
| 28 |
+
assert c2.checkout_url.endswith("2:1") and c2.items_text == "2 art."
|
| 29 |
+
assert c2.email is None # nothing captured yet
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def test_capture_autofills_known_email(db_session):
|
| 33 |
+
s = await _session(db_session, 1)
|
| 34 |
+
c = await carts.capture(
|
| 35 |
+
db_session, 1, session_id=s.id, checkout_url="https://x/cart/9:1",
|
| 36 |
+
email="Buyer@X.com", name="Leo",
|
| 37 |
+
)
|
| 38 |
+
assert c.email == "buyer@x.com" and c.name == "Leo" # normalized
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def test_attach_contact_requires_existing_cart(db_session):
|
| 42 |
+
s = await _session(db_session, 1)
|
| 43 |
+
# no cart built yet in this session -> nothing to attach
|
| 44 |
+
assert await carts.attach_contact(db_session, 1, session_id=s.id, email="a@b.com") is None
|
| 45 |
+
await carts.capture(db_session, 1, session_id=s.id, checkout_url="https://x/cart/3:1")
|
| 46 |
+
c = await carts.attach_contact(db_session, 1, session_id=s.id, email="a@b.com", name="Ann")
|
| 47 |
+
assert c is not None and c.email == "a@b.com" and c.name == "Ann"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
async def test_capture_is_tenant_isolated(db_session):
|
| 51 |
+
sa = await _session(db_session, 1)
|
| 52 |
+
sb = await _session(db_session, 2)
|
| 53 |
+
a = await carts.capture(db_session, 1, session_id=sa.id, checkout_url="https://a/cart/1:1")
|
| 54 |
+
b = await carts.capture(db_session, 2, session_id=sb.id, checkout_url="https://b/cart/1:1")
|
| 55 |
+
assert a.id != b.id and a.tenant_id == 1 and b.tenant_id == 2
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_recovery_email_language():
|
| 59 |
+
es_subject, es_body = carts._recovery_email(
|
| 60 |
+
AbandonedCart(name="María García", lang="es", checkout_url="https://x/cart/1:1"), "MiTienda"
|
| 61 |
+
)
|
| 62 |
+
assert "MiTienda" in es_subject and "María" in es_body and "https://x/cart/1:1" in es_body
|
| 63 |
+
en_subject, en_body = carts._recovery_email(
|
| 64 |
+
AbandonedCart(name="John", lang="en", checkout_url="https://x/cart/1:1"), "MyShop"
|
| 65 |
+
)
|
| 66 |
+
assert "saved" in en_subject.lower() and en_body.startswith("Hi John")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
async def test_recover_due_carts_sends_and_skips(db_session, monkeypatch):
|
| 70 |
+
db_session.add(Tenant(slug="cr", name="CR", brand_name="CartShop",
|
| 71 |
+
allow_cart_recovery=True, support_email="shop@cr.com"))
|
| 72 |
+
db_session.add(Tenant(slug="off", name="OFF", allow_cart_recovery=False))
|
| 73 |
+
await db_session.flush()
|
| 74 |
+
cr = (await db_session.execute(
|
| 75 |
+
select(Tenant).where(Tenant.slug == "cr"))).scalar_one()
|
| 76 |
+
off = (await db_session.execute(
|
| 77 |
+
select(Tenant).where(Tenant.slug == "off"))).scalar_one()
|
| 78 |
+
old = datetime.now(UTC) - timedelta(hours=5) # past the 4h delay, within 48h
|
| 79 |
+
# due: opted-in tenant, has email, old enough
|
| 80 |
+
db_session.add(AbandonedCart(tenant_id=cr.id, email="a@b.com", checkout_url="https://x/cart/1:1",
|
| 81 |
+
created_at=old))
|
| 82 |
+
# not due: no email captured
|
| 83 |
+
db_session.add(AbandonedCart(tenant_id=cr.id, checkout_url="https://x/cart/2:1", created_at=old))
|
| 84 |
+
# not due: too recent
|
| 85 |
+
db_session.add(AbandonedCart(tenant_id=cr.id, email="c@d.com", checkout_url="https://x/cart/3:1",
|
| 86 |
+
created_at=datetime.now(UTC)))
|
| 87 |
+
# opted-out tenant must NEVER be emailed
|
| 88 |
+
db_session.add(AbandonedCart(tenant_id=off.id, email="e@f.com", checkout_url="https://x/cart/4:1",
|
| 89 |
+
created_at=old))
|
| 90 |
+
await db_session.flush()
|
| 91 |
+
|
| 92 |
+
sent: list[tuple] = []
|
| 93 |
+
|
| 94 |
+
async def fake_send(settings, to, subject, body, **kw):
|
| 95 |
+
sent.append((to, subject, kw.get("reply_to")))
|
| 96 |
+
return True
|
| 97 |
+
|
| 98 |
+
monkeypatch.setattr(mailer, "send_email", fake_send)
|
| 99 |
+
n = await carts.recover_due_carts(db_session, object(), now=datetime.now(UTC))
|
| 100 |
+
assert n == 1
|
| 101 |
+
assert sent == [("a@b.com", sent[0][1], "shop@cr.com")] # only the one due cart, with reply-to
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
async def test_recover_due_skips_already_bought(db_session, monkeypatch):
|
| 105 |
+
import app.shopify.orders as orders_mod
|
| 106 |
+
import app.tenancy as tenancy_mod
|
| 107 |
+
|
| 108 |
+
db_session.add(Tenant(slug="cr2", name="CR2", brand_name="Shop2", allow_cart_recovery=True))
|
| 109 |
+
await db_session.flush()
|
| 110 |
+
t = (await db_session.execute(
|
| 111 |
+
select(Tenant).where(Tenant.slug == "cr2"))).scalar_one()
|
| 112 |
+
old = datetime.now(UTC) - timedelta(hours=5)
|
| 113 |
+
cart = AbandonedCart(tenant_id=t.id, email="paid@x.com", checkout_url="https://x/cart/1:1",
|
| 114 |
+
created_at=old)
|
| 115 |
+
db_session.add(cart)
|
| 116 |
+
await db_session.flush()
|
| 117 |
+
|
| 118 |
+
monkeypatch.setattr(tenancy_mod, "tenant_shopify_client", lambda tenant: object())
|
| 119 |
+
|
| 120 |
+
async def fake_orders(client, email):
|
| 121 |
+
# an order placed AFTER the cart was built -> they already bought
|
| 122 |
+
return [{"created_at": datetime.now(UTC).isoformat()}]
|
| 123 |
+
|
| 124 |
+
monkeypatch.setattr(orders_mod, "find_orders_by_email", fake_orders)
|
| 125 |
+
sent = []
|
| 126 |
+
|
| 127 |
+
async def fake_send(*a, **k):
|
| 128 |
+
sent.append(a)
|
| 129 |
+
return True
|
| 130 |
+
|
| 131 |
+
monkeypatch.setattr(mailer, "send_email", fake_send)
|
| 132 |
+
n = await carts.recover_due_carts(db_session, object(), now=datetime.now(UTC))
|
| 133 |
+
assert n == 0 and sent == [] # no email — sale already closed
|
| 134 |
+
# same identity-mapped row recover_due_carts mutated (caller commits; don't refresh
|
| 135 |
+
# here or the un-flushed change is discarded)
|
| 136 |
+
assert cart.recovered is True and cart.nudged_at is None
|
tests/tools/test_tools.py
CHANGED
|
@@ -27,6 +27,7 @@ def test_specs_expose_tools():
|
|
| 27 |
"search_knowledge",
|
| 28 |
"search_products",
|
| 29 |
"create_cart_link",
|
|
|
|
| 30 |
"watch_stock",
|
| 31 |
"lookup_order",
|
| 32 |
"reorder",
|
|
@@ -37,6 +38,44 @@ def test_specs_expose_tools():
|
|
| 37 |
} <= names
|
| 38 |
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
async def test_cart_link_with_discount(db_session):
|
| 41 |
ctx = ToolContext(db=db_session, session=await _session(db_session), shop="s.myshopify.com")
|
| 42 |
out = await registry.dispatch(
|
|
|
|
| 27 |
"search_knowledge",
|
| 28 |
"search_products",
|
| 29 |
"create_cart_link",
|
| 30 |
+
"save_cart",
|
| 31 |
"watch_stock",
|
| 32 |
"lookup_order",
|
| 33 |
"reorder",
|
|
|
|
| 38 |
} <= names
|
| 39 |
|
| 40 |
|
| 41 |
+
async def test_save_cart_requires_optin_and_cart(db_session):
|
| 42 |
+
from app.models import AbandonedCart
|
| 43 |
+
from sqlalchemy import select
|
| 44 |
+
|
| 45 |
+
sess = await _session(db_session)
|
| 46 |
+
# opt-out store: the tool is inert (so the bot never promises an email it can't send)
|
| 47 |
+
off = ToolContext(db=db_session, session=sess, tenant_id=1, allow_cart_recovery=False)
|
| 48 |
+
assert (await registry.dispatch("save_cart", {"email": "a@b.com"}, off))["status"] == "unavailable"
|
| 49 |
+
|
| 50 |
+
on = ToolContext(db=db_session, session=sess, tenant_id=1, allow_cart_recovery=True)
|
| 51 |
+
# needs an email
|
| 52 |
+
assert (await registry.dispatch("save_cart", {}, on))["status"] == "need_info"
|
| 53 |
+
# no cart built in this session yet
|
| 54 |
+
assert (await registry.dispatch("save_cart", {"email": "a@b.com"}, on))["status"] == "no_cart"
|
| 55 |
+
|
| 56 |
+
# build a cart, then saving attaches the email to it
|
| 57 |
+
on.shop = "s.myshopify.com"
|
| 58 |
+
await registry.dispatch("create_cart_link", {"items": [{"variant_id": "9"}]}, on)
|
| 59 |
+
out = await registry.dispatch("save_cart", {"email": "Buyer@X.com", "name": "Leo"}, on)
|
| 60 |
+
assert out["status"] == "ok" and out["email"] == "buyer@x.com"
|
| 61 |
+
cart = (await db_session.execute(
|
| 62 |
+
select(AbandonedCart).where(AbandonedCart.session_id == sess.id))).scalar_one()
|
| 63 |
+
assert cart.email == "buyer@x.com" and cart.name == "Leo"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
async def test_create_cart_link_auto_captures_when_recovery_on(db_session):
|
| 67 |
+
from app.models import AbandonedCart
|
| 68 |
+
from sqlalchemy import select
|
| 69 |
+
|
| 70 |
+
sess = await _session(db_session)
|
| 71 |
+
ctx = ToolContext(db=db_session, session=sess, tenant_id=1, shop="s.myshopify.com",
|
| 72 |
+
allow_cart_recovery=True)
|
| 73 |
+
await registry.dispatch("create_cart_link", {"items": [{"variant_id": "5"}]}, ctx)
|
| 74 |
+
cart = (await db_session.execute(
|
| 75 |
+
select(AbandonedCart).where(AbandonedCart.session_id == sess.id))).scalar_one()
|
| 76 |
+
assert cart.checkout_url.endswith("5:1") and cart.email is None # captured, awaiting email
|
| 77 |
+
|
| 78 |
+
|
| 79 |
async def test_cart_link_with_discount(db_session):
|
| 80 |
ctx = ToolContext(db=db_session, session=await _session(db_session), shop="s.myshopify.com")
|
| 81 |
out = await registry.dispatch(
|