kill-switch guarantees + billing hardening
Browse files- app/billing.py +63 -0
- app/models.py +4 -0
- app/products/sync.py +8 -2
- app/routes/admin.py +24 -1
- app/routes/chat.py +9 -0
- app/routes/portal.py +14 -0
- app/routes/widget.py +5 -4
- app/scheduler.py +95 -6
- app/static/widget.js +1 -1
- docs/IDEAS-2026-06-11.md +103 -0
- migrations/versions/0018_disabled_reason.py +31 -0
- tests/products/test_corpus.py +32 -0
- tests/routes/test_admin.py +93 -0
- tests/routes/test_killswitch.py +191 -0
- tests/routes/test_portal.py +67 -0
- tests/routes/test_simple_mode.py +6 -2
- tests/routes/test_widget_mode.py +1 -0
- tests/test_billing.py +176 -5
- tests/test_billing_reconcile.py +198 -0
- tests/test_personalization_isolation.py +4 -1
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
| 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
|
| 35 |
-
so they're skipped
|
|
|
|
|
|
|
|
|
|
| 36 |
own tenant_id). Returns how many sources were refreshed."""
|
|
|
|
| 37 |
srcs = (
|
| 38 |
-
await db.execute(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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 ==
|
| 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
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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
|
| 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 |
-
#
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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"] == []
|