victor34593993 commited on
Commit
2994a94
·
verified ·
1 Parent(s): f95d00d

Feature 3: abandoned-cart recovery (opt-in)

Browse files
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 }, "biz-state");
 
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(