victor34593993 commited on
Commit
fb60961
·
verified ·
1 Parent(s): dd13404

kill-switch guarantees + billing hardening

Browse files
app/billing.py CHANGED
@@ -143,6 +143,7 @@ async def handle_checkout_completed(db: AsyncSession, event: dict, *, origin: st
143
  if existing is not None:
144
  # retry/duplicate delivery — make sure it's on and linked, then stop
145
  existing.chatbot_enabled = True
 
146
  if subscription_id:
147
  existing.stripe_subscription_id = subscription_id
148
  await db.flush()
@@ -177,6 +178,39 @@ async def handle_checkout_completed(db: AsyncSession, event: dict, *, origin: st
177
  return {"status": "provisioned", "slug": slug, "email_sent": sent}
178
 
179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  async def handle_subscription_event(db: AsyncSession, event: dict) -> dict:
181
  """Map subscription lifecycle to the chatbot master switch."""
182
  etype = event.get("type") or ""
@@ -191,13 +225,42 @@ async def handle_subscription_event(db: AsyncSession, event: dict) -> dict:
191
  return {"status": "unknown_customer"}
192
 
193
  if etype == "customer.subscription.deleted":
 
 
 
 
 
 
 
 
 
 
 
 
194
  tenant.chatbot_enabled = False
 
 
 
 
195
  await db.flush()
196
  log.info("stripe: subscription deleted -> chatbot OFF for %s", tenant.slug)
197
  return {"status": "disabled", "slug": tenant.slug}
198
  if etype == "invoice.paid":
199
  if not tenant.chatbot_enabled:
 
 
 
 
 
 
 
 
 
 
 
 
200
  tenant.chatbot_enabled = True
 
201
  await db.flush()
202
  log.info("stripe: invoice paid -> chatbot ON for %s", tenant.slug)
203
  return {"status": "enabled", "slug": tenant.slug}
 
143
  if existing is not None:
144
  # retry/duplicate delivery — make sure it's on and linked, then stop
145
  existing.chatbot_enabled = True
146
+ existing.disabled_reason = ""
147
  if subscription_id:
148
  existing.stripe_subscription_id = subscription_id
149
  await db.flush()
 
178
  return {"status": "provisioned", "slug": slug, "email_sent": sent}
179
 
180
 
181
+ def _as_id(value: object) -> str:
182
+ """A Stripe reference can come as the bare id or the expanded object."""
183
+ if isinstance(value, dict):
184
+ return value.get("id") or ""
185
+ return value if isinstance(value, str) else ""
186
+
187
+
188
+ def _invoice_subscription_id(invoice: dict) -> str:
189
+ """Subscription id of an invoice event, across Stripe API versions.
190
+
191
+ Pre-Basil (< 2025-03-31) invoices carry a top-level "subscription"; newer
192
+ versions moved it to parent.subscription_details and onto each line item
193
+ (parent.subscription_item_details / line.subscription). Check them all.
194
+ """
195
+ sub = _as_id(invoice.get("subscription"))
196
+ if sub:
197
+ return sub
198
+ sub = _as_id(((invoice.get("parent") or {}).get("subscription_details") or {})
199
+ .get("subscription"))
200
+ if sub:
201
+ return sub
202
+ for line in (invoice.get("lines") or {}).get("data") or []:
203
+ sub = _as_id(line.get("subscription"))
204
+ if sub:
205
+ return sub
206
+ line_parent = line.get("parent") or {}
207
+ for details in ("subscription_item_details", "subscription_details"):
208
+ sub = _as_id((line_parent.get(details) or {}).get("subscription"))
209
+ if sub:
210
+ return sub
211
+ return ""
212
+
213
+
214
  async def handle_subscription_event(db: AsyncSession, event: dict) -> dict:
215
  """Map subscription lifecycle to the chatbot master switch."""
216
  etype = event.get("type") or ""
 
225
  return {"status": "unknown_customer"}
226
 
227
  if etype == "customer.subscription.deleted":
228
+ deleted_id = _as_id(obj.get("id"))
229
+ if (
230
+ deleted_id
231
+ and tenant.stripe_subscription_id
232
+ and deleted_id != tenant.stripe_subscription_id
233
+ ):
234
+ # Late delivery for an OLD subscription: the client already
235
+ # re-purchased (the tenant is linked to a newer one), so a stale
236
+ # cancel must never switch off a paying client.
237
+ log.info("stripe: stale subscription.deleted (%s) ignored for %s",
238
+ deleted_id, tenant.slug)
239
+ return {"status": "stale_subscription", "slug": tenant.slug}
240
  tenant.chatbot_enabled = False
241
+ tenant.disabled_reason = "billing"
242
+ # Unlink the dead subscription: a late invoice.paid for it (Stripe does
243
+ # not guarantee event order) must never resurrect a canceled client.
244
+ tenant.stripe_subscription_id = ""
245
  await db.flush()
246
  log.info("stripe: subscription deleted -> chatbot OFF for %s", tenant.slug)
247
  return {"status": "disabled", "slug": tenant.slug}
248
  if etype == "invoice.paid":
249
  if not tenant.chatbot_enabled:
250
+ if tenant.disabled_reason == "admin":
251
+ # Manually paused by the owner — only the owner may re-enable.
252
+ log.info("stripe: invoice paid but %s is admin-paused; staying OFF",
253
+ tenant.slug)
254
+ return {"status": "admin_paused", "slug": tenant.slug}
255
+ sub_id = _invoice_subscription_id(obj)
256
+ if not sub_id or sub_id != tenant.stripe_subscription_id:
257
+ # Late/out-of-order invoice for a subscription that is no
258
+ # longer this tenant's active one — do not resurrect.
259
+ log.info("stripe: invoice paid for %s ignored (subscription %r "
260
+ "does not match)", tenant.slug, sub_id)
261
+ return {"status": "subscription_mismatch", "slug": tenant.slug}
262
  tenant.chatbot_enabled = True
263
+ tenant.disabled_reason = ""
264
  await db.flush()
265
  log.info("stripe: invoice paid -> chatbot ON for %s", tenant.slug)
266
  return {"status": "enabled", "slug": tenant.slug}
app/models.py CHANGED
@@ -88,6 +88,10 @@ class Tenant(Base):
88
  # Master switch: admin can turn a client's chatbot off (e.g. non-payment)
89
  # without deleting the account. The widget hides and the chat stops replying.
90
  chatbot_enabled: Mapped[bool] = mapped_column(default=True)
 
 
 
 
91
  # Free-text instructions from the merchant appended to the system prompt
92
  # ("we are a consultancy, never quote delivery times", "your job is to sell
93
  # product X"...). Lets each business tune its assistant's behavior.
 
88
  # Master switch: admin can turn a client's chatbot off (e.g. non-payment)
89
  # without deleting the account. The widget hides and the chat stops replying.
90
  chatbot_enabled: Mapped[bool] = mapped_column(default=True)
91
+ # Why the switch is off: "" (on), "billing" (subscription lapsed) or
92
+ # "admin" (manually paused). A late Stripe invoice.paid may only undo a
93
+ # "billing" pause — it must never resurrect an admin-paused client.
94
+ disabled_reason: Mapped[str] = mapped_column(String(12), default="", server_default="")
95
  # Free-text instructions from the merchant appended to the system prompt
96
  # ("we are a consultancy, never quote delivery times", "your job is to sell
97
  # product X"...). Lets each business tune its assistant's behavior.
app/products/sync.py CHANGED
@@ -122,8 +122,14 @@ async def sync_tenant(
122
 
123
 
124
  async def sync_all_tenants(db: AsyncSession) -> int:
125
- """Sync every tenant that has Shopify connected. Returns tenants synced."""
126
- tenants = (await db.execute(select(Tenant))).scalars().all()
 
 
 
 
 
 
127
  done = 0
128
  for tenant in tenants:
129
  if not tenant.shopify_shop:
 
122
 
123
 
124
  async def sync_all_tenants(db: AsyncSession) -> int:
125
+ """Sync every ACTIVE tenant that has Shopify connected. Returns tenants synced.
126
+
127
+ Paused tenants (``chatbot_enabled=False`` — e.g. payment lapsed) get zero
128
+ service, so their catalogs are not fetched or re-embedded either.
129
+ """
130
+ tenants = (
131
+ await db.execute(select(Tenant).where(Tenant.chatbot_enabled.is_(True)))
132
+ ).scalars().all()
133
  done = 0
134
  for tenant in tenants:
135
  if not tenant.shopify_shop:
app/routes/admin.py CHANGED
@@ -209,6 +209,10 @@ async def put_tenant(
209
  fields = body.model_dump(
210
  exclude={"shopify_client_secret", "whatsapp_token"}, exclude_none=True
211
  )
 
 
 
 
212
  await update_tenant(
213
  db,
214
  tenant,
@@ -547,9 +551,28 @@ async def wa_reply(
547
 
548
  @router.delete("/tenants/{slug}", dependencies=[Depends(require_admin)])
549
  async def delete_tenant(
550
- slug: str, db: AsyncSession = Depends(get_session)
 
 
551
  ) -> dict[str, bool]:
552
  tenant = await _require_tenant(db, slug)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
553
  # Explicitly remove children first (sqlite has no FK enforcement; Postgres
554
  # would cascade, but be explicit so both backends behave the same).
555
  await db.execute(delete(KnowledgeChunk).where(KnowledgeChunk.tenant_id == tenant.id))
 
209
  fields = body.model_dump(
210
  exclude={"shopify_client_secret", "whatsapp_token"}, exclude_none=True
211
  )
212
+ if body.chatbot_enabled is not None:
213
+ # Track WHO turned the switch: an admin pause may only be undone by the
214
+ # admin — a late Stripe invoice.paid must not re-enable this tenant.
215
+ tenant.disabled_reason = "" if body.chatbot_enabled else "admin"
216
  await update_tenant(
217
  db,
218
  tenant,
 
551
 
552
  @router.delete("/tenants/{slug}", dependencies=[Depends(require_admin)])
553
  async def delete_tenant(
554
+ slug: str,
555
+ db: AsyncSession = Depends(get_session),
556
+ settings: Settings = Depends(get_settings),
557
  ) -> dict[str, bool]:
558
  tenant = await _require_tenant(db, slug)
559
+ # Cancel the client's Stripe subscription first (best-effort): a deleted
560
+ # tenant must never keep getting charged. Skipped when no key is configured
561
+ # (dev/tests); a Stripe failure never blocks the deletion itself.
562
+ if tenant.stripe_subscription_id and settings.stripe_secret_key:
563
+ import httpx
564
+
565
+ url = f"https://api.stripe.com/v1/subscriptions/{tenant.stripe_subscription_id}"
566
+ try:
567
+ async with httpx.AsyncClient(timeout=15.0) as client:
568
+ r = await client.delete(
569
+ url, headers={"Authorization": f"Bearer {settings.stripe_secret_key}"}
570
+ )
571
+ log.info("stripe: canceled subscription %s for deleted tenant %s (%s)",
572
+ tenant.stripe_subscription_id, tenant.slug, r.status_code)
573
+ except Exception: # noqa: BLE001 - deletion must not depend on Stripe
574
+ log.exception("stripe cancel failed for %s; deleting tenant anyway",
575
+ tenant.slug)
576
  # Explicitly remove children first (sqlite has no FK enforcement; Postgres
577
  # would cascade, but be explicit so both backends behave the same).
578
  await db.execute(delete(KnowledgeChunk).where(KnowledgeChunk.tenant_id == tenant.id))
app/routes/chat.py CHANGED
@@ -63,6 +63,11 @@ async def _resolve_tenant(db: AsyncSession, slug: str | None, *, shop: str = "")
63
  tenant = await get_tenant_by_slug(db, slug)
64
  if tenant is not None:
65
  return tenant
 
 
 
 
 
66
  if shop:
67
  tenant = (
68
  await db.execute(select(Tenant).where(Tenant.shopify_shop == shop))
@@ -146,6 +151,10 @@ async def handoff_submit(
146
  """Public: the widget's 'talk to a human' contact form submits here. Stores
147
  the request (no SMTP needed) so the merchant follows up by email."""
148
  tenant = await _resolve_tenant(db, request.query_params.get("t"))
 
 
 
 
149
  await create_handoff(
150
  db, tenant.id,
151
  name=body.name or "", email=body.email, message=body.message or "",
 
63
  tenant = await get_tenant_by_slug(db, slug)
64
  if tenant is not None:
65
  return tenant
66
+ if slug != DEFAULT_SLUG:
67
+ # An explicit slug that matches no tenant means a deleted/unknown
68
+ # client. NEVER fall back to default: their site still embeds the
69
+ # widget line and would keep a working bot at the owner's expense.
70
+ raise HTTPException(status_code=404, detail="unknown tenant")
71
  if shop:
72
  tenant = (
73
  await db.execute(select(Tenant).where(Tenant.shopify_shop == shop))
 
151
  """Public: the widget's 'talk to a human' contact form submits here. Stores
152
  the request (no SMTP needed) so the merchant follows up by email."""
153
  tenant = await _resolve_tenant(db, request.query_params.get("t"))
154
+ if not tenant.chatbot_enabled:
155
+ # Same gate as chat: a paused (non-paying) store must not keep
156
+ # harvesting leads (stored + emailed) through the contact form.
157
+ raise HTTPException(status_code=403, detail="chatbot disabled for this store")
158
  await create_handoff(
159
  db, tenant.id,
160
  name=body.name or "", email=body.email, message=body.message or "",
app/routes/portal.py CHANGED
@@ -72,6 +72,16 @@ async def require_portal_tenant(
72
  return tenant
73
 
74
 
 
 
 
 
 
 
 
 
 
 
75
  @router.get("", response_class=HTMLResponse)
76
  @router.get("/", response_class=HTMLResponse)
77
  async def portal_page() -> HTMLResponse:
@@ -170,6 +180,7 @@ async def portal_add_url(
170
  tenant: Tenant = Depends(require_portal_tenant),
171
  db: AsyncSession = Depends(get_session),
172
  ) -> SourceOut:
 
173
  src = await sources.add_url_source(db, tenant, body.location)
174
  await db.commit()
175
  return SourceOut.model_validate(src, from_attributes=True)
@@ -181,6 +192,7 @@ async def portal_add_file(
181
  tenant: Tenant = Depends(require_portal_tenant),
182
  db: AsyncSession = Depends(get_session),
183
  ) -> SourceOut:
 
184
  src = await sources.add_file_source(db, tenant, file.filename, await file.read())
185
  await db.commit()
186
  return SourceOut.model_validate(src, from_attributes=True)
@@ -202,6 +214,7 @@ async def portal_reindex(
202
  tenant: Tenant = Depends(require_portal_tenant),
203
  db: AsyncSession = Depends(get_session),
204
  ) -> dict[str, str]:
 
205
  await index.reindex_all(db, tenant_id=tenant.id)
206
  await db.commit()
207
  return {"status": "reindexed"}
@@ -212,6 +225,7 @@ async def portal_sync_products(
212
  tenant: Tenant = Depends(require_portal_tenant),
213
  db: AsyncSession = Depends(get_session),
214
  ) -> dict:
 
215
  from app.products import sync as product_sync
216
 
217
  res = await product_sync.sync_tenant(db, tenant)
 
72
  return tenant
73
 
74
 
75
+ def _require_active(tenant: Tenant) -> None:
76
+ """Resource-consuming actions (crawling, embedding, Shopify catalog pulls)
77
+ are paid by the owner: a paused tenant (``chatbot_enabled=False``, e.g.
78
+ payment lapsed) gets zero service here too, same rule as the chat itself.
79
+ The rest of the portal (leads, config) stays readable so the client can
80
+ come back and resubscribe."""
81
+ if not tenant.chatbot_enabled:
82
+ raise HTTPException(status_code=403, detail="chatbot disabled for this store")
83
+
84
+
85
  @router.get("", response_class=HTMLResponse)
86
  @router.get("/", response_class=HTMLResponse)
87
  async def portal_page() -> HTMLResponse:
 
180
  tenant: Tenant = Depends(require_portal_tenant),
181
  db: AsyncSession = Depends(get_session),
182
  ) -> SourceOut:
183
+ _require_active(tenant)
184
  src = await sources.add_url_source(db, tenant, body.location)
185
  await db.commit()
186
  return SourceOut.model_validate(src, from_attributes=True)
 
192
  tenant: Tenant = Depends(require_portal_tenant),
193
  db: AsyncSession = Depends(get_session),
194
  ) -> SourceOut:
195
+ _require_active(tenant)
196
  src = await sources.add_file_source(db, tenant, file.filename, await file.read())
197
  await db.commit()
198
  return SourceOut.model_validate(src, from_attributes=True)
 
214
  tenant: Tenant = Depends(require_portal_tenant),
215
  db: AsyncSession = Depends(get_session),
216
  ) -> dict[str, str]:
217
+ _require_active(tenant)
218
  await index.reindex_all(db, tenant_id=tenant.id)
219
  await db.commit()
220
  return {"status": "reindexed"}
 
225
  tenant: Tenant = Depends(require_portal_tenant),
226
  db: AsyncSession = Depends(get_session),
227
  ) -> dict:
228
+ _require_active(tenant)
229
  from app.products import sync as product_sync
230
 
231
  res = await product_sync.sync_tenant(db, tenant)
app/routes/widget.py CHANGED
@@ -123,12 +123,13 @@ async def widget_config(
123
  # Never cache: a store's branding/mode/starter changes must show on next load.
124
  response.headers["Cache-Control"] = "no-store, max-age=0"
125
  tenant = await get_tenant_by_slug(db, t)
126
- if tenant is None and t != DEFAULT_SLUG:
127
- # Match the chat's resolution (an unknown slug falls back to default), so
128
- # branding and chat never disagree about which tenant a widget shows.
129
- tenant = await get_tenant_by_slug(db, DEFAULT_SLUG)
130
  if tenant is None:
 
 
 
 
131
  return {
 
132
  "brand_name": "Asistente",
133
  "brand_color": "#e8491d",
134
  "welcome": "¡Hola! ¿En qué puedo ayudarte?",
 
123
  # Never cache: a store's branding/mode/starter changes must show on next load.
124
  response.headers["Cache-Control"] = "no-store, max-age=0"
125
  tenant = await get_tenant_by_slug(db, t)
 
 
 
 
126
  if tenant is None:
127
+ # Unknown/deleted slug (or missing default tenant): match the chat's
128
+ # resolution, which 404s for unknown slugs. enabled=False makes the
129
+ # widget render NOTHING even though the embed line is still on the
130
+ # deleted client's site.
131
  return {
132
+ "enabled": False,
133
  "brand_name": "Asistente",
134
  "brand_color": "#e8491d",
135
  "welcome": "¡Hola! ¿En qué puedo ayudarte?",
app/scheduler.py CHANGED
@@ -12,11 +12,12 @@ import asyncio
12
  import logging
13
  from datetime import UTC, datetime, timedelta
14
 
15
- from sqlalchemy import select
16
  from sqlalchemy.ext.asyncio import AsyncSession
17
 
 
18
  from app.db import get_sessionmaker
19
- from app.models import KnowledgeSource
20
  from app.rag import index
21
  from app.store import get_config, upsert_config
22
 
@@ -24,18 +25,36 @@ log = logging.getLogger(__name__)
24
 
25
  _KEY = "last_url_reindex"
26
  _PRODUCT_KEY = "last_product_sync"
 
27
  REINDEX_EVERY = timedelta(days=7)
28
  PRODUCT_SYNC_EVERY = timedelta(days=1) # catalog text changes more often than docs
 
29
  CHECK_EVERY_SECONDS = 6 * 3600
30
  FIRST_DELAY_SECONDS = 120 # let boot settle before the first check
31
 
 
 
 
 
32
 
33
  async def reindex_all_urls(db: AsyncSession) -> int:
34
- """Re-fetch + re-embed every tenant's URL sources. File sources are static
35
- so they're skipped. Per-tenant isolation is intact (each chunk keeps its
 
 
 
36
  own tenant_id). Returns how many sources were refreshed."""
 
37
  srcs = (
38
- await db.execute(select(KnowledgeSource).where(KnowledgeSource.kind == "url"))
 
 
 
 
 
 
 
 
39
  ).scalars().all()
40
  done = 0
41
  for src in srcs:
@@ -82,10 +101,80 @@ async def _maybe_sync_products() -> None:
82
  log.info("auto-synced product corpus for %d tenant(s)", n)
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  async def run_periodic_reindex() -> None:
86
  await asyncio.sleep(FIRST_DELAY_SECONDS)
87
  while True:
88
- for tick in (_maybe_reindex, _maybe_sync_products):
 
 
89
  try:
90
  await tick()
91
  except Exception: # noqa: BLE001 - never let the loop die
 
12
  import logging
13
  from datetime import UTC, datetime, timedelta
14
 
15
+ from sqlalchemy import or_, select
16
  from sqlalchemy.ext.asyncio import AsyncSession
17
 
18
+ from app.config import get_settings
19
  from app.db import get_sessionmaker
20
+ from app.models import KnowledgeSource, Tenant
21
  from app.rag import index
22
  from app.store import get_config, upsert_config
23
 
 
25
 
26
  _KEY = "last_url_reindex"
27
  _PRODUCT_KEY = "last_product_sync"
28
+ _BILLING_KEY = "last_billing_reconcile"
29
  REINDEX_EVERY = timedelta(days=7)
30
  PRODUCT_SYNC_EVERY = timedelta(days=1) # catalog text changes more often than docs
31
+ BILLING_RECONCILE_EVERY = timedelta(days=1)
32
  CHECK_EVERY_SECONDS = 6 * 3600
33
  FIRST_DELAY_SECONDS = 120 # let boot settle before the first check
34
 
35
+ # Stripe subscription states that mean "this client is no longer paying".
36
+ # past_due/incomplete are still recoverable, so they keep service for now.
37
+ DEAD_SUBSCRIPTION_STATUSES = {"canceled", "unpaid", "incomplete_expired"}
38
+
39
 
40
  async def reindex_all_urls(db: AsyncSession) -> int:
41
+ """Re-fetch + re-embed URL sources of ACTIVE tenants. File sources are
42
+ static so they're skipped, and paused tenants (chatbot_enabled=False, e.g.
43
+ payment lapsed) get zero service — their URLs are never crawled or
44
+ re-embedded. Legacy sources without a tenant (tenant_id NULL) keep
45
+ refreshing as before. Per-tenant isolation is intact (each chunk keeps its
46
  own tenant_id). Returns how many sources were refreshed."""
47
+ paused_ids = select(Tenant.id).where(Tenant.chatbot_enabled.is_(False))
48
  srcs = (
49
+ await db.execute(
50
+ select(KnowledgeSource).where(
51
+ KnowledgeSource.kind == "url",
52
+ or_(
53
+ KnowledgeSource.tenant_id.is_(None),
54
+ KnowledgeSource.tenant_id.not_in(paused_ids),
55
+ ),
56
+ )
57
+ )
58
  ).scalars().all()
59
  done = 0
60
  for src in srcs:
 
101
  log.info("auto-synced product corpus for %d tenant(s)", n)
102
 
103
 
104
+ async def reconcile_billing(db: AsyncSession) -> int:
105
+ """Safety net for missed Stripe webhooks: ask Stripe directly whether each
106
+ paying tenant's subscription is still alive, and switch off the ones that
107
+ are not (zero service for non-payers, same as the webhook would do).
108
+
109
+ Skipped entirely when no Stripe key is configured (dev/tests). A Stripe
110
+ error on one tenant is logged and never blocks the rest — a flaky network
111
+ must not crash the scheduler loop. Returns how many tenants were paused.
112
+ """
113
+ settings = get_settings()
114
+ if not settings.stripe_secret_key:
115
+ return 0
116
+ tenants = (
117
+ await db.execute(
118
+ select(Tenant).where(
119
+ Tenant.chatbot_enabled.is_(True),
120
+ Tenant.stripe_subscription_id != "",
121
+ )
122
+ )
123
+ ).scalars().all()
124
+ if not tenants:
125
+ return 0
126
+ import httpx
127
+
128
+ paused = 0
129
+ headers = {"Authorization": f"Bearer {settings.stripe_secret_key}"}
130
+ async with httpx.AsyncClient(timeout=15.0) as client:
131
+ for tenant in tenants:
132
+ url = f"https://api.stripe.com/v1/subscriptions/{tenant.stripe_subscription_id}"
133
+ try:
134
+ r = await client.get(url, headers=headers)
135
+ status = (r.json() or {}).get("status", "")
136
+ except Exception: # noqa: BLE001 - one Stripe hiccup must not stop the rest
137
+ log.warning(
138
+ "billing reconcile: Stripe check failed for tenant %s", tenant.slug,
139
+ exc_info=True,
140
+ )
141
+ continue
142
+ if status in DEAD_SUBSCRIPTION_STATUSES:
143
+ tenant.chatbot_enabled = False
144
+ tenant.disabled_reason = "billing"
145
+ if status != "unpaid":
146
+ # canceled/incomplete_expired are terminal: unlink the dead
147
+ # subscription (same as the webhook) so a late invoice.paid
148
+ # for it can never resurrect this tenant. "unpaid" stays
149
+ # linked — actually paying it legitimately re-enables.
150
+ tenant.stripe_subscription_id = ""
151
+ paused += 1
152
+ log.warning(
153
+ "billing reconcile: subscription %s of tenant %s is %s -> chatbot off",
154
+ tenant.stripe_subscription_id, tenant.slug, status,
155
+ )
156
+ await db.commit()
157
+ return paused
158
+
159
+
160
+ async def _maybe_reconcile_billing() -> None:
161
+ async with get_sessionmaker()() as db:
162
+ now = datetime.now(UTC)
163
+ if not _due(await get_config(db, _BILLING_KEY) or {}, BILLING_RECONCILE_EVERY, now):
164
+ return
165
+ n = await reconcile_billing(db)
166
+ await upsert_config(db, _BILLING_KEY, {"at": now.isoformat()})
167
+ await db.commit()
168
+ if n:
169
+ log.warning("billing reconcile paused %d tenant(s)", n)
170
+
171
+
172
  async def run_periodic_reindex() -> None:
173
  await asyncio.sleep(FIRST_DELAY_SECONDS)
174
  while True:
175
+ # Billing first: a tenant paused for non-payment must not get one
176
+ # last crawl/sync out of the very same tick.
177
+ for tick in (_maybe_reconcile_billing, _maybe_reindex, _maybe_sync_products):
178
  try:
179
  await tick()
180
  except Exception: # noqa: BLE001 - never let the loop die
app/static/widget.js CHANGED
@@ -180,7 +180,7 @@
180
  }
181
 
182
  function build() {
183
- if (cfg.enabled === false) return; // admin disabled this store's chatbot
184
  injectStyles();
185
  var name = cfg.assistant_name || cfg.brand_name;
186
 
 
180
  }
181
 
182
  function build() {
183
+ if (cfg.enabled !== true) return; // fail closed: disabled/unknown/error -> render nothing
184
  injectStyles();
185
  var name = cfg.assistant_name || cfg.brand_name;
186
 
docs/IDEAS-2026-06-11.md ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Informe de ideas — inteligencia y valor diferencial (2026-06-11)
2
+
3
+ INFORME FINAL — TOP 10 IDEAS PARA ATENDYO (ponderación: impacto 45% / unicidad 30% / viabilidad 25%, media de 3 jueces)
4
+
5
+ Repo base de todo: /Users/victorgomez/code/shopify-support-bot
6
+
7
+ Nota previa de consolidación: los jueces detectaron duplicados. "Recuperador de carritos por WhatsApp" (7.48) y "Rescate de Carrito Conversacional" (7.50) son la misma feature — aquí van fusionadas en una sola entrada (quedándonos con el truco de demo del segundo). Igual con el trío back-in-stock (Aviso-Venta / Dinero en Espera / Avisos que Venden): se construye UNA vez, en su versión "Dinero en Espera". Esto libera hueco para Perfil España en el puesto 10.
8
+
9
+ ========================================
10
+ TOP 10
11
+ ========================================
12
+
13
+ 1. DEMO INSTANTANEA "Mira cómo aprende tu tienda en 60 segundos" — 8.00 (I 9.0 / U 7.3 / F 7.0)
14
+ Pitch: "Pega la URL de tu tienda y en un minuto hablas con un bot que ya se sabe tus productos, tus envíos y tus devoluciones — sin registrarte y sin tarjeta."
15
+ Qué hace: campo público en atendyo.com; el prospecto pega su URL, el sistema crawlea web + catálogo Shopify narrando el progreso en español, y abre un chat funcional contra un tenant efímero que se autodestruye a las 48h. El enlace es compartible y captura el lead.
16
+ Por qué no lo tiene nadie: Chatbase/Tidio exigen cuenta, créditos y setup DIY antes de que el bot sepa nada; los gigantes no pueden regalar ingestión anónima porque cada demo les cuesta dinero de OpenAI — tu coste marginal es ~0, y el "teatro de progreso" convierte la CPU lenta gratuita en parte del show.
17
+ Build: reusa crawler BFS + chunker + fastembed + cold-start de productos casi tal cual; nuevo: tenant efímero con TTL (extender el purge RGPD), stream de progreso (SSE o polling), landing UI. OJO: presupuestar control de abuso (SSRF, límites de crawl, rate-limit duro para que no te quemen las keys gratuitas). 3-5 días.
18
+ Es la única idea que fabrica prospectos en vez de asumirlos: con el cold email muerto, esto ES el funnel.
19
+
20
+ 2. QR MAGICO: el bot de TU tienda en TU WhatsApp — 8.00 (I 8.3 / U 8.3 / F 7.0)
21
+ Pitch: "Escanea este código y el bot de tu propia tienda te contesta en tu WhatsApp de toda la vida — hoy, no dentro de un mes de implantación."
22
+ Qué hace: al final de la demo instantánea, un QR abre wa.me a un número demo compartido de Atendyo con código prellenado (DEMO-tutienda); el webhook mapea el código al tenant efímero y el móvil del prospecto conversa con el bot de SU tienda, consultas de pedidos incluidas.
23
+ Por qué no lo tiene nadie: Gorgias AI no corre en WhatsApp a ningún precio, Crisp lo encierra tras 95 USD/mes, Landbot lo vende como producto aparte de ~200 EUR/mes. Nadie puede meter un bot de WhatsApp funcionando para la tienda del propio prospecto en su bolsillo durante la demo. En España, el móvil vibrando con tu propio catálogo es lo que cierra la venta.
24
+ Build: el cerebro WhatsApp completo ya existe (webhooks, dedup, sesiones, tools); nuevo: un número WABA demo propio, routing del keyword al tenant demo, limpieza TTL. La asincronía de WhatsApp (5-30s aceptables) esconde la CPU lenta. 2-3 días + alta del número en Meta.
25
+
26
+ 3. EL EXAMEN DEL BOT (boletín de notas de tu tienda) — 7.80 (I 8.0 / U 7.3 / F 8.0)
27
+ Pitch: "Antes de cobrarte nada, el bot se examina delante de ti: 10 preguntas reales de clientes sobre TU tienda, respondidas con nota y con la fuente de cada respuesta."
28
+ Qué hace: tras la ingesta (en la demo y como botón del portal), el LLM genera las 10 preguntas más probables desde el contenido crawleado, las responde por el pipeline normal y pinta un boletín: verde con fuente, ámbar "tu web no lo dice" con botón "añadir respuesta" que escribe directo en la base de conocimiento.
29
+ Por qué no lo tiene nadie: Tidio tiene un Playground de una pregunta, Chatbase no tiene simulación bulk, Fin asume un help center maduro. Convertir la autoauditoría del bot en el artefacto de venta ("tu web no responde 3 de estas 10") es un movimiento consultivo que un self-serve gigante no va a hacer — y da al email outreach algo honesto que enviar.
30
+ Build: una llamada de cascada para generar preguntas sobre los chunks RAG existentes; respuestas vía pipeline /chat; informe = página simple + plumbing de unresolved-questions ya existente. Corre async en CPU (resultados apareciendo uno a uno = más teatro). 2-3 días.
31
+
32
+ 4. RESCATE DE CARRITO POR WHATSAPP (fusión de las dos fichas de carrito) — 7.50
33
+ Pitch: "Si el cliente pide su carrito y desaparece sin comprar, el bot le escribe solo al día siguiente con el carrito listo — y recupera la venta sin coste por mensaje."
34
+ Qué hace: un job del scheduler escanea sesiones con cart_link sin pedido posterior; en WhatsApp manda UN solo nudge educado dentro de la ventana de servicio de 24h (coste Meta: 0 EUR), en web email Brevo si hay email capturado, o el widget retoma sesión al volver el visitante. Opt-in por tenant, copy editable, horas de silencio. Robar el truco del "modo demo": nudge a los 2 minutos en directo.
35
+ Por qué no lo tiene nadie: la recuperación de carritos por WhatsApp cuesta 50-200 EUR/mes como producto separado (TextYess, CartsGuru); recovery originado en chat no existe en el set competitivo, y Tidio/Gorgias ni siquiera pueden iniciar mensajes en WhatsApp. Es la frase de ingresos que el comerciante español ya quiere comprar.
36
+ Build: eventos products_shown/cart_link ya se registran, el envío WhatsApp ya existe; falta poll de pedidos (o webhook orders/paid) para supresión + job de nudge + toggle en portal. La parte oculta es el matching de identidad. ~1 semana.
37
+
38
+ 5. FACTURA CON NIF EN EL CHAT — 7.38 (la unicidad más alta de toda la lista: 9.0)
39
+ Pitch: "Cuando un cliente pide factura con IVA, el bot le toma el NIF, lo valida al instante y te la deja lista para emitir — sin emails de ida y vuelta."
40
+ Qué hace: tool nuevo: verifica el pedido (flujo existente), recoge conversacionalmente razón social + NIF/CIF/NIE + dirección fiscal, valida el checksum de forma determinista (~30 líneas, caza typos al momento) y te envía por Brevo un borrador de factura con líneas del pedido y desglose de IVA (21/10/4%).
41
+ Por qué no lo tiene nadie: ningún chatbot del mercado sabe qué es un NIF. Es un dolor inexportablemente español que los gigantes jamás localizarán para un tier sub-50 EUR. Señal instantánea de "esto está hecho para tiendas como la mía" y diferenciador potente en el email frío.
42
+ Build: validación determinista (coste cero), recogida conversacional reusa la maquinaria de confirmación/verificación, salida = email Brevo + fila FacturaRequest en el inbox de leads del portal. 4-6 días.
43
+
44
+ 6. INFORME "TU BOT SE PAGA SOLO" — 7.32
45
+ Pitch: "Cada lunes recibes un WhatsApp: 'tu bot generó 437 EUR esta semana', con carritos recuperados y pedidos cerrados demostrados con datos de Shopify, no con humo."
46
+ Qué hace: cart links pasan a ser redirects trackeados (/go/<token>), redenciones de códigos únicos y carritos recuperados se cruzan con pedidos Shopify, y un mensaje semanal al dueño reporta ventas asistidas en euros + carritos + alertas convertidas + top preguntas sin respuesta. Mismo dato en un tile "ventas asistidas" del portal. Consolidar aquí "Informe del Lunes" y "Mientras Dormías" (1-2 días extra de SQL) en UN solo mensaje de lunes con los euros arriba.
47
+ Por qué no lo tiene nadie: todos los incumbentes reportan deflection/resoluciones — su unidad de facturación — nunca ingresos del comerciante; los dashboards de revenue viven en el tier de 360+ USD/mes de Gorgias. A 10 EUR/trimestre, UNA venta atribuida hace la frase de ROI incontestable y renueva sola.
48
+ Build: endpoint /go + tabla de tokens, poll de pedidos, plantilla Brevo, reuso del ledger de analytics. ~1 semana. Aviso de los jueces: atribución honesta o nada — un euro inventado es peor que ninguno. Depende de que existan cart links y códigos: va DESPUÉS del rescate de carritos.
49
+
50
+ 7. EL RETO DEL DESCUENTO (modo a prueba de jetas) — 7.27
51
+ Pitch: "Reta al bot: intenta sacarle un descuento que no existe o inventarte una política de devoluciones — no lo vas a conseguir."
52
+ Qué hace: guard determinista de salida (misma filosofía que el confirm-gate existente): escanea respuestas buscando códigos/porcentajes fuera del allowlist del tenant y patrones de promesa de política, bloquea y reescribe a un honesto "no me consta, te paso con el dueño". En demo, contador en vivo de intentos bloqueados.
53
+ Por qué no lo tiene nadie: tras el caso viral del 80% de descuento negociado, ningún vendor SMB se atreve a vender "intenta romperlo en directo" — pesadilla legal para Intercom/Zendesk e imposible para competidores prompt-only. Convierte el miedo nº1 en el argumento de cierre.
54
+ Build: regex/string scan contra allowlist (create_cart_link ya sanea códigos), pase de reescritura, evento de analytics, banner demo. 1-2 días. Cautela unánime de los jueces: endurécelo de verdad antes de retar a nadie en público, y no uses la palabra "imposible" hasta que el guard la merezca.
55
+
56
+ 8. DINERO EN ESPERA (la versión a construir del trío back-in-stock) — 7.25 (viabilidad 9.0)
57
+ Pitch: "Ve cuántos euros tienes esperando en productos agotados — y en cuanto repones, avisamos nosotros a cada cliente con su enlace de compra."
58
+ Qué hace: card del portal que agrega los StockWatch ya capturados por producto ("8 personas esperan la camiseta azul M = 240 EUR"); el sync diario detecta la reposición y dispara por fin el email prometido con permalink add-to-cart, marcando StockWatch.notified.
59
+ Por qué no lo tiene nadie: las apps standalone de back-in-stock cuestan 19-49 USD/mes ellas solas; nadie bundlea captura + dashboard de demanda en euros + recovery automático cerca de 10 EUR/trimestre. Y repara la promesa rota que el bot hace HOY ("te avisaremos" y nunca avisa) — el coste de no hacerlo es reputacional.
60
+ Build: la idea de menor riesgo de toda la lista: captura y diff diario ya existen (app/products/sync.py, app/scheduler.py); falta el check de transición de inventario, cola Brevo respetando 300/día, flag notified y una card con suma de precios. 2-3 días.
61
+
62
+ 9. RESPUESTAS CON LEY (garantías y devoluciones según la ley española) — 7.17 (U 8.7)
63
+ Pitch: "Tu bot responde sobre garantías y desistimiento citando la ley española de verdad — 3 años de garantía, 14 días de desistimiento — y jamás se inventa una política."
64
+ Qué hace: corpus legal curado (RD-ley 7/2021: garantía de 3 años, desistimiento 14 días, gastos de devolución, matices Canarias/UE) precargado como RAG compartido en ES/CA/GL/EU, fusionado con la política propia de la tienda (la tienda gana, la ley es el suelo), citando artículo.
65
+ Por qué no lo tiene nadie: ningún bot competidor sabe nada de derecho de consumo español; la demo lado a lado es demoledora (Chatbase inventa una política americana de 30 días, el tuyo cita el art. 120). Un vendor US jamás mantendrá contenido estatutario español para un micro-tier.
66
+ Build: ~30 chunks Q&A por idioma en un namespace pgvector compartido consultado junto a los del tenant. 3-5 días de contenido + 1-2 de código. Condición de los jueces: una pasada de revisión jurídica real y disclaimer de responsabilidad antes de venderlo — una respuesta estatutaria errónea es responsabilidad tuya.
67
+
68
+ 10. PERFIL ESPAÑA: Bizum, contrareembolso y Canarias — 7.13
69
+ Pitch: "Marca unas casillas — Bizum sí, contrareembolso no, Canarias con IGIC — y tu bot contesta esas preguntas eternas sin que escribas ni una FAQ."
70
+ Qué hace: formulario estructurado en el portal (métodos de pago, zonas de envío con precio/días incl. Canarias/Baleares/Ceuta/Melilla, umbral de envío gratis) servido como tool determinista: "¿puedo pagar con Bizum?" o "¿enviáis a Canarias?" se responden exactos, nunca alucinados, con plantillas IGIC/DUA. El crawler pre-rellena detectando logos de pago en el checkout/footer.
71
+ Por qué no lo tiene nadie: los bots US literalmente no saben que existe Bizum; los RAG-only lo responden de lo que pilló el crawl o se lo inventan. "Sabe lo de Canarias" es la prueba silenciosa de localización que ningún bot americano puede fingir. Conviene tratarlo como parte del onboarding done-for-you, no como feature suelta.
72
+ Build: una columna JSON en Postgres + formulario + tool de lookup determinista que el orquestador ya sabe rutar; pre-fill = regex sobre páginas ya crawleadas. 4-5 días.
73
+
74
+ Mención fuera por décimas (7.12): "Fuentes a la vista / Respuestas con recibo" (mismo feature en dos fichas): chips de fuente en cada respuesta + el camino honesto "esto no me consta". Dos días de trabajo que atacan el bloqueador de compra nº1 en cada respuesta de cada demo. Yo lo colaría igualmente en la primera semana — el metadata de chunk ya lleva la URL de origen.
75
+
76
+ ========================================
77
+ LOS 3 QUE ENVIARIA PRIMERO (la historia coherente "esto no lo tiene nadie")
78
+ ========================================
79
+
80
+ 1º Demo instantánea -> 2º El examen del bot -> 3º QR mágico
81
+
82
+ Son una sola máquina de adquisición en tres actos, y atacan tu cuello de botella real: tienes 0 clientes, 0 red y el cold email muerto. Todo lo demás de la lista necesita ser VISTO; estas tres son las únicas que fabrican el ser visto.
83
+
84
+ El guion de venta queda así, de punta a punta sin intervención tuya:
85
+ - El comerciante pega su URL (o la pegas tú y le mandas el enlace): en 60 segundos el bot se sabe su tienda. Nadie en el mercado deja ver eso antes de registrarse.
86
+ - Acto seguido el bot se examina delante de él: "tu web no responde 3 de estas 10 preguntas" — el cierre consultivo que además justifica los 200 EUR de setup.
87
+ - Escanea el QR y su propia tienda le contesta en SU WhatsApp mientras va en el metro. En España, eso cierra.
88
+
89
+ Coste total: ~7-11 días de trabajo, marginal ~0 EUR, y cada pieza alimenta a la siguiente (el demo crea el tenant efímero que usan el examen y el QR). Además le da al pipeline de email parado algo honesto que enviar: no "compra mi bot" sino "tu web no responde estas 3 preguntas de tus clientes — míralo tú mismo, sin registro".
90
+
91
+ Después de ese trío, la primera oleada de producto: Dinero en Espera (2-3 días, repara la mentira actual), Fuentes a la vista (2 días, confianza), Rescate de carritos (la feature de ingresos), y entonces el Informe "se paga solo" para que renueven.
92
+
93
+ ========================================
94
+ 3 IDEAS CON BUEN IMPACTO QUE RECHAZARIA (por ahora)
95
+ ========================================
96
+
97
+ 1. Tu Pedido Te Habla (avisos de envío proactivos por WhatsApp) — impacto 6.7, unicidad 7.3, pero viabilidad 4.7, la peor de las grandes. "Respondes a la notificación y el bot te contesta" es un foso real, pero exige WABA por tenant, aprobación de plantillas en Meta, opt-in en el checkout y facturación pass-through: un pantano operativo que un producto de 10 EUR/trimestre gestionado por una sola persona no puede absorber. Versión email primero; plantillas WhatsApp cuando haya clientes que lo pidan.
98
+
99
+ 2. El Bot Te Pregunta a Ti (teach-loop por WhatsApp del dueño) — impacto 6.7, la mejor encarnación de "un empleado, no un software", pero parsear la respuesta libre de un dueño distraído por WhatsApp hacia la pregunta pendiente correcta es exactamente donde los modelos gratuitos van a morir: una máquina de estados frágil que convierte respuestas mal enrutadas en conocimiento permanente equivocado. Mismo valor con un décimo del riesgo: el "Buzón de dudas"/gap-filler con magic link y borrador pre-redactado. Nota: hay CUATRO fichas de teach-loop en la lista; se construye UNA.
100
+
101
+ 3. Radar de Ventas Perdidas — unicidad 8.0 y la frase "la talla 42 te costó 540 EUR" es matadora… hasta que el clasificador de la cascada gratuita infla un número, el comerciante escéptico lo caza UNA vez y deja de creerse todos los demás números que le enseñas — incluido el informe de ROI, que es tu arma de renovación. Con tiendas de 30 pedidos/mes tampoco hay volumen para que las cifras signifiquen algo durante meses. V2 para cuando haya tráfico y un clasificador validado.
102
+
103
+ Patrón general de los jueces que conviene grabarse: la lista contenía ~38 ideas reales disfrazadas de 44 (tres fichas de back-in-stock, dos de carritos, dos de citas de fuentes, dos de exit-intent, cuatro teach-loops). Cada duplicado se construye una vez y se cuenta una vez. Y la regla de oro para tu situación: primero lo que fabrica prospectos (el trío demo), luego lo que repara promesas rotas, luego lo que genera euros atribuibles, y solo al final lo que retiene — porque sin clientes no hay nada que retener.
migrations/versions/0018_disabled_reason.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Why a tenant's chatbot is off: "" | "billing" | "admin"
2
+
3
+ Revision ID: 0018_disabled_reason
4
+ Revises: 0017_custom_instructions
5
+ Create Date: 2026-06-11
6
+
7
+ Additive, Postgres-guarded. Tests use create_all.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from alembic import op
13
+
14
+ revision = "0018_disabled_reason"
15
+ down_revision = "0017_custom_instructions"
16
+ branch_labels = None
17
+ depends_on = None
18
+
19
+
20
+ def upgrade() -> None:
21
+ if op.get_bind().dialect.name != "postgresql":
22
+ return
23
+ op.execute(
24
+ "ALTER TABLE tenants ADD COLUMN IF NOT EXISTS disabled_reason VARCHAR(12) NOT NULL DEFAULT ''"
25
+ )
26
+
27
+
28
+ def downgrade() -> None:
29
+ if op.get_bind().dialect.name != "postgresql":
30
+ return
31
+ op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS disabled_reason")
tests/products/test_corpus.py CHANGED
@@ -90,6 +90,38 @@ async def test_sync_builds_corpus_idempotent_and_prunes(db_session):
90
  assert len(remaining) == 1
91
 
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  async def test_cross_language_semantic_retrieval(db_session):
94
  t = await _tenant(db_session, "store-cl")
95
  nodes = [
 
90
  assert len(remaining) == 1
91
 
92
 
93
+ async def test_sync_all_tenants_skips_paused_syncs_active(db_session, monkeypatch):
94
+ """A paused tenant (chatbot_enabled=False, e.g. stopped paying) must consume
95
+ zero sync resources — no client resolved, no rows written — while an active
96
+ tenant's catalog still gets indexed."""
97
+ active = await _tenant(db_session, "all-active")
98
+ paused = await _tenant(db_session, "all-paused")
99
+ paused.chatbot_enabled = False
100
+ await db_session.flush()
101
+
102
+ resolved_slugs: list[str] = []
103
+
104
+ def _fake_resolver(tenant):
105
+ resolved_slugs.append(tenant.slug)
106
+ return FakeCatalogClient([_node(1, "Manguera activa", desc="riego de jardín")])
107
+
108
+ monkeypatch.setattr(product_sync, "tenant_shopify_client", _fake_resolver)
109
+
110
+ done = await product_sync.sync_all_tenants(db_session)
111
+
112
+ assert done == 1 # exactly the active tenant was synced
113
+ assert resolved_slugs == ["all-active"] # paused never even resolved a client
114
+
115
+ active_rows = (await db_session.execute(
116
+ select(ProductChunk).where(ProductChunk.tenant_id == active.id)
117
+ )).scalars().all()
118
+ assert len(active_rows) == 1 and active_rows[0].title == "Manguera activa"
119
+ paused_rows = (await db_session.execute(
120
+ select(ProductChunk).where(ProductChunk.tenant_id == paused.id)
121
+ )).scalars().all()
122
+ assert paused_rows == [] # nothing indexed for the paused tenant
123
+
124
+
125
  async def test_cross_language_semantic_retrieval(db_session):
126
  t = await _tenant(db_session, "store-cl")
127
  nodes = [
tests/routes/test_admin.py CHANGED
@@ -1,6 +1,8 @@
1
  from __future__ import annotations
2
 
 
3
  import pytest
 
4
 
5
  AUTH = {"Authorization": "Bearer test-admin-token"}
6
 
@@ -109,6 +111,97 @@ async def test_delete_tenant(app_client):
109
  assert (await client.get("/admin/tenants/gone/sources", headers=AUTH)).status_code == 404
110
 
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  async def test_delete_tenant_purges_all_child_rows(app_client, db_session):
113
  """Deleting a client must remove EVERY tenant-scoped row (incl. the tables
114
  added after the endpoint was written: product corpus, analytics, watches),
 
1
  from __future__ import annotations
2
 
3
+ import httpx
4
  import pytest
5
+ import respx
6
 
7
  AUTH = {"Authorization": "Bearer test-admin-token"}
8
 
 
111
  assert (await client.get("/admin/tenants/gone/sources", headers=AUTH)).status_code == 404
112
 
113
 
114
+ async def test_admin_chatbot_switch_tracks_disabled_reason(app_client, db_session):
115
+ """Pausing from the admin marks the tenant 'admin' (so a Stripe payment can
116
+ never resurrect it); re-enabling clears the mark."""
117
+ from sqlalchemy import select
118
+
119
+ from app.models import Tenant
120
+
121
+ _app, client = app_client
122
+ await client.post("/admin/tenants", headers=AUTH, json={"slug": "pausada"})
123
+
124
+ r = await client.put(
125
+ "/admin/tenants/pausada", headers=AUTH, json={"chatbot_enabled": False}
126
+ )
127
+ assert r.status_code == 200 and r.json()["chatbot_enabled"] is False
128
+ db_session.expire_all()
129
+ t = (await db_session.execute(select(Tenant).where(Tenant.slug == "pausada"))).scalar_one()
130
+ assert t.chatbot_enabled is False
131
+ assert t.disabled_reason == "admin"
132
+
133
+ r2 = await client.put(
134
+ "/admin/tenants/pausada", headers=AUTH, json={"chatbot_enabled": True}
135
+ )
136
+ assert r2.status_code == 200 and r2.json()["chatbot_enabled"] is True
137
+ db_session.expire_all()
138
+ t = (await db_session.execute(select(Tenant).where(Tenant.slug == "pausada"))).scalar_one()
139
+ assert t.chatbot_enabled is True
140
+ assert t.disabled_reason == ""
141
+
142
+
143
+ @respx.mock
144
+ async def test_delete_tenant_cancels_stripe_subscription(app_client, db_session, monkeypatch):
145
+ """Deleting a paying client must also cancel their Stripe subscription so
146
+ they are never charged again for a service that no longer exists."""
147
+ from sqlalchemy import select
148
+
149
+ from app.config import get_settings
150
+ from app.models import Tenant
151
+
152
+ _app, client = app_client
153
+ monkeypatch.setattr(get_settings(), "stripe_secret_key", "sk_test_123")
154
+ await client.post("/admin/tenants", headers=AUTH, json={"slug": "depago"})
155
+ t = (await db_session.execute(select(Tenant).where(Tenant.slug == "depago"))).scalar_one()
156
+ t.stripe_subscription_id = "sub_del1"
157
+ await db_session.commit()
158
+
159
+ cancel = respx.delete("https://api.stripe.com/v1/subscriptions/sub_del1").mock(
160
+ return_value=httpx.Response(200, json={"id": "sub_del1", "status": "canceled"})
161
+ )
162
+ d = await client.delete("/admin/tenants/depago", headers=AUTH)
163
+ assert d.status_code == 200
164
+ assert d.json() == {"deleted": True}
165
+ # Stripe really was told to stop charging, with our key
166
+ assert cancel.called
167
+ assert cancel.calls.last.request.headers["Authorization"] == "Bearer sk_test_123"
168
+ db_session.expire_all()
169
+ rows = (
170
+ await db_session.execute(select(Tenant).where(Tenant.slug == "depago"))
171
+ ).scalars().all()
172
+ assert rows == [] # tenant existed above (scalar_one) and is now gone
173
+
174
+
175
+ @respx.mock
176
+ async def test_delete_tenant_survives_stripe_failure(app_client, db_session, monkeypatch):
177
+ """If Stripe is down, the cancel is best-effort: log and DELETE anyway —
178
+ the business goal is zero service for deleted clients, immediately."""
179
+ from sqlalchemy import select
180
+
181
+ from app.config import get_settings
182
+ from app.models import Tenant
183
+
184
+ _app, client = app_client
185
+ monkeypatch.setattr(get_settings(), "stripe_secret_key", "sk_test_123")
186
+ await client.post("/admin/tenants", headers=AUTH, json={"slug": "caida"})
187
+ t = (await db_session.execute(select(Tenant).where(Tenant.slug == "caida"))).scalar_one()
188
+ t.stripe_subscription_id = "sub_del2"
189
+ await db_session.commit()
190
+
191
+ cancel = respx.delete("https://api.stripe.com/v1/subscriptions/sub_del2").mock(
192
+ side_effect=httpx.ConnectError("stripe caido")
193
+ )
194
+ d = await client.delete("/admin/tenants/caida", headers=AUTH)
195
+ assert d.status_code == 200
196
+ assert d.json() == {"deleted": True}
197
+ assert cancel.called # the cancel WAS attempted before deleting
198
+ db_session.expire_all()
199
+ rows = (
200
+ await db_session.execute(select(Tenant).where(Tenant.slug == "caida"))
201
+ ).scalars().all()
202
+ assert rows == []
203
+
204
+
205
  async def test_delete_tenant_purges_all_child_rows(app_client, db_session):
206
  """Deleting a client must remove EVERY tenant-scoped row (incl. the tables
207
  added after the endpoint was written: product corpus, analytics, watches),
tests/routes/test_killswitch.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Kill switch: a DELETED or PAUSED client must get ZERO service even though
2
+ their website still embeds <script src=".../widget.js?t=slug" defer>.
3
+
4
+ Regression for the critical hole where an unknown/deleted slug FELL BACK to the
5
+ 'default' tenant, keeping a fully working bot at the owner's LLM expense. Every
6
+ test first proves the path WORKS while the tenant is alive/enabled (rows + LLM
7
+ calls + emails happen), then deletes/pauses and asserts the exact same request
8
+ is refused with nothing stored, no LLM call and no email.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from sqlalchemy import func, select
14
+
15
+ import app.mailer as mailer
16
+ from app.config import get_settings
17
+ from app.deps import get_router
18
+ from app.llm.base import ChatResult
19
+ from app.models import ChatSession, Event, HandoffRequest
20
+
21
+ AUTH = {"Authorization": "Bearer test-admin-token"}
22
+
23
+
24
+ class RecordingRouter:
25
+ """Fake LLM router that records every call so tests can assert ZERO calls
26
+ happen for a deleted tenant (same dependency-override pattern as
27
+ tests/routes/test_simple_mode.py)."""
28
+
29
+ def __init__(self):
30
+ self.calls = []
31
+
32
+ async def chat(self, messages, tools, tier="large", temperature=None, order=None, tool_choice=None, max_tokens=None):
33
+ self.calls.append(messages)
34
+ return ChatResult(content="¡Hola! Soy el asistente.", tool_calls=[], finish_reason="stop")
35
+
36
+
37
+ async def _count(db, model) -> int:
38
+ return (await db.execute(select(func.count()).select_from(model))).scalar_one()
39
+
40
+
41
+ async def _setup_default_and(client, slug: str) -> None:
42
+ """A real 'default' tenant exists (the fallback target) plus the tenant
43
+ under test — so a fallback bug would SUCCEED, not 404 by accident."""
44
+ r = await client.post("/admin/tenants", headers=AUTH, json={"slug": "default"})
45
+ assert r.status_code == 201
46
+ r = await client.post("/admin/tenants", headers=AUTH, json={"slug": slug})
47
+ assert r.status_code == 201
48
+
49
+
50
+ async def _delete(client, slug: str) -> None:
51
+ r = await client.delete(f"/admin/tenants/{slug}", headers=AUTH)
52
+ assert r.status_code == 200 and r.json()["deleted"] is True
53
+
54
+
55
+ async def test_widget_config_deleted_slug_is_disabled_not_default(app_client):
56
+ _app, client = app_client
57
+ await _setup_default_and(client, "borrada")
58
+ await client.put(
59
+ "/admin/tenants/default", headers=AUTH, json={"brand_name": "Marca Default"}
60
+ )
61
+
62
+ # alive: the widget is enabled for the tenant
63
+ wc = (await client.get("/widget-config?t=borrada")).json()
64
+ assert wc["enabled"] is True
65
+
66
+ await _delete(client, "borrada")
67
+
68
+ # deleted: enabled flips to False and the DEFAULT tenant's branding is
69
+ # NOT served (the old bug returned default's fully-enabled config)
70
+ wc2 = (await client.get("/widget-config?t=borrada")).json()
71
+ assert wc2["enabled"] is False
72
+ assert wc2["brand_name"] != "Marca Default"
73
+
74
+
75
+ async def test_chat_deleted_slug_404_no_rows_no_llm_call(app_client, db_session):
76
+ app, client = app_client
77
+ fake = RecordingRouter()
78
+ app.dependency_overrides[get_router] = lambda: fake
79
+ await _setup_default_and(client, "borrada")
80
+
81
+ # alive: the chat works through this very fake router and stores a session
82
+ ok = await client.post("/chat?t=borrada", json={"message": "hola"})
83
+ assert ok.status_code == 200
84
+ assert len(fake.calls) == 1
85
+ assert await _count(db_session, ChatSession) == 1
86
+
87
+ await _delete(client, "borrada")
88
+ assert await _count(db_session, ChatSession) == 0 # cascade wiped its data
89
+
90
+ # deleted: 404 (NOT a silent fallback to 'default'), nothing stored,
91
+ # and the LLM was never invoked again
92
+ r = await client.post("/chat?t=borrada", json={"message": "hola"})
93
+ assert r.status_code == 404
94
+ assert len(fake.calls) == 1
95
+ assert await _count(db_session, ChatSession) == 0
96
+ assert await _count(db_session, Event) == 0
97
+
98
+
99
+ async def test_apps_chat_deleted_slug_404_even_with_valid_signature(app_client, db_session):
100
+ """The App Proxy entry point shares _resolve_tenant: an explicit deleted
101
+ slug 404s there too, even on a correctly signed request."""
102
+ import time
103
+
104
+ from app.shopify.proxy import compute_proxy_signature
105
+
106
+ app, client = app_client
107
+ fake = RecordingRouter()
108
+ app.dependency_overrides[get_router] = lambda: fake
109
+ await _setup_default_and(client, "borrada")
110
+ await _delete(client, "borrada")
111
+
112
+ params = {"shop": "x.myshopify.com", "path_prefix": "/apps/chat",
113
+ "t": "borrada", "timestamp": str(int(time.time()))}
114
+ params["signature"] = compute_proxy_signature(
115
+ params, get_settings().shopify_app_proxy_secret
116
+ )
117
+ r = await client.post("/apps/chat", params=params, json={"message": "hola"})
118
+ assert r.status_code == 404
119
+ assert fake.calls == []
120
+ assert await _count(db_session, ChatSession) == 0
121
+
122
+
123
+ async def test_handoff_deleted_slug_404_zero_rows(app_client, db_session):
124
+ _app, client = app_client
125
+ await _setup_default_and(client, "borrada")
126
+
127
+ # alive: the form stores a lead
128
+ ok = await client.post(
129
+ "/handoff?t=borrada", json={"name": "Ana", "email": "a@x.com", "message": "info"}
130
+ )
131
+ assert ok.status_code == 200
132
+ assert await _count(db_session, HandoffRequest) == 1
133
+
134
+ await _delete(client, "borrada")
135
+ assert await _count(db_session, HandoffRequest) == 0 # cascade wiped the lead
136
+
137
+ # deleted: 404 and the lead is NOT stored for the default tenant either
138
+ r = await client.post(
139
+ "/handoff?t=borrada", json={"name": "Ana", "email": "a@x.com", "message": "info"}
140
+ )
141
+ assert r.status_code == 404
142
+ assert await _count(db_session, HandoffRequest) == 0
143
+
144
+
145
+ async def test_handoff_paused_tenant_403_zero_rows_zero_emails(app_client, db_session, monkeypatch):
146
+ _app, client = app_client
147
+ r = await client.post("/admin/tenants", headers=AUTH, json={"slug": "morosa"})
148
+ assert r.status_code == 201
149
+ await client.put(
150
+ "/admin/tenants/morosa", headers=AUTH, json={"support_email": "dueno@morosa.com"}
151
+ )
152
+
153
+ # fake mailer (same pattern as tests/test_handoff.py) records every send
154
+ sent = []
155
+
156
+ async def fake_send(to, subject, body):
157
+ sent.append(to)
158
+ return True
159
+
160
+ monkeypatch.setattr(get_settings(), "smtp_host", "smtp.test")
161
+ monkeypatch.setattr(mailer, "default_sender", lambda _s: fake_send)
162
+
163
+ payload = {"name": "Lead", "email": "lead@x.com", "message": "quiero info"}
164
+
165
+ # enabled: the lead is stored AND emailed through this very fake
166
+ ok = await client.post("/handoff?t=morosa", json=payload)
167
+ assert ok.status_code == 200
168
+ assert await _count(db_session, HandoffRequest) == 1
169
+ assert sent == ["dueno@morosa.com"]
170
+
171
+ # admin pauses the tenant (stopped paying)
172
+ r = await client.put(
173
+ "/admin/tenants/morosa", headers=AUTH, json={"chatbot_enabled": False}
174
+ )
175
+ assert r.status_code == 200 and r.json()["chatbot_enabled"] is False
176
+
177
+ # paused: 403, no new row, no new email — the lead harvest stops
178
+ r = await client.post("/handoff?t=morosa", json=payload)
179
+ assert r.status_code == 403
180
+ assert await _count(db_session, HandoffRequest) == 1
181
+ assert sent == ["dueno@morosa.com"]
182
+
183
+
184
+ def test_widget_js_gate_is_fail_closed():
185
+ """The embed must render NOTHING unless the backend positively says
186
+ enabled=true — any error/missing key must not show the widget."""
187
+ from pathlib import Path
188
+
189
+ js = (Path(__file__).resolve().parents[2] / "app" / "static" / "widget.js").read_text()
190
+ assert "cfg.enabled !== true" in js
191
+ assert "cfg.enabled === false" not in js # the old fail-open gate is gone
tests/routes/test_portal.py CHANGED
@@ -257,3 +257,70 @@ async def test_admin_sees_and_revokes_portal_token(app_client):
257
  rows2 = (await client.get("/admin/tenants", headers=AUTH)).json()
258
  payer2 = next(t for t in rows2 if t["slug"] == "payer")
259
  assert payer2["has_dashboard"] is False and payer2["dashboard_token"] == ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  rows2 = (await client.get("/admin/tenants", headers=AUTH)).json()
258
  payer2 = next(t for t in rows2 if t["slug"] == "payer")
259
  assert payer2["has_dashboard"] is False and payer2["dashboard_token"] == ""
260
+
261
+
262
+ async def test_portal_resource_endpoints_blocked_for_paused_tenant(
263
+ app_client, db_session, monkeypatch
264
+ ):
265
+ """Zero service for a paused (non-paying) store: its portal token must not
266
+ trigger owner-paid work (catalog sync, reindex, source ingestion). The
267
+ active store is the positive control proving each patched path still runs."""
268
+ from sqlalchemy import select
269
+
270
+ from app.models import KnowledgeSource
271
+ from app.products import sync as product_sync
272
+ from app.rag import index as rag_index
273
+
274
+ synced_slugs: list[str] = []
275
+ reindexed_ids: list[int] = []
276
+
277
+ async def fake_sync_tenant(db, tenant, **kw):
278
+ synced_slugs.append(tenant.slug)
279
+ return {"status": "ok", "total": 0, "reembedded": 0, "removed": 0}
280
+
281
+ async def fake_reindex_all(db, *, tenant_id=None):
282
+ reindexed_ids.append(tenant_id)
283
+
284
+ async def fake_index_source(db, src):
285
+ src.status = "indexed"
286
+ return src
287
+
288
+ monkeypatch.setattr(product_sync, "sync_tenant", fake_sync_tenant)
289
+ monkeypatch.setattr(rag_index, "reindex_all", fake_reindex_all)
290
+ monkeypatch.setattr(rag_index, "index_source", fake_index_source)
291
+
292
+ _app, client = app_client
293
+ active, active_tok = await _tenant_with_token(db_session, "res-active")
294
+ paused, paused_tok = await _tenant_with_token(db_session, "res-paused")
295
+ paused.chatbot_enabled = False
296
+ await db_session.commit()
297
+ ah = {"Authorization": "Bearer " + active_tok}
298
+ ph = {"Authorization": "Bearer " + paused_tok}
299
+
300
+ # paused → 403 on every resource-consuming endpoint, nothing executed
301
+ assert (await client.post("/portal/api/products/sync", headers=ph)).status_code == 403
302
+ assert (await client.post("/portal/api/reindex", headers=ph)).status_code == 403
303
+ r_url = await client.post(
304
+ "/portal/api/sources/url", headers=ph, json={"location": "https://paused.com/faq"}
305
+ )
306
+ assert r_url.status_code == 403
307
+ r_file = await client.post(
308
+ "/portal/api/sources/file", headers=ph,
309
+ files={"file": ("faq.txt", b"hola", "text/plain")},
310
+ )
311
+ assert r_file.status_code == 403
312
+
313
+ # active → the very same endpoints still work (the gate is the only diff)
314
+ ok_sync = await client.post("/portal/api/products/sync", headers=ah)
315
+ assert ok_sync.status_code == 200 and ok_sync.json()["status"] == "ok"
316
+ ok_re = await client.post("/portal/api/reindex", headers=ah)
317
+ assert ok_re.status_code == 200 and ok_re.json() == {"status": "reindexed"}
318
+ ok_url = await client.post(
319
+ "/portal/api/sources/url", headers=ah, json={"location": "https://active.com/faq"}
320
+ )
321
+ assert ok_url.status_code == 201
322
+
323
+ assert synced_slugs == ["res-active"] # paused never reached sync_tenant
324
+ assert reindexed_ids == [active.id] # paused never reached reindex_all
325
+ rows = (await db_session.execute(select(KnowledgeSource))).scalars().all()
326
+ assert [s.tenant_id for s in rows] == [active.id] # only active's source stored
tests/routes/test_simple_mode.py CHANGED
@@ -38,11 +38,15 @@ async def test_widget_js_served(app_client):
38
  assert 'searchParams.get("t")' in body # reads tenant from its own src
39
 
40
 
41
- async def test_widget_config_defaults_for_unknown_tenant(app_client):
 
 
42
  _app, client = app_client
43
  r = await client.get("/widget-config?t=nope")
44
  assert r.status_code == 200
45
  data = r.json()
 
 
46
  assert data["brand_name"] and data["brand_color"] and data["welcome"]
47
 
48
 
@@ -80,7 +84,7 @@ async def test_widget_config_returns_whatsapp_number(app_client):
80
  )
81
  data = (await client.get("/widget-config?t=w1")).json()
82
  assert data["whatsapp_number"] == "+34 639 913 105"
83
- # default tenant: empty widget renders no button
84
  assert (await client.get("/widget-config?t=nope")).json()["whatsapp_number"] == ""
85
 
86
 
 
38
  assert 'searchParams.get("t")' in body # reads tenant from its own src
39
 
40
 
41
+ async def test_widget_config_unknown_tenant_is_disabled(app_client):
42
+ """An unknown/deleted slug must NOT fall back to default: the widget gets
43
+ enabled=False and renders nothing (the embed line may still be live)."""
44
  _app, client = app_client
45
  r = await client.get("/widget-config?t=nope")
46
  assert r.status_code == 200
47
  data = r.json()
48
+ assert data["enabled"] is False
49
+ # shape stays sane so the widget never breaks on missing keys
50
  assert data["brand_name"] and data["brand_color"] and data["welcome"]
51
 
52
 
 
84
  )
85
  data = (await client.get("/widget-config?t=w1")).json()
86
  assert data["whatsapp_number"] == "+34 639 913 105"
87
+ # unknown tenant: disabled fallback carries an empty number → no button
88
  assert (await client.get("/widget-config?t=nope")).json()["whatsapp_number"] == ""
89
 
90
 
tests/routes/test_widget_mode.py CHANGED
@@ -7,6 +7,7 @@ async def test_widget_config_defaults_chat_mode(app_client):
7
  _app, client = app_client
8
  cfg = (await client.get("/widget-config?t=nope")).json()
9
  assert cfg["widget_mode"] == "chat"
 
10
 
11
 
12
  async def test_widget_mode_persists_per_tenant(app_client):
 
7
  _app, client = app_client
8
  cfg = (await client.get("/widget-config?t=nope")).json()
9
  assert cfg["widget_mode"] == "chat"
10
+ assert cfg["enabled"] is False # unknown slug: fail closed, never default
11
 
12
 
13
  async def test_widget_mode_persists_per_tenant(app_client):
tests/test_billing.py CHANGED
@@ -115,21 +115,192 @@ async def test_subscription_lifecycle_toggles_chatbot(db_session, monkeypatch):
115
  await db_session.execute(select(Tenant).where(Tenant.slug == out["slug"]))
116
  ).scalar_one()
117
  assert t.chatbot_enabled is True
 
118
 
119
- # stops paying → Stripe cancels → chatbot OFF automatically
120
  deleted = {"type": "customer.subscription.deleted",
121
  "data": {"object": {"customer": "cus_life", "id": "sub_456"}}}
122
  r = await billing.handle_subscription_event(db_session, deleted)
123
  assert r["status"] == "disabled"
124
  await db_session.refresh(t)
125
  assert t.chatbot_enabled is False
 
 
126
 
127
- # pays again → chatbot back ON
128
- paid = {"type": "invoice.paid", "data": {"object": {"customer": "cus_life"}}}
129
- r2 = await billing.handle_subscription_event(db_session, paid)
130
- assert r2["status"] == "enabled"
 
131
  await db_session.refresh(t)
132
  assert t.chatbot_enabled is True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
 
135
  async def test_invoice_paid_before_checkout_is_safely_ignored(db_session):
 
115
  await db_session.execute(select(Tenant).where(Tenant.slug == out["slug"]))
116
  ).scalar_one()
117
  assert t.chatbot_enabled is True
118
+ assert t.disabled_reason == ""
119
 
120
+ # stops paying → Stripe cancels → chatbot OFF, reason recorded, sub unlinked
121
  deleted = {"type": "customer.subscription.deleted",
122
  "data": {"object": {"customer": "cus_life", "id": "sub_456"}}}
123
  r = await billing.handle_subscription_event(db_session, deleted)
124
  assert r["status"] == "disabled"
125
  await db_session.refresh(t)
126
  assert t.chatbot_enabled is False
127
+ assert t.disabled_reason == "billing"
128
+ assert t.stripe_subscription_id == "" # late invoices can't match it anymore
129
 
130
+ # buys again (new checkout, same customer) → chatbot back ON, state clean
131
+ out2 = await billing.handle_checkout_completed(
132
+ db_session, _checkout_event(customer="cus_life", sub="sub_789"), origin="https://x"
133
+ )
134
+ assert out2["status"] == "already_provisioned"
135
  await db_session.refresh(t)
136
  assert t.chatbot_enabled is True
137
+ assert t.disabled_reason == ""
138
+ assert t.stripe_subscription_id == "sub_789"
139
+
140
+
141
+ def _paid_event(customer: str, sub_shape: dict | None = None) -> dict:
142
+ obj: dict = {"customer": customer}
143
+ if sub_shape:
144
+ obj.update(sub_shape)
145
+ return {"type": "invoice.paid", "data": {"object": obj}}
146
+
147
+
148
+ async def _disabled_tenant(db_session, *, slug, customer, sub, reason) -> Tenant:
149
+ t = Tenant(slug=slug, name=slug, stripe_customer_id=customer,
150
+ stripe_subscription_id=sub, chatbot_enabled=False,
151
+ disabled_reason=reason)
152
+ db_session.add(t)
153
+ await db_session.commit()
154
+ return t
155
+
156
+
157
+ def test_invoice_subscription_id_across_api_versions():
158
+ # pre-Basil: top-level, bare id or expanded object
159
+ assert billing._invoice_subscription_id({"subscription": "sub_a"}) == "sub_a"
160
+ assert billing._invoice_subscription_id({"subscription": {"id": "sub_b"}}) == "sub_b"
161
+ # Basil+: parent.subscription_details
162
+ assert billing._invoice_subscription_id(
163
+ {"parent": {"subscription_details": {"subscription": "sub_c"}}}
164
+ ) == "sub_c"
165
+ # Basil+: per line item
166
+ assert billing._invoice_subscription_id(
167
+ {"lines": {"data": [
168
+ {"parent": {"subscription_item_details": {"subscription": "sub_d"}}}
169
+ ]}}
170
+ ) == "sub_d"
171
+ assert billing._invoice_subscription_id(
172
+ {"lines": {"data": [{"subscription": "sub_e"}]}}
173
+ ) == "sub_e"
174
+ # no subscription anywhere (one-off invoice) → ""
175
+ assert billing._invoice_subscription_id({"lines": {"data": [{}]}}) == ""
176
+
177
+
178
+ async def test_late_invoice_paid_after_cancel_does_not_resurrect(db_session, monkeypatch):
179
+ """Stripe does not guarantee event order: an invoice.paid generated before
180
+ the cancellation may arrive after it. It must NOT switch the tenant back on."""
181
+ async def fake_send(settings, to, subject, body, **kw):
182
+ return True
183
+
184
+ import app.mailer
185
+
186
+ monkeypatch.setattr(app.mailer, "send_email", fake_send)
187
+ out = await billing.handle_checkout_completed(
188
+ db_session, _checkout_event(customer="cus_late", sub="sub_late"), origin="https://x"
189
+ )
190
+ t = (
191
+ await db_session.execute(select(Tenant).where(Tenant.slug == out["slug"]))
192
+ ).scalar_one()
193
+ deleted = {"type": "customer.subscription.deleted",
194
+ "data": {"object": {"customer": "cus_late", "id": "sub_late"}}}
195
+ assert (await billing.handle_subscription_event(db_session, deleted))["status"] == "disabled"
196
+ await db_session.refresh(t)
197
+ assert t.chatbot_enabled is False # positively off before the late event
198
+
199
+ late = _paid_event("cus_late", {"subscription": "sub_late"})
200
+ r = await billing.handle_subscription_event(db_session, late)
201
+ assert r["status"] == "subscription_mismatch"
202
+ await db_session.refresh(t)
203
+ assert t.chatbot_enabled is False # still off: canceled stays canceled
204
+ assert t.disabled_reason == "billing"
205
+
206
+
207
+ async def test_stale_subscription_deleted_does_not_disable_repurchased_tenant(db_session, monkeypatch):
208
+ """Cancel + re-purchase, then the OLD subscription's deleted event arrives
209
+ late (Stripe does not guarantee order). It must NOT switch off the paying
210
+ client nor unlink its new subscription."""
211
+ async def fake_send(settings, to, subject, body, **kw):
212
+ return True
213
+
214
+ import app.mailer
215
+
216
+ monkeypatch.setattr(app.mailer, "send_email", fake_send)
217
+ out = await billing.handle_checkout_completed(
218
+ db_session, _checkout_event(customer="cus_re", sub="sub_old"), origin="https://x"
219
+ )
220
+ t = (
221
+ await db_session.execute(select(Tenant).where(Tenant.slug == out["slug"]))
222
+ ).scalar_one()
223
+ # re-purchase: same customer, NEW subscription
224
+ out2 = await billing.handle_checkout_completed(
225
+ db_session, _checkout_event(customer="cus_re", sub="sub_new"), origin="https://x"
226
+ )
227
+ assert out2["status"] == "already_provisioned"
228
+ await db_session.refresh(t)
229
+ assert t.chatbot_enabled is True and t.stripe_subscription_id == "sub_new"
230
+
231
+ stale = {"type": "customer.subscription.deleted",
232
+ "data": {"object": {"customer": "cus_re", "id": "sub_old"}}}
233
+ r = await billing.handle_subscription_event(db_session, stale)
234
+ assert r["status"] == "stale_subscription"
235
+ await db_session.refresh(t)
236
+ assert t.chatbot_enabled is True # the paying client keeps full service
237
+ assert t.disabled_reason == ""
238
+ assert t.stripe_subscription_id == "sub_new" # the live link survived
239
+
240
+ # the CURRENT subscription's deleted event still disables, as always
241
+ real = {"type": "customer.subscription.deleted",
242
+ "data": {"object": {"customer": "cus_re", "id": "sub_new"}}}
243
+ r2 = await billing.handle_subscription_event(db_session, real)
244
+ assert r2["status"] == "disabled"
245
+ await db_session.refresh(t)
246
+ assert t.chatbot_enabled is False
247
+ assert t.disabled_reason == "billing"
248
+ assert t.stripe_subscription_id == ""
249
+
250
+
251
+ async def test_invoice_paid_matching_subscription_reenables(db_session):
252
+ """A paid invoice for the tenant's CURRENT subscription lifts a billing
253
+ pause — across every Stripe API shape that carries the subscription id."""
254
+ shapes = [
255
+ ("viejo", {"subscription": "sub_m"}),
256
+ ("basil", {"parent": {"subscription_details": {"subscription": "sub_m"}}}),
257
+ ("lineas", {"lines": {"data": [
258
+ {"parent": {"subscription_item_details": {"subscription": "sub_m"}}}
259
+ ]}}),
260
+ ]
261
+ for slug, shape in shapes:
262
+ t = await _disabled_tenant(
263
+ db_session, slug=slug, customer=f"cus_{slug}", sub="sub_m", reason="billing"
264
+ )
265
+ r = await billing.handle_subscription_event(
266
+ db_session, _paid_event(f"cus_{slug}", shape)
267
+ )
268
+ assert r["status"] == "enabled", slug
269
+ await db_session.refresh(t)
270
+ assert t.chatbot_enabled is True, slug
271
+ assert t.disabled_reason == "", slug
272
+
273
+
274
+ async def test_invoice_paid_mismatched_or_missing_subscription_stays_off(db_session):
275
+ t = await _disabled_tenant(
276
+ db_session, slug="desfase", customer="cus_mis", sub="sub_actual", reason="billing"
277
+ )
278
+ # wrong subscription id
279
+ r = await billing.handle_subscription_event(
280
+ db_session, _paid_event("cus_mis", {"subscription": "sub_OTRA"})
281
+ )
282
+ assert r["status"] == "subscription_mismatch"
283
+ # no subscription id at all (one-off invoice)
284
+ r2 = await billing.handle_subscription_event(db_session, _paid_event("cus_mis"))
285
+ assert r2["status"] == "subscription_mismatch"
286
+ await db_session.refresh(t)
287
+ assert t.chatbot_enabled is False
288
+ assert t.disabled_reason == "billing"
289
+
290
+
291
+ async def test_admin_paused_tenant_stays_off_after_invoice_paid(db_session):
292
+ """Victor paused this client on purpose — a Stripe payment (even for the
293
+ right subscription) must never override the admin's decision."""
294
+ t = await _disabled_tenant(
295
+ db_session, slug="castigada", customer="cus_adm", sub="sub_adm", reason="admin"
296
+ )
297
+ r = await billing.handle_subscription_event(
298
+ db_session, _paid_event("cus_adm", {"subscription": "sub_adm"})
299
+ )
300
+ assert r["status"] == "admin_paused"
301
+ await db_session.refresh(t)
302
+ assert t.chatbot_enabled is False
303
+ assert t.disabled_reason == "admin"
304
 
305
 
306
  async def test_invoice_paid_before_checkout_is_safely_ignored(db_session):
tests/test_billing_reconcile.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Daily billing reconciliation: the scheduler asks Stripe for the truth so a
2
+ single missed webhook can never leave a non-payer with service forever, and
3
+ the weekly reindex must not spend crawl/embedding resources on paused tenants.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import httpx
9
+ import respx
10
+ from sqlalchemy import select
11
+
12
+ from app.config import get_settings
13
+ from app.models import KnowledgeSource, Tenant
14
+ from app.scheduler import reconcile_billing, reindex_all_urls
15
+
16
+ STRIPE_SUB_URL = "https://api.stripe.com/v1/subscriptions/{}"
17
+
18
+
19
+ async def _paying_tenant(db, slug: str, sub_id: str, *, enabled: bool = True) -> Tenant:
20
+ t = Tenant(
21
+ slug=slug,
22
+ name=slug,
23
+ stripe_subscription_id=sub_id,
24
+ chatbot_enabled=enabled,
25
+ disabled_reason="",
26
+ )
27
+ db.add(t)
28
+ await db.commit()
29
+ return t
30
+
31
+
32
+ @respx.mock
33
+ async def test_canceled_subscription_pauses_tenant(db_session, monkeypatch):
34
+ """A canceled subscription found by the daily poll disables the tenant with
35
+ disabled_reason='billing' — exactly what the missed webhook would have done,
36
+ including unlinking the dead subscription so a late invoice.paid for it can
37
+ never resurrect the tenant."""
38
+ from app import billing
39
+
40
+ monkeypatch.setattr(get_settings(), "stripe_secret_key", "sk_test_123")
41
+ t = await _paying_tenant(db_session, "moroso", "sub_dead1")
42
+ t.stripe_customer_id = "cus_moroso"
43
+ await db_session.commit()
44
+ assert t.chatbot_enabled is True and t.disabled_reason == "" # live before
45
+
46
+ route = respx.get(STRIPE_SUB_URL.format("sub_dead1")).mock(
47
+ return_value=httpx.Response(200, json={"id": "sub_dead1", "status": "canceled"})
48
+ )
49
+ paused = await reconcile_billing(db_session)
50
+
51
+ assert paused == 1
52
+ assert route.called # Stripe really was consulted, with our key
53
+ assert route.calls.last.request.headers["Authorization"] == "Bearer sk_test_123"
54
+ db_session.expire_all()
55
+ t = (
56
+ await db_session.execute(select(Tenant).where(Tenant.slug == "moroso"))
57
+ ).scalar_one()
58
+ assert t.chatbot_enabled is False
59
+ assert t.disabled_reason == "billing"
60
+ assert t.stripe_subscription_id == "" # unlinked, same as the webhook
61
+
62
+ # a late invoice.paid for the dead subscription must NOT resurrect it
63
+ late = {"type": "invoice.paid",
64
+ "data": {"object": {"customer": "cus_moroso", "subscription": "sub_dead1"}}}
65
+ r = await billing.handle_subscription_event(db_session, late)
66
+ assert r["status"] == "subscription_mismatch"
67
+ await db_session.refresh(t)
68
+ assert t.chatbot_enabled is False
69
+ assert t.disabled_reason == "billing"
70
+
71
+
72
+ @respx.mock
73
+ async def test_active_subscription_stays_enabled(db_session, monkeypatch):
74
+ """A paying client must NOT be touched: Stripe is consulted and the tenant
75
+ keeps full service (enabled, no disabled_reason)."""
76
+ monkeypatch.setattr(get_settings(), "stripe_secret_key", "sk_test_123")
77
+ await _paying_tenant(db_session, "alcorriente", "sub_ok1")
78
+
79
+ route = respx.get(STRIPE_SUB_URL.format("sub_ok1")).mock(
80
+ return_value=httpx.Response(200, json={"id": "sub_ok1", "status": "active"})
81
+ )
82
+ paused = await reconcile_billing(db_session)
83
+
84
+ assert paused == 0
85
+ assert route.called # the check DID happen; staying enabled is a decision
86
+ db_session.expire_all()
87
+ t = (
88
+ await db_session.execute(select(Tenant).where(Tenant.slug == "alcorriente"))
89
+ ).scalar_one()
90
+ assert t.chatbot_enabled is True
91
+ assert t.disabled_reason == ""
92
+
93
+
94
+ @respx.mock
95
+ async def test_no_stripe_key_makes_no_http_calls(db_session, monkeypatch):
96
+ """Without a configured key (dev/tests) the reconcile is a no-op: zero HTTP
97
+ traffic and the tenant untouched."""
98
+ monkeypatch.setattr(get_settings(), "stripe_secret_key", "")
99
+ await _paying_tenant(db_session, "sinkey", "sub_nokey")
100
+
101
+ paused = await reconcile_billing(db_session)
102
+
103
+ assert paused == 0
104
+ assert len(respx.calls) == 0 # not a single request left the process
105
+ db_session.expire_all()
106
+ t = (
107
+ await db_session.execute(select(Tenant).where(Tenant.slug == "sinkey"))
108
+ ).scalar_one()
109
+ assert t.chatbot_enabled is True
110
+
111
+
112
+ @respx.mock
113
+ async def test_network_error_logged_and_other_tenants_still_checked(db_session, monkeypatch):
114
+ """Stripe failing for one tenant must not crash the tick nor shield the
115
+ next tenant: the dead one is still found and paused."""
116
+ monkeypatch.setattr(get_settings(), "stripe_secret_key", "sk_test_123")
117
+ await _paying_tenant(db_session, "conred", "sub_neterr")
118
+ await _paying_tenant(db_session, "muerto", "sub_dead2")
119
+
120
+ down = respx.get(STRIPE_SUB_URL.format("sub_neterr")).mock(
121
+ side_effect=httpx.ConnectError("stripe caido")
122
+ )
123
+ dead = respx.get(STRIPE_SUB_URL.format("sub_dead2")).mock(
124
+ return_value=httpx.Response(200, json={"id": "sub_dead2", "status": "unpaid"})
125
+ )
126
+ paused = await reconcile_billing(db_session) # must not raise
127
+
128
+ assert paused == 1
129
+ assert down.called and dead.called # both were attempted despite the error
130
+ db_session.expire_all()
131
+ survivor = (
132
+ await db_session.execute(select(Tenant).where(Tenant.slug == "conred"))
133
+ ).scalar_one()
134
+ assert survivor.chatbot_enabled is True # unverifiable -> keep service
135
+ assert survivor.disabled_reason == ""
136
+ gone = (
137
+ await db_session.execute(select(Tenant).where(Tenant.slug == "muerto"))
138
+ ).scalar_one()
139
+ assert gone.chatbot_enabled is False
140
+ assert gone.disabled_reason == "billing"
141
+ # "unpaid" is recoverable: the link stays so actually paying the open
142
+ # invoice re-enables through the normal invoice.paid path
143
+ assert gone.stripe_subscription_id == "sub_dead2"
144
+
145
+
146
+ async def test_reindex_skips_paused_tenants_sources(db_session, monkeypatch):
147
+ """The weekly reindex must crawl the ACTIVE tenant's URL source and never
148
+ touch the paused tenant's one (zero service includes zero crawling)."""
149
+ from app.rag import index
150
+
151
+ active = await _paying_tenant(db_session, "activo", "sub_a")
152
+ paused = await _paying_tenant(db_session, "pausado", "sub_p", enabled=False)
153
+ db_session.add_all([
154
+ KnowledgeSource(tenant_id=active.id, kind="url", name="a", location="https://a.example"),
155
+ KnowledgeSource(tenant_id=paused.id, kind="url", name="p", location="https://p.example"),
156
+ KnowledgeSource(tenant_id=active.id, kind="file", name="f", location="/tmp/f.pdf"),
157
+ ])
158
+ await db_session.commit()
159
+
160
+ indexed: list[str] = []
161
+
162
+ async def fake_index_source(db, src):
163
+ indexed.append(src.location)
164
+ src.status = "indexed"
165
+ return src
166
+
167
+ monkeypatch.setattr(index, "index_source", fake_index_source)
168
+ done = await reindex_all_urls(db_session)
169
+
170
+ # Positive: the active tenant's URL really was refreshed...
171
+ assert done == 1
172
+ assert indexed == ["https://a.example"]
173
+ # ...and the paused tenant's URL (and the static file) were not.
174
+ assert "https://p.example" not in indexed
175
+
176
+
177
+ async def test_reindex_keeps_legacy_sources_without_tenant(db_session, monkeypatch):
178
+ """Pre-multitenant URL sources (tenant_id NULL) belong to no paused client
179
+ and must keep refreshing as before."""
180
+ from app.rag import index
181
+
182
+ db_session.add(
183
+ KnowledgeSource(tenant_id=None, kind="url", name="legacy", location="https://l.example")
184
+ )
185
+ await db_session.commit()
186
+
187
+ indexed: list[str] = []
188
+
189
+ async def fake_index_source(db, src):
190
+ indexed.append(src.location)
191
+ src.status = "indexed"
192
+ return src
193
+
194
+ monkeypatch.setattr(index, "index_source", fake_index_source)
195
+ done = await reindex_all_urls(db_session)
196
+
197
+ assert done == 1
198
+ assert indexed == ["https://l.example"]
tests/test_personalization_isolation.py CHANGED
@@ -70,7 +70,10 @@ async def test_every_personalization_is_unique_per_account(app_client):
70
  assert cross["brand_name"] == "Beta Servicios" # b_tok can't see alpha
71
 
72
 
73
- async def test_widget_config_unknown_tenant_is_generic_default(app_client):
 
 
74
  _app, client = app_client
75
  wc = (await client.get("/widget-config?t=does-not-exist")).json()
 
76
  assert wc["brand_name"] == "Asistente" and wc["starters"] == []
 
70
  assert cross["brand_name"] == "Beta Servicios" # b_tok can't see alpha
71
 
72
 
73
+ async def test_widget_config_unknown_tenant_is_disabled_generic(app_client):
74
+ """An unknown slug never leaks another tenant's branding AND is disabled
75
+ (no fallback to default: a deleted client must get zero service)."""
76
  _app, client = app_client
77
  wc = (await client.get("/widget-config?t=does-not-exist")).json()
78
+ assert wc["enabled"] is False
79
  assert wc["brand_name"] == "Asistente" and wc["starters"] == []