victor34593993 commited on
Commit
0937536
·
verified ·
1 Parent(s): 3fde6d0

feat(audit): free public store-support audit tool + programmatic-SEO pages (/audit)

Browse files
_shopify-review/FIX-PLAN.md ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ I have all the confirmation I need. The audit's line numbers and code are accurate. Now I'll produce the final report.
2
+
3
+ ## 1. Veredicto
4
+
5
+ **Sí — el path de Shopify Billing API está bien cableado.** `appSubscriptionCreate` (29.00 EUR, 14 trial_days, `test=auto` en dev stores) → `confirmation_url`; `/shopify/billing/return` re-verifica `has_active_subscription` contra Shopify antes de habilitar; el webhook `app_subscriptions/update` y `app/uninstalled` mantienen el gate (`chatbot_enabled`/`disabled_reason="billing"`) sincronizado; los OAuth installs arrancan GATED. Eso **se conserva intacto**.
6
+
7
+ **Root problem de 1.2.1:** no hay una **única fuente de verdad server-side** de "esto es un install de App Store". El routing de cobro depende de un flag de cliente (`shopify_install_source === "oauth"`) evaluado **solo en JS, después** de que `/portal/api/config` resuelva. El MISMO `portal_ui/index.html` se sirve verbatim dentro del iframe de Shopify (`shopify_oauth.py:264`), y en ese HTML el bloque Stripe (`#sub-stripe`) **arranca VISIBLE por defecto** (no tiene clase `hidden`, al revés que `#sub-shopify`). Resultado: cualquier FOUC, fallo/lentitud del bootstrap de sesión, o fallback a login manual deja un link `buy.stripe.com` y precio "29 €/mes" renderizado **dentro del admin de Shopify**. A esto se suman: (a) el cron `pause_expired_trials` que emaila el link de Stripe sin excluir tiendas Shopify, (b) el footer legal incondicional que apunta a páginas que describen SOLO el cobro Stripe, y (c) landing/demo en el mismo host con botones `buy.stripe.com`. El fix de fondo: **derivar el proveedor de cobro en el servidor y, para tiendas Shopify, no emitir nunca markup/links/emails de Stripe — fail closed.**
8
+
9
+ ---
10
+
11
+ ## 2. TODAS las fugas de cobro off-platform que ve un cliente/revisor de Shopify
12
+
13
+ Dedupe y ordenadas por severidad.
14
+
15
+ ### CRÍTICO
16
+
17
+ **1. `app/portal_ui/index.html:314` (link en :324)** · El bloque `<div id="sub-stripe">` arranca **VISIBLE** (sin clase `hidden`, a diferencia de `#sub-shopify` en :308) y contiene el link hardcodeado `https://buy.stripe.com/eVq5kC5h6cQS9qa5ar5wI04` "Mensual 29 € / mes". Se sirve verbatim en el iframe (`shopify_oauth.py:264`). Solo se oculta en `fillConfig()` (:446) tras cargar config. · **Ve:** shopify-merchant / reviewer · **Fix exacto:** (a) cambiar a `<div id="sub-stripe" class="hidden">` para que falle cerrado; y (b) — definitivo — en `shopify_embedded_app()` (`shopify_oauth.py:270-281`), antes de servir el HTML, **eliminar/neutralizar el nodo `#sub-stripe`** (el iframe es por definición un install de App Store). Nunca debe existir un nodo `buy.stripe.com` en el DOM que renderice el iframe.
18
+
19
+ **2. `app/scheduler.py:253-278` (query :260-270, email :236-250, const :42)** · `pause_expired_trials` selecciona trials expirados filtrando solo `is_demo=False, trial_ends_at<now, chatbot_enabled, stripe_subscription_id==""` — **NO excluye `shopify_shop`/`shopify_install_source`** — y llama `_email_trial_ended`, que emaila `TRIAL_PAY_LINK` (buy.stripe.com) + "29 euros al mes". Un OAuth tenant nunca tiene `stripe_subscription_id`, así que el guard actual no lo protege; un tenant con `shopify_shop` set + `trial_ends_at` no-nulo es barrido y emailado el link de Stripe. · **Ve:** shopify-merchant · **Fix exacto:** añadir al SELECT `Tenant.shopify_shop == ""` **y** `Tenant.shopify_install_source != "oauth"`; y en `_email_trial_ended` ramificar por install source (a tiendas Shopify: no email o apuntar a `/shopify/billing/start`, nunca `TRIAL_PAY_LINK`). Pasar el `Tenant` al helper, no solo email/slug.
20
+
21
+ ### ALTO
22
+
23
+ **3. `app/portal_ui/index.html:316-318` → `app/routes/portal.py:398-432`** · El mismo `#sub-stripe` visible-por-defecto expone "Gestionar mi suscripción" → `openBillingPortal()` → POST `/portal/api/billing-portal` (Stripe Customer Portal). · **Ve:** shopify-merchant · **Fix:** mismo remedio que #1 — ocultar/eliminar todo `#sub-stripe` por defecto en contexto embedded/oauth.
24
+
25
+ **4. `app/routes/shopify_oauth.py:347-354` + `app/routes/shopify_webhooks.py:127-131`** · Al activarse la suscripción Shopify se pone `chatbot_enabled=True`/`disabled_reason=""` pero **NO se limpia `trial_ends_at`** (a diferencia de `billing.py:193` en el path Stripe). Esto deja a un tenant Shopify clasificable como "trial impago" por el scheduler. · **Ve:** shopify-merchant (indirecto, habilita #2) · **Fix:** en ambos puntos de activación, añadir `tenant.trial_ends_at = None` (espejo de `billing.py:193`), para que un tenant Shopify-billed nunca satisfaga el predicado `trial_ends_at IS NOT NULL`.
26
+
27
+ **5. `app/portal_ui/index.html:331-336` (footer) → `app/legal_ui/condiciones.html:7,71` + `cookies.html:70-71` + `privacidad.html:75,111`** · El footer se renderiza **incondicionalmente** (también en el iframe) y enlaza a Condiciones/Cookies/Privacidad, que describen SOLO el cobro Stripe ("200 EUR de alta + 10 EUR cada 3 meses… a través de la pasarela de Stripe"). No mencionan el plan Shopify de 29/mo que el merchant de App Store realmente paga. · **Ve:** shopify-merchant / reviewer · **Fix:** ramificar las plantillas legales por `billing_provider`: para installs OAuth, los términos deben describir Shopify Billing (14 días gratis, luego 29/mo en la factura de Shopify) y NO citar Stripe ni el 200€+10€. Alternativa: servir una página de términos Shopify-specific y apuntar el footer del portal embedded ahí.
28
+
29
+ **6. `app/landing_ui/index.html:418, 433`** · Landing pública (`/`, mismo host que la app) renderiza dos botones Stripe vivos: `buy.stripe.com/eVq5kC5h6cQS9qa5ar5wI04` ("Empezar — 29 €/mes") y `buy.stripe.com/5kQcN4bFu2ce31MfP55wI02` ("Comprar ahora", 200€+10€). Un reviewer que navegue al dominio ve checkout off-Shopify. · **Ve:** reviewer · **Fix:** mover la landing/ventas Stripe a un host de marketing separado del `application_url`; o, si se queda en el mismo host, ocultar/reemplazar ambos botones + las notas "Pago seguro con Stripe" para contexto Shopify, dirigiendo a instalar vía App Store.
30
+
31
+ **7. `app/demo_ui/index.html:164`** · Demo pública (`/demo`, sin auth, mismo host) muestra en la CTA de trial `buy.stripe.com/eVq5kC5h6cQS9qa5ar5wI04` "Suscribirme · 29 €/mes". Un reviewer que pruebe el bot acaba en una pantalla con link Stripe. · **Ve:** reviewer · **Fix:** quitar el link `buy.stripe.com` + el precio del bloque become-customer (o mover la demo al host de marketing); para merchants Shopify la demo debe enrutar al listing de la App Store.
32
+
33
+ ### MEDIO
34
+
35
+ **8. `app/routes/demo.py:75, 353` (`_email_prospect`)** · El email de re-engagement de demo añade `MONTHLY_BUY_LINK` (buy.stripe.com) + "29 € al mes". Si un reviewer mete su email en la demo pública, recibe el link off-platform. · **Ve:** reviewer · **Fix:** no emailar link Stripe a prospects de origen Shopify; gatear `_email_prospect` para que no dispare a un email/shop ya ligado a un `shopify_shop` tenant. (También aplica a `_email_trial_activated` en demo.py:546-570.)
36
+
37
+ **9. `app/routes/widget.py:149`** · El widget de tienda (superficie instalada del merchant) tiene footer "Con tecnología de [Atendyo](https://atendyo.com)". atendyo.com = la landing con botones `buy.stripe.com`. Un clic desde el widget instalado lleva al checkout Stripe. · **Ve:** all-tenants · **Fix:** para tenants Shopify-instalados, quitar el link "powered by", apuntarlo a una página sin botones Stripe, o limpiar los botones Stripe de la landing alcanzada desde contexto Shopify.
38
+
39
+ **10. `app/landing_ui/index.html:29` (JSON-LD)** · `AggregateOffer` schema.org con `lowPrice 29.00`/`highPrice 200.00` EUR + setup 200€ + 10€/3mo, duplicado en FAQ (:35, :444). Estructura pública que anuncia precios off-Shopify. · **Ve:** all-tenants · **Fix:** quitar/revisar el `AggregateOffer`/`Offer` del JSON-LD y las respuestas FAQ de precio si la landing queda en el host de la app; si se mueve a host de marketing, dejar claro que es el canal directo/no-Shopify.
40
+
41
+ ### BAJO
42
+
43
+ **11. `app/portal_ui/index.html:279 (inst-manual) + :188 (sh-manual)`** · Mismo patrón visible-por-defecto en dos panes más que ve el merchant embedded; no contienen links Stripe pero refuerzan que el estado base del panel es el experience NO-App-Store, con el App-Store como excepción activada por JS. · **Ve:** shopify-merchant · **Fix:** invertir los defaults: cuando `window.__EMBEDDED_API_KEY__` está set, tratar al tenant como oauth/App-Store por defecto (mostrar `sub-shopify`/`inst-shopify-app`/`sh-auto`, ocultar los bloques manual+Stripe) ANTES de que config resuelva, y solo relajar si config prueba lo contrario.
44
+
45
+ ---
46
+
47
+ ## 3. Plan de arreglo (orden de aplicación)
48
+
49
+ Solo toca lo que un merchant de Shopify pueda ver/recibir. **No rompe el cobro Stripe de clientes directos** (todos los cambios son ramas condicionales o exclusiones por contexto Shopify; el path Stripe se conserva idéntico cuando `shopify_install_source != "oauth"`).
50
+
51
+ 1. **Server-side, neutralizar `#sub-stripe` en el shell embedded** (`shopify_oauth.py:270-281`): tras leer `_PAGE`, eliminar/reemplazar el bloque `#sub-stripe` (incl. su link `buy.stripe.com` y el botón billing-portal) antes de inyectar App Bridge. El iframe nunca debe contener un nodo `buy.stripe.com`. *(arregla #1, #3)*
52
+ 2. **Fail-closed en el HTML base**: cambiar a `<div id="sub-stripe" class="hidden">` y, en JS, cuando `window.__EMBEDDED_API_KEY__` esté presente, asumir oauth por defecto (ocultar manual+Stripe, mostrar Shopify) antes de cargar config. *(refuerza #1, arregla #11)*
53
+ 3. **Scheduler**: añadir `Tenant.shopify_shop == ""` **y** `Tenant.shopify_install_source != "oauth"` al SELECT de `pause_expired_trials` (`scheduler.py:262-268`); y ramificar `_email_trial_ended` por install source (pasar el `Tenant`). *(arregla #2)*
54
+ 4. **Limpiar `trial_ends_at` al activar sub Shopify**: `tenant.trial_ends_at = None` en `shopify_oauth.py:~350` y `shopify_webhooks.py:~128`. *(arregla #4, defensa en profundidad de #2)*
55
+ 5. **Legal install-source-aware**: ramificar Condiciones/Cookies/Privacidad por `billing_provider`; para oauth describir Shopify Billing y no citar Stripe ni 200€+10€. Apuntar el footer del portal embedded a esos términos. *(arregla #5)*
56
+ 6. **Demo emails**: gatear `_email_prospect`/`_email_trial_activated` (demo.py) para no emailar `MONTHLY_BUY_LINK` a emails/shops ligados a un `shopify_shop` tenant. *(arregla #8)*
57
+ 7. **Widget footer** (`widget.py:149`): para tenants Shopify, apuntar "powered by" a una página neutra sin botones Stripe (o quitar el link). *(arregla #9)*
58
+ 8. **Landing/demo públicas + JSON-LD**: idealmente mover a host de marketing separado del `application_url`; si no, ocultar botones `buy.stripe.com` y el `AggregateOffer` para visitantes de contexto Shopify y dirigir a instalar vía App Store. *(arregla #6, #7, #10)*
59
+
60
+ Verificar suite verde antes de push (la regla del usuario). No tocar `reconcile_billing`, `carts.recover_due_carts` (link Shopify checkout, no billing), ni el path Shopify Billing API.
61
+
62
+ ---
63
+
64
+ ## 4. 4.5.4 — Credenciales de prueba
65
+
66
+ **Cómo se accede al dashboard.** Dos vías:
67
+ - **Embedded (lo que usa el reviewer):** dentro del admin de Shopify, el panel se auto-autentica vía **App Bridge session token** (`GET /shopify/app/session`). **No necesita Access Key ni login.** El reviewer solo instala vía OAuth desde el listing y abre la app.
68
+ - **Standalone `/portal`:** se autentica con un **Access Key = `Tenant.dashboard_token`** (formato `"cli_" + token_urlsafe(24)`, columna `models.py:140`). Solo necesario si el reviewer quiere probar el portal fuera de Shopify.
69
+
70
+ **Qué Access Key dar al reviewer.** Para el flujo App Store **no hace falta ninguna** (session token automático). Si se quiere ofrecer acceso al portal standalone de cortesía, generar/elegir un test store y darle su `dashboard_token` como Access Key. El charge se crea como **TEST** en una development store (`is_development_store` → `test=true`), así que el reviewer completa el trial de 14 días sin dinero real.
71
+
72
+ **Texto exacto de "Testing instructions" (pegar en el Partner Dashboard, en inglés):**
73
+
74
+ ```
75
+ TESTING INSTRUCTIONS — Atendyo
76
+
77
+ No separate username/password is required. The app authenticates inside the
78
+ Shopify admin via App Bridge session tokens.
79
+
80
+ 1. From your Partner/development store, install Atendyo via the App Store
81
+ listing (OAuth). If you land on a 404, the app redirects you to
82
+ /shopify/install?shop=<your-store> to start the OAuth flow.
83
+ 2. After install, open the app from Apps in your Shopify admin. The embedded
84
+ panel loads automatically — no login screen, no access key.
85
+ 3. Billing is handled entirely by Shopify Billing (App Subscriptions API):
86
+ 14 days free, then 29 EUR/month, charged on your Shopify invoice. Open the
87
+ "Mi suscripción / My subscription" tab and click "Start free trial" — this
88
+ calls /shopify/billing/start and redirects you to Shopify's standard
89
+ subscription approval screen. On a development store the charge is created
90
+ as a TEST charge, so you can approve the 14-day trial with no real money.
91
+ 4. After approving, you are returned to the embedded app and the chatbot is
92
+ enabled. Add knowledge (a URL or PDF) in the "Conocimiento / Knowledge" tab
93
+ and test the assistant on your storefront or the hosted chat page.
94
+ 5. To uninstall, remove the app from Settings > Apps and sales channels — the
95
+ app/uninstalled webhook disables the bot automatically.
96
+
97
+ OPTIONAL — standalone dashboard (not required for review): the web portal at
98
+ /portal authenticates with a per-store Access Key. If you want to test it,
99
+ use this Access Key for the test store: cli_<PEGAR_dashboard_token_AQUI>
100
+
101
+ All app charges go through Shopify Billing. There is no off-Shopify checkout in
102
+ the embedded app experience.
103
+ ```
104
+
105
+ > Antes de enviar, reemplazar `cli_<PEGAR_dashboard_token_AQUI>` por el `dashboard_token` real de un test store (consultable en la DB: `SELECT dashboard_token FROM tenants WHERE slug='<test-store>'`).
106
+
107
+ ---
108
+
109
+ ## 5. Checklist de resubmisión
110
+
111
+ - [ ] **#1/#3** `#sub-stripe` neutralizado server-side en el shell embedded (`shopify_oauth.py`) — verificado: 0 nodos `buy.stripe.com` en el HTML servido en `/shopify/app` (grep del response).
112
+ - [ ] **#1/#11** `#sub-stripe` con `class="hidden"` por defecto + JS fail-closed (oauth por defecto cuando `__EMBEDDED_API_KEY__` presente).
113
+ - [ ] **#2** `pause_expired_trials` excluye `shopify_shop==""` y `shopify_install_source!="oauth"`; `_email_trial_ended` ramifica por install source.
114
+ - [ ] **#4** `trial_ends_at=None` al activar sub Shopify (oauth callback + webhook).
115
+ - [ ] **#5** Páginas legales (condiciones/cookies/privacidad) install-source-aware; footer embedded apunta a términos Shopify (sin citar Stripe ni 200€+10€).
116
+ - [ ] **#8** `_email_prospect`/`_email_trial_activated` no emailan link Stripe a contexto Shopify.
117
+ - [ ] **#9** Footer del widget no enlaza a página con botones Stripe para tenants Shopify.
118
+ - [ ] **#6/#7/#10** Landing + demo + JSON-LD: Stripe checkout no alcanzable desde contexto Shopify (idealmente host de marketing separado del `application_url`).
119
+ - [ ] **Regresión Stripe directo**: probar un tenant `shopify_install_source!="oauth"` — sigue viendo `#sub-stripe`, recibe `TRIAL_PAY_LINK`, y `pause_expired_trials` lo barre como antes.
120
+ - [ ] **Flujo Shopify Billing intacto**: install OAuth → gate billing → `/shopify/billing/start` → approval (TEST en dev store) → `/shopify/billing/return` habilita el bot.
121
+ - [ ] **Suite de tests completa en verde** (gate de push del usuario; tests que aserten exclusión Shopify del cron y ausencia de `buy.stripe.com` en el HTML embedded).
122
+ - [ ] **4.5.4**: "Testing instructions" pegadas en el Partner Dashboard con el `dashboard_token` real sustituido.
123
+ - [ ] Confirmar que `extension/shopify.app.toml` declara el precio (Managed Pricing) o que `appSubscriptionCreate` price/currency coinciden con el copy del panel (reconciliar "29 $/mes" USD del portal con `shopify_plan_currency="EUR"` de `config.py`).
124
+
125
+ **Archivos a editar (rutas absolutas):**
126
+ - `/Users/victorgomez/code/shopify-support-bot/app/scheduler.py` (#2, #4-helper)
127
+ - `/Users/victorgomez/code/shopify-support-bot/app/routes/shopify_oauth.py` (#1 server-strip, #4)
128
+ - `/Users/victorgomez/code/shopify-support-bot/app/routes/shopify_webhooks.py` (#4)
129
+ - `/Users/victorgomez/code/shopify-support-bot/app/portal_ui/index.html` (#1, #3, #11)
130
+ - `/Users/victorgomez/code/shopify-support-bot/app/legal_ui/{condiciones,cookies,privacidad}.html` (#5)
131
+ - `/Users/victorgomez/code/shopify-support-bot/app/routes/demo.py` (#8)
132
+ - `/Users/victorgomez/code/shopify-support-bot/app/routes/widget.py` (#9)
133
+ - `/Users/victorgomez/code/shopify-support-bot/app/landing_ui/index.html` + `app/demo_ui/index.html` (#6, #7, #10)
134
+ - `/Users/victorgomez/code/shopify-support-bot/extension/shopify.app.toml` (separación de host / pricing — opcional pero recomendado)
_shopify-review/TESTING-INSTRUCTIONS.txt ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ TESTING INSTRUCTIONS — Atendyo AI ChatBot & Live Chat
2
+ (Paste this into Partner Dashboard → App listing → App setup → "Testing instructions / app review".)
3
+
4
+ No separate username or password is required. Atendyo is an embedded app and
5
+ authenticates inside the Shopify admin automatically via App Bridge session
6
+ tokens — there is no login screen and no access key to enter.
7
+
8
+ HOW TO TEST
9
+ 1. From your Partner development store, install Atendyo from the App Store
10
+ listing (OAuth). If you open a deep link and see a 404, start the flow at
11
+ /shopify/install?shop=YOUR-STORE.myshopify.com
12
+ 2. After install, open Atendyo from "Apps" in your Shopify admin. The embedded
13
+ panel loads automatically — no login, no access key.
14
+ 3. BILLING (Shopify Billing API only): open the "Mi suscripción / My
15
+ subscription" tab and click "Start free trial". This calls
16
+ /shopify/billing/start, which creates a Shopify App Subscription
17
+ (14 days free, then 29 EUR/month) and redirects you to Shopify's standard
18
+ subscription approval screen. On a development store the charge is created as
19
+ a TEST charge, so you can approve the 14-day trial with no real money.
20
+ All app charges go through Shopify Billing — there is NO off-Shopify checkout
21
+ anywhere in the embedded app experience.
22
+ 4. After approving, you return to the embedded app and the assistant is enabled.
23
+ Add knowledge (paste a URL or upload a PDF) in the "Conocimiento / Knowledge"
24
+ tab, then test the assistant on your storefront or the hosted chat page.
25
+ 5. To uninstall: Settings → Apps and sales channels → remove Atendyo. The
26
+ app/uninstalled webhook disables the bot automatically.
27
+
28
+ OPTIONAL — standalone web dashboard (NOT required for review)
29
+ The app also has a standalone portal at /portal that uses a per-store Access
30
+ Key. You do not need it to review the embedded app. If you want to test it, use
31
+ this Access Key for our test store:
32
+
33
+ Access Key: <PASTE dashboard_token OF A TEST STORE HERE>
34
+ Portal URL: https://victor34593993-flexigo-support-bot.hf.space/portal
35
+
36
+ (To get the key, run on the DB: SELECT dashboard_token FROM tenants WHERE slug='<your-test-store>'; — it looks like cli_xxxxxxxx.)
37
+
38
+ NOTE FOR THE REVIEWER
39
+ All app charges are processed through the Shopify Billing API (App
40
+ Subscriptions). The embedded app never links to or uses any external payment
41
+ provider. Our public marketing website sells a separate, self-hosted (non-Shopify)
42
+ version; that channel is not part of, and is not reachable from, the Shopify app.
app/audit.py ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Free public "store support audit" tool — logic + rendering.
2
+
3
+ For a crawled Shopify store we score how many of the 12 universal high-intent
4
+ buyer questions its OWN public site answers (reusing the honest gap report), grade
5
+ it, and persist it (AuditResult, keyed by domain) so /audit/<domain> is a PERMANENT,
6
+ shareable, crawlable page — the build-as-marketing / programmatic-SEO wedge.
7
+
8
+ Everything here is honest by construction: real buyer questions, HIT/MISS from the
9
+ store's own indexed pages, conservative MISS threshold, no invented figures.
10
+ """
11
+ # ruff: noqa: E501 - this module embeds HTML/CSS page templates
12
+
13
+ from __future__ import annotations
14
+
15
+ import html
16
+ import io
17
+ from datetime import UTC, datetime
18
+
19
+ from sqlalchemy import select
20
+ from sqlalchemy.ext.asyncio import AsyncSession
21
+
22
+ from app.demo_gap import generate_gap_report
23
+ from app.models import AuditResult, Tenant
24
+
25
+ PUBLIC_BASE = "https://atendyo.com"
26
+ BACKEND_BASE = "https://victor34593993-flexigo-support-bot.hf.space"
27
+
28
+
29
+ def grade_for(answered: int, total: int) -> str:
30
+ """A clear letter grade from the answered ratio (honest, not punitive)."""
31
+ if total <= 0:
32
+ return "F"
33
+ pct = answered / total
34
+ if pct >= 0.9:
35
+ return "A"
36
+ if pct >= 0.75:
37
+ return "B"
38
+ if pct >= 0.6:
39
+ return "C"
40
+ if pct >= 0.4:
41
+ return "D"
42
+ return "F"
43
+
44
+
45
+ def _norm_domain(raw: str) -> str:
46
+ d = (raw or "").strip().lower()
47
+ d = d.split("//")[-1].split("/")[0].removeprefix("www.")
48
+ return d[:255]
49
+
50
+
51
+ async def save_audit(
52
+ db: AsyncSession, domain: str, report: dict, lang: str = "es"
53
+ ) -> AuditResult:
54
+ """Upsert the audit result for a domain (re-audits update the same row)."""
55
+ domain = _norm_domain(domain)
56
+ answered = int(report.get("answered", 0))
57
+ total = int(report.get("total", 12))
58
+ row = (
59
+ await db.execute(select(AuditResult).where(AuditResult.domain == domain))
60
+ ).scalar_one_or_none()
61
+ now = datetime.now(UTC)
62
+ if row is None:
63
+ row = AuditResult(domain=domain, created_at=now)
64
+ db.add(row)
65
+ row.brand = (report.get("brand") or domain)[:255]
66
+ row.answered = answered
67
+ row.total = total
68
+ row.grade = grade_for(answered, total)
69
+ row.questions = report.get("questions", [])
70
+ row.lang = "en" if lang == "en" else "es"
71
+ row.updated_at = now
72
+ await db.flush()
73
+ return row
74
+
75
+
76
+ async def get_audit(db: AsyncSession, domain: str) -> AuditResult | None:
77
+ return (
78
+ await db.execute(
79
+ select(AuditResult).where(AuditResult.domain == _norm_domain(domain))
80
+ )
81
+ ).scalar_one_or_none()
82
+
83
+
84
+ async def audit_demo_tenant(db: AsyncSession, tenant: Tenant, lang: str = "es") -> AuditResult:
85
+ """Run the gap report on an already-crawled demo tenant and persist it."""
86
+ report = await generate_gap_report(db, tenant, lang=lang)
87
+ domain = _norm_domain(tenant.brand_name or tenant.name or "")
88
+ return await save_audit(db, domain, report, lang=lang)
89
+
90
+
91
+ # ── rendering ───────────────────────────────────────────────────────────────
92
+ _T = {
93
+ "es": {
94
+ "title": "{brand}: responde {a}/{t} preguntas de compra | Auditoría Atendyo",
95
+ "desc": "{brand} responde {a} de {t} preguntas clave que tus clientes hacen antes de comprar. Mira cuáles fallan (gratis).",
96
+ "h1": "{brand} responde {a}/{t} preguntas de compra",
97
+ "sub": "Estas son las preguntas que tus clientes hacen ANTES de comprar. Cada una sin respuesta clara en tu web = una venta que se escapa o un ticket de soporte.",
98
+ "answers": "Tu web responde",
99
+ "gaps_h": "No responde con claridad:",
100
+ "ok_h": "Sí responde:",
101
+ "cta_h": "El asistente de IA de Atendyo responde TODAS estas, 24/7, con la información de tu propia tienda.",
102
+ "cta_btn": "Probar gratis con mi tienda",
103
+ "share": "Comparte tu resultado",
104
+ "copy": "Copiar enlace",
105
+ "audit_another": "Auditar otra tienda",
106
+ "powered": "Auditoría gratis de Atendyo · atendyo.com",
107
+ },
108
+ "en": {
109
+ "title": "{brand}: answers {a}/{t} shopper questions | Atendyo Audit",
110
+ "desc": "{brand} answers {a} of {t} key questions your customers ask before buying. See which ones fail (free).",
111
+ "h1": "{brand} answers {a}/{t} shopper questions",
112
+ "sub": "These are the questions your customers ask BEFORE buying. Each one your site doesn't clearly answer = a lost sale or a support ticket.",
113
+ "answers": "Your site answers",
114
+ "gaps_h": "Not clearly answered:",
115
+ "ok_h": "Answered:",
116
+ "cta_h": "Atendyo's AI assistant answers ALL of these, 24/7, from your own store's info.",
117
+ "cta_btn": "Try free on my store",
118
+ "share": "Share your result",
119
+ "copy": "Copy link",
120
+ "audit_another": "Audit another store",
121
+ "powered": "Free audit by Atendyo · atendyo.com",
122
+ },
123
+ }
124
+
125
+ _GRADE_COLOR = {"A": "#16a34a", "B": "#65a30d", "C": "#ca8a04", "D": "#ea580c", "F": "#dc2626"}
126
+
127
+
128
+ def _question_text(q: dict, lang: str) -> str:
129
+ return str(q.get("q") or "")
130
+
131
+
132
+ def render_audit_page(row: AuditResult, *, base: str = PUBLIC_BASE) -> str:
133
+ lang = "en" if row.lang == "en" else "es"
134
+ t = _T[lang]
135
+ brand = html.escape(row.brand or row.domain)
136
+ a, tot = row.answered, row.total
137
+ grade = row.grade or grade_for(a, tot)
138
+ color = _GRADE_COLOR.get(grade, "#dc2626")
139
+ qs = row.questions or []
140
+ gaps = [q for q in qs if q.get("status") == "miss"]
141
+ oks = [q for q in qs if q.get("status") == "hit"]
142
+ page_url = f"{base}/audit/{row.domain}"
143
+ og = f"{base}/audit/{row.domain}/og.png"
144
+
145
+ def _li(items):
146
+ return "".join(f"<li>{html.escape(_question_text(q, lang))}</li>" for q in items)
147
+
148
+ title = t["title"].format(brand=brand, a=a, t=tot)
149
+ desc = t["desc"].format(brand=brand, a=a, t=tot)
150
+ try_url = f"{base}/audit?u={html.escape(row.domain)}"
151
+ return f"""<!doctype html>
152
+ <html lang="{lang}"><head>
153
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
154
+ <title>{title}</title>
155
+ <meta name="description" content="{html.escape(desc)}" />
156
+ <link rel="canonical" href="{page_url}" />
157
+ <meta property="og:type" content="website" />
158
+ <meta property="og:title" content="{title}" />
159
+ <meta property="og:description" content="{html.escape(desc)}" />
160
+ <meta property="og:image" content="{og}" />
161
+ <meta property="og:url" content="{page_url}" />
162
+ <meta name="twitter:card" content="summary_large_image" />
163
+ <meta name="twitter:image" content="{og}" />
164
+ <style>
165
+ :root {{ --c:{color}; }}
166
+ *{{box-sizing:border-box}} body{{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#0f172a;background:#f8fafc}}
167
+ .wrap{{max-width:680px;margin:0 auto;padding:28px 20px 60px}}
168
+ .logo{{font-weight:800;font-size:18px;color:#1c59e9;text-decoration:none}}
169
+ .card{{background:#fff;border:1px solid #e2e8f0;border-radius:18px;padding:26px;margin-top:18px;box-shadow:0 8px 30px rgba(2,8,20,.05)}}
170
+ .score{{display:flex;align-items:center;gap:18px}}
171
+ .grade{{flex:none;width:78px;height:78px;border-radius:16px;background:var(--c);color:#fff;font-size:40px;font-weight:800;display:flex;align-items:center;justify-content:center}}
172
+ h1{{font-size:24px;margin:0 0 4px}}
173
+ .sub{{color:#475569;font-size:15px;margin:14px 0 0;line-height:1.55}}
174
+ h3{{font-size:15px;margin:22px 0 8px}}
175
+ ul{{margin:0;padding-left:20px;line-height:1.8}}
176
+ ul.miss li{{color:#b91c1c}} ul.ok li{{color:#15803d}}
177
+ .cta{{background:#0f1830;color:#fff;border-radius:16px;padding:22px;margin-top:22px;text-align:center}}
178
+ .cta p{{margin:0 0 14px;font-size:16px;line-height:1.5}}
179
+ .btn{{display:inline-block;background:#1c59e9;color:#fff;text-decoration:none;font-weight:700;border-radius:999px;padding:13px 26px}}
180
+ .share{{margin-top:20px}} .share label{{font-size:13px;color:#64748b;display:block;margin-bottom:6px}}
181
+ .row{{display:flex;gap:8px}} .row input{{flex:1;border:1px solid #cbd5e1;border-radius:10px;padding:10px;font-size:13px}}
182
+ .row a,.row button{{border:1px solid #cbd5e1;background:#fff;border-radius:10px;padding:10px 12px;font-size:13px;cursor:pointer;text-decoration:none;color:#0f172a;white-space:nowrap}}
183
+ footer{{text-align:center;color:#94a3b8;font-size:12px;margin-top:30px}} footer a{{color:#94a3b8}}
184
+ </style></head>
185
+ <body><div class="wrap">
186
+ <a class="logo" href="{base}/audit">Atendyo</a>
187
+ <div class="card">
188
+ <div class="score">
189
+ <div class="grade">{grade}</div>
190
+ <div><h1>{t['h1'].format(brand=brand, a=a, t=tot)}</h1>
191
+ <div style="color:#64748b;font-size:14px">{t['answers']} <b>{a}/{tot}</b></div></div>
192
+ </div>
193
+ <p class="sub">{t['sub']}</p>
194
+ {f'<h3>{t["gaps_h"]}</h3><ul class="miss">{_li(gaps)}</ul>' if gaps else ''}
195
+ {f'<h3>{t["ok_h"]}</h3><ul class="ok">{_li(oks)}</ul>' if oks else ''}
196
+ <div class="cta">
197
+ <p>{t['cta_h']}</p>
198
+ <a class="btn" href="{try_url}">{t['cta_btn']}</a>
199
+ </div>
200
+ <div class="share">
201
+ <label>{t['share']}</label>
202
+ <div class="row">
203
+ <input id="u" value="{page_url}" readonly />
204
+ <button onclick="navigator.clipboard&&navigator.clipboard.writeText(document.getElementById('u').value);this.textContent='OK'">{t['copy']}</button>
205
+ <a href="https://wa.me/?text={html.escape(page_url)}" target="_blank" rel="noopener">WhatsApp</a>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ <footer><a href="{base}/audit">{t['powered']}</a> · <a href="{base}/audit">{t['audit_another']}</a></footer>
210
+ </div></body></html>"""
211
+
212
+
213
+ _LANDING = {
214
+ "es": {
215
+ "title": "¿Qué preguntas de compra NO responde tu tienda Shopify? Compruébalo gratis (2026) | Atendyo",
216
+ "desc": "Pega la URL de tu tienda Shopify y en 60 segundos te decimos cuántas de las 12 preguntas que tus clientes hacen antes de comprar responde tu web. Gratis, sin registro.",
217
+ "h1": "¿Qué preguntas de tus clientes NO responde tu tienda?",
218
+ "sub": "Pega la URL de tu tienda Shopify. En 60 segundos analizamos tu propia web y te decimos cuántas de las 12 preguntas clave de compra responde — y cuáles se te escapan. Gratis, sin registro.",
219
+ "ph": "tutienda.com",
220
+ "btn": "Auditar mi tienda gratis",
221
+ "working": "Analizando tu tienda… (suele tardar menos de un minuto)",
222
+ "err": "No pudimos analizar esa tienda. Revisa la URL e inténtalo otra vez.",
223
+ },
224
+ "en": {
225
+ "title": "Which shopper questions does your Shopify store NOT answer? Check free (2026) | Atendyo",
226
+ "desc": "Paste your Shopify store URL and in 60 seconds we tell you how many of the 12 questions customers ask before buying your site answers. Free, no signup.",
227
+ "h1": "Which of your customers' questions does your store NOT answer?",
228
+ "sub": "Paste your Shopify store URL. In 60 seconds we analyze your own site and tell you how many of the 12 key buying questions it answers — and which ones slip through. Free, no signup.",
229
+ "ph": "yourstore.com",
230
+ "btn": "Audit my store free",
231
+ "working": "Analyzing your store… (usually under a minute)",
232
+ "err": "We couldn't analyze that store. Check the URL and try again.",
233
+ },
234
+ }
235
+
236
+
237
+ def render_audit_landing(lang: str = "es", *, base: str = PUBLIC_BASE) -> str:
238
+ lang = "en" if lang == "en" else "es"
239
+ t = _LANDING[lang]
240
+ return f"""<!doctype html>
241
+ <html lang="{lang}"><head>
242
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
243
+ <title>{t['title']}</title>
244
+ <meta name="description" content="{html.escape(t['desc'])}" />
245
+ <link rel="canonical" href="{base}/audit" />
246
+ <meta property="og:title" content="{html.escape(t['title'])}" />
247
+ <meta property="og:description" content="{html.escape(t['desc'])}" />
248
+ <meta property="og:image" content="{base}/og.png" />
249
+ <meta name="twitter:card" content="summary_large_image" />
250
+ <style>
251
+ *{{box-sizing:border-box}} body{{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#0f172a;background:linear-gradient(180deg,#fff,#eef2ff)}}
252
+ .wrap{{max-width:640px;margin:0 auto;padding:64px 20px;text-align:center}}
253
+ .logo{{font-weight:800;font-size:18px;color:#1c59e9;text-decoration:none}}
254
+ h1{{font-size:32px;margin:26px 0 12px;line-height:1.2}}
255
+ p.sub{{color:#475569;font-size:17px;line-height:1.55;margin:0 auto 26px;max-width:520px}}
256
+ form{{display:flex;gap:8px;max-width:460px;margin:0 auto}}
257
+ input{{flex:1;border:1px solid #cbd5e1;border-radius:12px;padding:14px;font-size:16px;min-width:0}}
258
+ button{{background:#1c59e9;color:#fff;border:0;border-radius:12px;padding:14px 22px;font-size:16px;font-weight:700;cursor:pointer;white-space:nowrap}}
259
+ .status{{margin-top:18px;color:#475569;min-height:24px}}
260
+ .err{{color:#b91c1c}}
261
+ @media(max-width:560px){{h1{{font-size:26px}} form{{flex-direction:column}}}}
262
+ </style></head>
263
+ <body><div class="wrap">
264
+ <a class="logo" href="{base}">Atendyo</a>
265
+ <h1>{t['h1']}</h1>
266
+ <p class="sub">{t['sub']}</p>
267
+ <form id="f" onsubmit="return go(event)">
268
+ <input id="u" type="text" inputmode="url" placeholder="{t['ph']}" autocomplete="off" />
269
+ <button type="submit">{t['btn']}</button>
270
+ </form>
271
+ <div class="status" id="s"></div>
272
+ <script>
273
+ var WORKING={t['working']!r}, ERR={t['err']!r};
274
+ (function(){{var p=new URLSearchParams(location.search).get('u');if(p){{document.getElementById('u').value=p;}}}})();
275
+ function go(e){{
276
+ e.preventDefault();
277
+ var u=(document.getElementById('u').value||'').trim();
278
+ if(!u) return false;
279
+ var s=document.getElementById('s'); s.className='status'; s.textContent=WORKING;
280
+ fetch('/audit/start',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{url:u}})}})
281
+ .then(function(r){{return r.ok?r.json():r.json().then(function(j){{throw new Error(j.detail||'error')}})}})
282
+ .then(function(d){{poll(d.slug,0)}})
283
+ .catch(function(){{s.className='status err';s.textContent=ERR}});
284
+ return false;
285
+ }}
286
+ function poll(slug,n){{
287
+ var s=document.getElementById('s');
288
+ if(n>40){{s.className='status err';s.textContent=ERR;return}}
289
+ fetch('/audit/status?d='+encodeURIComponent(slug))
290
+ .then(function(r){{return r.json()}})
291
+ .then(function(st){{
292
+ if(st.done && st.stage==='error'){{s.className='status err';s.textContent=ERR;return}}
293
+ // crawl ready enough to score once we have pages OR it's done
294
+ if(st.done || (st.pages_done||0)>=4){{ finalize(slug); return; }}
295
+ setTimeout(function(){{poll(slug,n+1)}},2500);
296
+ }})
297
+ .catch(function(){{setTimeout(function(){{poll(slug,n+1)}},2500)}});
298
+ }}
299
+ function finalize(slug){{
300
+ fetch('/audit/'+encodeURIComponent(slug)+'/finalize',{{method:'POST'}})
301
+ .then(function(r){{return r.ok?r.json():r.json().then(function(j){{throw new Error(j.detail||'error')}})}})
302
+ .then(function(d){{ window.location.href = '/audit/'+encodeURIComponent(d.domain); }})
303
+ .catch(function(){{document.getElementById('s').className='status err';document.getElementById('s').textContent=ERR}});
304
+ }}
305
+ </script>
306
+ </div></body></html>"""
307
+
308
+
309
+ def og_image_bytes(row: AuditResult) -> bytes:
310
+ """A 1200x630 share card with the score. Bulletproof: any failure returns a
311
+ minimal valid PNG instead of raising (the page must never break)."""
312
+ try:
313
+ from PIL import Image, ImageDraw, ImageFont
314
+
315
+ grade = row.grade or grade_for(row.answered, row.total)
316
+ color = _GRADE_COLOR.get(grade, "#dc2626")
317
+ img = Image.new("RGB", (1200, 630), "#0f1830")
318
+ d = ImageDraw.Draw(img)
319
+
320
+ def font(sz: int):
321
+ try:
322
+ return ImageFont.truetype("DejaVuSans-Bold.ttf", sz)
323
+ except Exception: # noqa: BLE001
324
+ try:
325
+ return ImageFont.load_default(sz)
326
+ except TypeError:
327
+ return ImageFont.load_default()
328
+
329
+ d.text((70, 70), "Atendyo · Auditoría de tienda", font=font(34), fill="#8aa0c8")
330
+ brand = (row.brand or row.domain)[:34]
331
+ d.text((70, 180), brand, font=font(64), fill="#ffffff")
332
+ d.text((70, 300), f"responde {row.answered}/{row.total}", font=font(58), fill="#ffffff")
333
+ d.text((70, 372), "preguntas de compra", font=font(40), fill="#cbd5e1")
334
+ # grade chip + centered letter (anchor="mm" centers cleanly; fallback if
335
+ # the loaded font doesn't support anchors)
336
+ d.rounded_rectangle((900, 180, 1130, 410), radius=28, fill=color)
337
+ gf = font(150)
338
+ try:
339
+ d.text((1015, 295), grade, font=gf, fill="#ffffff", anchor="mm")
340
+ except (ValueError, TypeError):
341
+ d.text((975, 225), grade, font=gf, fill="#ffffff")
342
+ d.text((70, 520), "atendyo.com/audit · pruébalo gratis con tu tienda", font=font(30), fill="#8aa0c8")
343
+ buf = io.BytesIO()
344
+ img.save(buf, format="PNG")
345
+ return buf.getvalue()
346
+ except Exception: # noqa: BLE001 - never break the page over an image
347
+ # 1x1 transparent PNG fallback
348
+ import base64
349
+ return base64.b64decode(
350
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mbut"
351
+ "FAAAAAElFTkSuQmCC"
352
+ )
app/legal_ui/condiciones.html CHANGED
@@ -68,7 +68,10 @@
68
  <p>Las respuestas del asistente se generan automáticamente mediante inteligencia artificial a partir de la información configurada por el cliente. Aunque el sistema está diseñado para responder solo con esa información, ningún sistema de IA está libre de errores: el cliente es responsable de revisar la información que le proporciona y la configuración de su asistente.</p>
69
 
70
  <h2>3. Precio y forma de pago</h2>
71
- <p>El precio del servicio es de <strong>200 EUR de alta</strong> (pago único) más <strong>10 EUR cada 3 meses</strong> en concepto de mantenimiento, <strong>IVA incluido</strong> en ambos importes. El pago se realiza de forma segura a través de la pasarela de Stripe. Para empresas y autónomos se emite factura con NIF a través de Stripe.</p>
 
 
 
72
 
73
  <h2>4. Activación tras el pago</h2>
74
  <p>La activación es inmediata y automática: al completarse el pago se crea tu cuenta y recibes un email con tu clave de acceso al panel y las instrucciones de instalación. Desde ese momento el servicio de alta queda completamente ejecutado y puedes configurar e instalar el asistente en tu web.</p>
 
68
  <p>Las respuestas del asistente se generan automáticamente mediante inteligencia artificial a partir de la información configurada por el cliente. Aunque el sistema está diseñado para responder solo con esa información, ningún sistema de IA está libre de errores: el cliente es responsable de revisar la información que le proporciona y la configuración de su asistente.</p>
69
 
70
  <h2>3. Precio y forma de pago</h2>
71
+ <p>El precio del servicio para clientes <strong>directos</strong> (que contratan en atendyo.com) es de <strong>200 EUR de alta</strong> (pago único) más <strong>10 EUR cada 3 meses</strong> en concepto de mantenimiento, <strong>IVA incluido</strong> en ambos importes. El pago se realiza de forma segura a través de la pasarela de Stripe. Para empresas y autónomos se emite factura con NIF a través de Stripe.</p>
72
+ <div class="box">
73
+ <p><strong>Instalación desde la Shopify App Store.</strong> Si has instalado Atendyo como aplicación desde la Shopify App Store, <strong>el precio anterior (200 EUR + 10 EUR) y la pasarela de Stripe no se te aplican</strong>. En ese caso el servicio dispone de <strong>14 días gratis</strong> y, a continuación, <strong>29 €/mes</strong>, importes que se cobran <strong>exclusivamente a través de Shopify Billing</strong> y aparecen en tu factura de Shopify. No te cobramos ni te facturamos por Stripe.</p>
74
+ </div>
75
 
76
  <h2>4. Activación tras el pago</h2>
77
  <p>La activación es inmediata y automática: al completarse el pago se crea tu cuenta y recibes un email con tu clave de acceso al panel y las instrucciones de instalación. Desde ese momento el servicio de alta queda completamente ejecutado y puedes configurar e instalar el asistente en tu web.</p>
app/legal_ui/cookies.html CHANGED
@@ -68,7 +68,8 @@
68
  <p>La normativa (artículo 22.2 de la LSSI-CE, que traslada la Directiva ePrivacy) solo exige consentimiento para los dispositivos de almacenamiento que <em>no</em> sean estrictamente necesarios para prestar el servicio solicitado por el usuario. Todo el almacenamiento descrito arriba es técnico y necesario (mantener tu sesión de chat, tu idioma o tu acceso), por lo que está <strong>exento de consentimiento</strong> y no se requiere banner. Si algún día incorporásemos cookies de analítica o publicidad, actualizaríamos esta política y pediríamos consentimiento previo.</p>
69
 
70
  <h2>4. Pagos con Stripe</h2>
71
- <p>El pago del servicio se realiza en la pasarela de Stripe (en el dominio de Stripe). Durante el proceso de pago, Stripe utiliza sus propias cookies conforme a su <a href="https://stripe.com/es/legal/cookies-policy" target="_blank" rel="noopener">política de cookies</a>.</p>
 
72
 
73
  <h2>5. Cómo eliminar este almacenamiento</h2>
74
  <p>Puedes borrar el almacenamiento local en cualquier momento desde la configuración de tu navegador (borrar datos de sitios web). Al hacerlo, el chat iniciará una conversación nueva y tendrás que volver a elegir tus preferencias.</p>
 
68
  <p>La normativa (artículo 22.2 de la LSSI-CE, que traslada la Directiva ePrivacy) solo exige consentimiento para los dispositivos de almacenamiento que <em>no</em> sean estrictamente necesarios para prestar el servicio solicitado por el usuario. Todo el almacenamiento descrito arriba es técnico y necesario (mantener tu sesión de chat, tu idioma o tu acceso), por lo que está <strong>exento de consentimiento</strong> y no se requiere banner. Si algún día incorporásemos cookies de analítica o publicidad, actualizaríamos esta política y pediríamos consentimiento previo.</p>
69
 
70
  <h2>4. Pagos con Stripe</h2>
71
+ <p>Para los clientes <strong>directos</strong> (que contratan en atendyo.com), el pago del servicio se realiza en la pasarela de Stripe (en el dominio de Stripe). Durante el proceso de pago, Stripe utiliza sus propias cookies conforme a su <a href="https://stripe.com/es/legal/cookies-policy" target="_blank" rel="noopener">política de cookies</a>.</p>
72
+ <p>Si has instalado Atendyo desde la <strong>Shopify App Store</strong>, el cobro (14 días gratis y luego 29 €/mes) se gestiona <strong>a través de Shopify Billing</strong>, no de Stripe, por lo que Stripe no interviene ni instala cookies en tu proceso de pago.</p>
73
 
74
  <h2>5. Cómo eliminar este almacenamiento</h2>
75
  <p>Puedes borrar el almacenamiento local en cualquier momento desde la configuración de tu navegador (borrar datos de sitios web). Al hacerlo, el chat iniciará una conversación nueva y tendrás que volver a elegir tus preferencias.</p>
app/legal_ui/privacidad.html CHANGED
@@ -72,7 +72,7 @@
72
  </tr>
73
  <tr>
74
  <td><strong>Compra y cuenta de cliente</strong></td>
75
- <td>Email, nombre del negocio y datos de facturación. El pago se realiza en la pasarela de Stripe; no almacenamos los datos de tu tarjeta.</td>
76
  <td>Ejecución del contrato (art. 6.1.b) y obligaciones legales, en especial fiscales (art. 6.1.c).</td>
77
  <td>Mientras dure la relación contractual y, después, durante los plazos de prescripción legales y fiscales aplicables.</td>
78
  </tr>
@@ -108,7 +108,8 @@
108
  <li><strong>Hugging Face, Inc.</strong> (EE. UU.) — alojamiento de la aplicación.</li>
109
  <li><strong>Neon, Inc.</strong> — base de datos PostgreSQL, alojada en la región <strong>eu-central-1</strong> (Fráncfort, Unión Europea).</li>
110
  <li><strong>Brevo</strong> (Sendinblue SAS, Francia, UE) — envío de emails transaccionales.</li>
111
- <li><strong>Stripe</strong> (Stripe Payments Europe, Ltd. y Stripe, Inc.) — pagos y facturación.</li>
 
112
  <li><strong>Proveedores de inferencia de IA</strong> — generación de las respuestas del asistente: Groq, Cloudflare, Cerebras, SambaNova y OpenRouter (EE. UU.) y Mistral AI (Francia, UE). Usamos sus API con condiciones de <strong>no entrenamiento</strong>: los mensajes enviados por API no se utilizan para entrenar sus modelos.</li>
113
  <li><strong>Meta Platforms</strong> (WhatsApp Cloud API) — solo cuando el canal WhatsApp está activo, para recibir y enviar los mensajes de WhatsApp.</li>
114
  </ul>
 
72
  </tr>
73
  <tr>
74
  <td><strong>Compra y cuenta de cliente</strong></td>
75
+ <td>Email, nombre del negocio y datos de facturación. Para clientes <strong>directos</strong> (atendyo.com) el pago se realiza en la pasarela de Stripe. Si instalaste Atendyo desde la <strong>Shopify App Store</strong>, el cobro lo gestiona <strong>Shopify Billing</strong> (no Stripe). En ningún caso almacenamos los datos de tu tarjeta.</td>
76
  <td>Ejecución del contrato (art. 6.1.b) y obligaciones legales, en especial fiscales (art. 6.1.c).</td>
77
  <td>Mientras dure la relación contractual y, después, durante los plazos de prescripción legales y fiscales aplicables.</td>
78
  </tr>
 
108
  <li><strong>Hugging Face, Inc.</strong> (EE. UU.) — alojamiento de la aplicación.</li>
109
  <li><strong>Neon, Inc.</strong> — base de datos PostgreSQL, alojada en la región <strong>eu-central-1</strong> (Fráncfort, Unión Europea).</li>
110
  <li><strong>Brevo</strong> (Sendinblue SAS, Francia, UE) — envío de emails transaccionales.</li>
111
+ <li><strong>Stripe</strong> (Stripe Payments Europe, Ltd. y Stripe, Inc.) — pagos y facturación de los clientes directos (atendyo.com).</li>
112
+ <li><strong>Shopify</strong> (Shopify International Ltd.) — facturación a través de Shopify Billing únicamente cuando Atendyo se ha instalado desde la Shopify App Store (en ese caso no se utiliza Stripe).</li>
113
  <li><strong>Proveedores de inferencia de IA</strong> — generación de las respuestas del asistente: Groq, Cloudflare, Cerebras, SambaNova y OpenRouter (EE. UU.) y Mistral AI (Francia, UE). Usamos sus API con condiciones de <strong>no entrenamiento</strong>: los mensajes enviados por API no se utilizan para entrenar sus modelos.</li>
114
  <li><strong>Meta Platforms</strong> (WhatsApp Cloud API) — solo cuando el canal WhatsApp está activo, para recibir y enviar los mensajes de WhatsApp.</li>
115
  </ul>
app/main.py CHANGED
@@ -12,6 +12,7 @@ from app import db as dbmod
12
  from app.config import get_settings
13
  from app.routes import (
14
  admin,
 
15
  blog,
16
  chat,
17
  demo,
@@ -118,6 +119,7 @@ def create_app() -> FastAPI:
118
  app.include_router(blog.router)
119
  app.include_router(legal.router)
120
  app.include_router(demo.router)
 
121
  app.include_router(reviews.router)
122
  return app
123
 
 
12
  from app.config import get_settings
13
  from app.routes import (
14
  admin,
15
+ audit,
16
  blog,
17
  chat,
18
  demo,
 
119
  app.include_router(blog.router)
120
  app.include_router(legal.router)
121
  app.include_router(demo.router)
122
+ app.include_router(audit.router)
123
  app.include_router(reviews.router)
124
  return app
125
 
app/models.py CHANGED
@@ -439,3 +439,25 @@ class ChatMessage(Base):
439
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
440
 
441
  session: Mapped[ChatSession] = relationship(back_populates="messages")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
440
 
441
  session: Mapped[ChatSession] = relationship(back_populates="messages")
442
+
443
+
444
+ class AuditResult(Base):
445
+ """PERSISTENT, public, SEO-indexable result of the free "store support audit"
446
+ tool: for a crawled Shopify store, how many of the 12 universal high-intent
447
+ buyer questions its OWN site answers. Keyed by domain (one page per store,
448
+ re-audits update it) and SURVIVES the ephemeral demo tenant being purged at
449
+ 48h — that's the point: /audit/<domain> is a permanent, shareable, crawlable
450
+ page that seeds programmatic SEO and funnels to the bot that DOES answer them."""
451
+
452
+ __tablename__ = "audit_results"
453
+
454
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
455
+ domain: Mapped[str] = mapped_column(String(255), unique=True, index=True)
456
+ brand: Mapped[str] = mapped_column(String(255), default="")
457
+ answered: Mapped[int] = mapped_column(Integer, default=0)
458
+ total: Mapped[int] = mapped_column(Integer, default=12)
459
+ grade: Mapped[str] = mapped_column(String(2), default="") # A..F
460
+ questions: Mapped[list] = mapped_column(JSON, default=list) # [{q,status,score}]
461
+ lang: Mapped[str] = mapped_column(String(4), default="es")
462
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
463
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
app/portal_ui/index.html CHANGED
@@ -310,8 +310,10 @@
310
  <p class="muted" id="sub-sh-status">Tu plan se gestiona a través de Shopify: 14 días gratis y luego 29 $/mes (aparece en tu factura de Shopify). Para cambiar de plan o cancelar, hazlo desde tu admin de Shopify: Configuración → Aplicaciones y canales de venta → Atendyo.</p>
311
  <div class="row" id="sub-sh-cta" style="margin-top:12px;"><button onclick="startShopifyTrial('/shopify/billing/start')">Empezar prueba gratis</button></div>
312
  </div>
313
- <!-- Stripe (direct) tenants. -->
314
- <div id="sub-stripe">
 
 
315
  <div class="card">
316
  <strong>Mi suscripción</strong>
317
  <p class="muted">Cancela, cambia tu tarjeta o consulta tus facturas.</p>
@@ -762,6 +764,15 @@
762
  // show another store's data — auth comes only from the Shopify session token.
763
  try { localStorage.removeItem("ssb_portal_token"); } catch(e){}
764
  var lg = $("login"); if(lg) lg.classList.add("hidden");
 
 
 
 
 
 
 
 
 
765
  (function boot(tries){
766
  if(!(window.shopify && window.shopify.idToken)){
767
  if(tries>0) return setTimeout(function(){ boot(tries-1); }, 300);
 
310
  <p class="muted" id="sub-sh-status">Tu plan se gestiona a través de Shopify: 14 días gratis y luego 29 $/mes (aparece en tu factura de Shopify). Para cambiar de plan o cancelar, hazlo desde tu admin de Shopify: Configuración → Aplicaciones y canales de venta → Atendyo.</p>
311
  <div class="row" id="sub-sh-cta" style="margin-top:12px;"><button onclick="startShopifyTrial('/shopify/billing/start')">Empezar prueba gratis</button></div>
312
  </div>
313
+ <!-- Stripe (direct) tenants. Starts hidden so it FAILS CLOSED: a Shopify
314
+ App-Store install must never see off-platform Stripe billing. fillConfig()
315
+ relaxes it only for a proven non-oauth (direct) tenant. -->
316
+ <div id="sub-stripe" class="hidden">
317
  <div class="card">
318
  <strong>Mi suscripción</strong>
319
  <p class="muted">Cancela, cambia tu tarjeta o consulta tus facturas.</p>
 
764
  // show another store's data — auth comes only from the Shopify session token.
765
  try { localStorage.removeItem("ssb_portal_token"); } catch(e){}
766
  var lg = $("login"); if(lg) lg.classList.add("hidden");
767
+ // FAIL CLOSED for App-Store installs: default to the Shopify experience BEFORE
768
+ // config loads, so off-platform Stripe billing is never shown/linked even for a
769
+ // moment. Show the Shopify subscription pane; keep the Stripe pane and the
770
+ // manual (non-App-Store) Shopify/install panes hidden. fillConfig() later relaxes
771
+ // these ONLY if config proves this is a non-oauth (direct) tenant.
772
+ if($("sub-shopify")) $("sub-shopify").classList.remove("hidden");
773
+ if($("sub-stripe")) $("sub-stripe").classList.add("hidden");
774
+ if($("sh-manual")) $("sh-manual").classList.add("hidden");
775
+ if($("inst-manual")) $("inst-manual").classList.add("hidden");
776
  (function boot(tries){
777
  if(!(window.shopify && window.shopify.idToken)){
778
  if(tries>0) return setTimeout(function(){ boot(tries-1); }, 300);
app/routes/audit.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public free "store support audit" tool — the build-as-marketing wedge.
2
+
3
+ Paste any Shopify store URL -> we crawl it (reusing the demo machinery) and score
4
+ how many of the 12 universal buyer questions its OWN site answers, then render a
5
+ PERMANENT, shareable, SEO-indexable page at /audit/<domain> (survives the demo
6
+ tenant purge). Each page funnels to the bot that DOES answer them. No signup.
7
+
8
+ ALL endpoints live under /audit/* so a single Netlify 200-proxy serves the whole
9
+ tool under atendyo.com (clean canonical URLs for SEO). Route order matters:
10
+ the catch-all GET /audit/{domain} is registered LAST so /audit/start and
11
+ /audit/status are not swallowed as a "domain".
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
19
+ from fastapi.responses import FileResponse, HTMLResponse
20
+ from sqlalchemy.ext.asyncio import AsyncSession
21
+
22
+ from app import audit as audit_mod
23
+ from app.db import get_session
24
+ from app.routes.demo import DemoStartIn, demo_start, demo_status
25
+ from app.tenancy import get_tenant_by_slug
26
+
27
+ router = APIRouter()
28
+
29
+ _OG_FALLBACK = Path(__file__).resolve().parent.parent / "landing_ui" / "og.png"
30
+
31
+
32
+ def _lang(request: Request) -> str:
33
+ q = (request.query_params.get("lang") or "").lower()
34
+ if q in ("es", "en"):
35
+ return q
36
+ al = (request.headers.get("accept-language") or "").lower()
37
+ return "es" if al.startswith("es") else ("en" if al.startswith("en") else "es")
38
+
39
+
40
+ @router.get("/audit", response_class=HTMLResponse)
41
+ async def audit_landing(request: Request) -> HTMLResponse:
42
+ return HTMLResponse(audit_mod.render_audit_landing(_lang(request)))
43
+
44
+
45
+ @router.post("/audit/start")
46
+ async def audit_start(
47
+ body: DemoStartIn, request: Request, db: AsyncSession = Depends(get_session)
48
+ ) -> dict:
49
+ """Kick off the crawl (delegates to the demo machinery so we reuse its
50
+ rate-limits, SSRF guard, crawl + lead capture). Returns the slug."""
51
+ return await demo_start(body, request, db)
52
+
53
+
54
+ @router.get("/audit/status")
55
+ async def audit_status(d: str, db: AsyncSession = Depends(get_session)) -> dict:
56
+ """Live crawl progress (delegates to the demo status)."""
57
+ return await demo_status(d=d, db=db)
58
+
59
+
60
+ @router.post("/audit/{slug}/finalize")
61
+ async def audit_finalize(
62
+ slug: str, request: Request, db: AsyncSession = Depends(get_session)
63
+ ) -> dict:
64
+ """Score an already-crawled demo tenant and persist the AuditResult by domain.
65
+ Called by the landing once the crawl is ready; returns the domain to redirect to."""
66
+ tenant = await get_tenant_by_slug(db, slug)
67
+ if tenant is None or not tenant.is_demo:
68
+ raise HTTPException(status_code=404, detail="Auditoría no encontrada o caducada.")
69
+ row = await audit_mod.audit_demo_tenant(db, tenant, lang=_lang(request))
70
+ await db.commit()
71
+ return {
72
+ "domain": row.domain,
73
+ "answered": row.answered,
74
+ "total": row.total,
75
+ "grade": row.grade,
76
+ }
77
+
78
+
79
+ @router.get("/audit/{domain}/og.png")
80
+ async def audit_og(domain: str, db: AsyncSession = Depends(get_session)) -> Response:
81
+ row = await audit_mod.get_audit(db, domain)
82
+ if row is None:
83
+ return FileResponse(_OG_FALLBACK, media_type="image/png")
84
+ return Response(
85
+ content=audit_mod.og_image_bytes(row),
86
+ media_type="image/png",
87
+ headers={"Cache-Control": "public, max-age=3600"},
88
+ )
89
+
90
+
91
+ @router.get("/audit/{domain}", response_class=HTMLResponse)
92
+ async def audit_page(
93
+ domain: str, request: Request, db: AsyncSession = Depends(get_session)
94
+ ) -> HTMLResponse:
95
+ row = await audit_mod.get_audit(db, domain)
96
+ if row is None:
97
+ # Not audited yet -> serve the tool prefilled with this domain so the URL
98
+ # still works AND invites the visitor/crawler to run the audit.
99
+ return HTMLResponse(audit_mod.render_audit_landing(_lang(request)))
100
+ return HTMLResponse(audit_mod.render_audit_page(row))
app/routes/shopify_oauth.py CHANGED
@@ -15,6 +15,7 @@ import asyncio
15
  import hashlib
16
  import hmac
17
  import logging
 
18
  from datetime import UTC, datetime, timedelta
19
  from json import dumps as json_dumps
20
 
@@ -249,6 +250,39 @@ def _embed_csp(shop: str) -> dict[str, str]:
249
  return {"Content-Security-Policy": f"frame-ancestors https://{shop} https://admin.shopify.com;"}
250
 
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  @router.get("/shopify/app")
253
  async def shopify_embedded_app(
254
  request: Request, shop: str = "", settings: Settings = Depends(get_settings)
@@ -262,6 +296,11 @@ async def shopify_embedded_app(
262
  csp = _embed_csp(shop_n or "*.myshopify.com")
263
  from app.routes.portal import _PAGE
264
  html = _PAGE.read_text(encoding="utf-8")
 
 
 
 
 
265
  # App Bridge setup, in the order Shopify requires (and the App Store check looks
266
  # for): the api-key META tag FIRST, then the App Bridge CDN script, BEFORE any
267
  # other script. App Bridge reads the key from the meta tag and exposes
@@ -452,6 +491,7 @@ async def billing_return(
452
  if active:
453
  tenant.chatbot_enabled = True
454
  tenant.disabled_reason = ""
 
455
  _registry.invalidate(tenant.id)
456
  await db.commit()
457
  log.info("shopify billing active for tenant=%s", tenant.slug)
 
15
  import hashlib
16
  import hmac
17
  import logging
18
+ import re
19
  from datetime import UTC, datetime, timedelta
20
  from json import dumps as json_dumps
21
 
 
250
  return {"Content-Security-Policy": f"frame-ancestors https://{shop} https://admin.shopify.com;"}
251
 
252
 
253
+ def _strip_stripe(html: str) -> str:
254
+ """Shopify App Store rule 1.2.1: an App-Store (OAuth) install must NEVER be
255
+ exposed to off-platform Stripe billing. Server-side remove the entire
256
+ `#sub-stripe` block (its "manage subscription" + "change plan" cards, which
257
+ hold the buy.stripe.com link) from the portal HTML BEFORE serving it inside
258
+ the Shopify admin iframe — so the embedded panel can never contain a Stripe
259
+ node, even momentarily, regardless of client JS. Balanced-div aware (the
260
+ block nests <div class="card"> children) and idempotent. As a belt-and-braces
261
+ safety net, also neutralize any stray buy.stripe.com URL left in the markup.
262
+ """
263
+ open_re = re.compile(r'<div\b[^>]*\bid="sub-stripe"[^>]*>', re.IGNORECASE)
264
+ m = open_re.search(html)
265
+ if m:
266
+ # Walk forward from the opening tag, tracking <div>/</div> nesting, so we
267
+ # cut exactly the matching close of #sub-stripe (not the first inner one).
268
+ tag_re = re.compile(r'<div\b[^>]*>|</div>', re.IGNORECASE)
269
+ depth = 0
270
+ end = None
271
+ for t in tag_re.finditer(html, m.start()):
272
+ if t.group().lower().startswith("</div"):
273
+ depth -= 1
274
+ if depth == 0:
275
+ end = t.end()
276
+ break
277
+ else:
278
+ depth += 1
279
+ if end is not None:
280
+ html = html[: m.start()] + html[end:]
281
+ # Belt-and-braces: kill any remaining buy.stripe.com link target.
282
+ html = re.sub(r'https?://buy\.stripe\.com/[^\s"\'<>]*', "#", html, flags=re.IGNORECASE)
283
+ return html
284
+
285
+
286
  @router.get("/shopify/app")
287
  async def shopify_embedded_app(
288
  request: Request, shop: str = "", settings: Settings = Depends(get_settings)
 
296
  csp = _embed_csp(shop_n or "*.myshopify.com")
297
  from app.routes.portal import _PAGE
298
  html = _PAGE.read_text(encoding="utf-8")
299
+ # App Store rule 1.2.1: strip ALL off-platform Stripe billing from the panel
300
+ # served inside the Shopify admin iframe (App-Store installs are billed only
301
+ # via the Shopify Billing API). The iframe must never contain a Stripe node.
302
+ html = _strip_stripe(html)
303
+ assert "buy.stripe.com" not in html # noqa: S101 - guard the rule above
304
  # App Bridge setup, in the order Shopify requires (and the App Store check looks
305
  # for): the api-key META tag FIRST, then the App Bridge CDN script, BEFORE any
306
  # other script. App Bridge reads the key from the meta tag and exposes
 
491
  if active:
492
  tenant.chatbot_enabled = True
493
  tenant.disabled_reason = ""
494
+ tenant.trial_ends_at = None # converted -> never auto-paused as a trial
495
  _registry.invalidate(tenant.id)
496
  await db.commit()
497
  log.info("shopify billing active for tenant=%s", tenant.slug)
app/routes/shopify_webhooks.py CHANGED
@@ -127,6 +127,9 @@ async def app_subscriptions_update(
127
  if status == "ACTIVE":
128
  tenant.chatbot_enabled = True
129
  tenant.disabled_reason = ""
 
 
 
130
  changed = True
131
  elif status in TERMINAL:
132
  tenant.chatbot_enabled = False
 
127
  if status == "ACTIVE":
128
  tenant.chatbot_enabled = True
129
  tenant.disabled_reason = ""
130
+ # Shopify-billed -> never let the scheduler treat this as a Stripe
131
+ # trial (its predicate is trial_ends_at IS NOT NULL); mirror billing.py.
132
+ tenant.trial_ends_at = None
133
  changed = True
134
  elif status in TERMINAL:
135
  tenant.chatbot_enabled = False
app/routes/widget.py CHANGED
@@ -25,6 +25,18 @@ _WIDGET_JS = Path(__file__).resolve().parent.parent / "static" / "widget.js"
25
  _SLUG_RE = re.compile(r"[^a-z0-9-]")
26
 
27
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  @router.get("/widget.js")
29
  async def widget_js() -> FileResponse:
30
  return FileResponse(
@@ -146,7 +158,7 @@ _HOSTED_HTML = """<!doctype html>
146
  <p>{sub}</p>
147
  <button class="cta" id="hosted-open" type="button">{open_label}</button>
148
  </main>
149
- <footer>Con tecnologia de <a href="https://atendyo.com" target="_blank" rel="noopener">Atendyo</a></footer>
150
  <script src="/widget.js?t={slug}" defer></script>
151
  <script>
152
  // Open the assistant automatically (and via the button) — on this page the
@@ -203,11 +215,20 @@ async def hosted_chat(
203
  )
204
  brand = (tenant.brand_name or "Asistente").replace("<", "").replace(">", "")
205
  color = (tenant.brand_color or "#1c59e9").replace("<", "").replace(">", "")
 
 
 
 
 
 
 
 
206
  html = _HOSTED_HTML.format(
207
  brand=brand, color=color, slug=safe,
208
  hello="¿En qué te ayudo?",
209
  sub=f"Pregúntame lo que quieras sobre {brand}: productos, envíos, devoluciones y más.",
210
  open_label="Hablar con el asistente",
 
211
  )
212
  return HTMLResponse(html)
213
 
 
25
  _SLUG_RE = re.compile(r"[^a-z0-9-]")
26
 
27
 
28
+ def _is_shopify_install(tenant) -> bool:
29
+ """A merchant who installed via the Shopify App Store (OAuth) or has a linked
30
+ Shopify shop. Such tenants must NEVER be linked to off-platform Stripe billing
31
+ (App Store policy 1.2.1); direct/Stripe tenants are unaffected."""
32
+ if tenant is None:
33
+ return False
34
+ return bool(
35
+ getattr(tenant, "shopify_shop", "")
36
+ or getattr(tenant, "shopify_install_source", "") == "oauth"
37
+ )
38
+
39
+
40
  @router.get("/widget.js")
41
  async def widget_js() -> FileResponse:
42
  return FileResponse(
 
158
  <p>{sub}</p>
159
  <button class="cta" id="hosted-open" type="button">{open_label}</button>
160
  </main>
161
+ <footer>{footer}</footer>
162
  <script src="/widget.js?t={slug}" defer></script>
163
  <script>
164
  // Open the assistant automatically (and via the button) — on this page the
 
215
  )
216
  brand = (tenant.brand_name or "Asistente").replace("<", "").replace(">", "")
217
  color = (tenant.brand_color or "#1c59e9").replace("<", "").replace(">", "")
218
+ # Shopify-installed tenants must not be linked to the atendyo.com landing
219
+ # (which carries Stripe checkout buttons) — show plain text instead of a link.
220
+ # Direct/Stripe tenants keep the link exactly as before.
221
+ footer = (
222
+ "Con tecnologia de Atendyo"
223
+ if _is_shopify_install(tenant)
224
+ else 'Con tecnologia de <a href="https://atendyo.com" target="_blank" rel="noopener">Atendyo</a>'
225
+ )
226
  html = _HOSTED_HTML.format(
227
  brand=brand, color=color, slug=safe,
228
  hello="¿En qué te ayudo?",
229
  sub=f"Pregúntame lo que quieras sobre {brand}: productos, envíos, devoluciones y más.",
230
  open_label="Hablar con el asistente",
231
+ footer=footer,
232
  )
233
  return HTMLResponse(html)
234
 
app/scheduler.py CHANGED
@@ -233,7 +233,14 @@ async def _maybe_purge_demos() -> None:
233
  log.info("demo purge removed %d expired demo tenant(s)", n)
234
 
235
 
236
- async def _email_trial_ended(email: str, slug: str) -> None:
 
 
 
 
 
 
 
237
  body = (
238
  "Hola,\n\n"
239
  "Tu prueba gratis de Atendyo ha terminado y el bot se ha desactivado en tu "
@@ -265,6 +272,11 @@ async def pause_expired_trials(db: AsyncSession) -> int:
265
  Tenant.trial_ends_at < now,
266
  Tenant.chatbot_enabled.is_(True),
267
  Tenant.stripe_subscription_id == "",
 
 
 
 
 
268
  )
269
  )
270
  ).scalars().all()
@@ -273,7 +285,7 @@ async def pause_expired_trials(db: AsyncSession) -> int:
273
  tenant.chatbot_enabled = False
274
  tenant.disabled_reason = "trial"
275
  if tenant.support_email:
276
- await _email_trial_ended(tenant.support_email, tenant.slug)
277
  await db.commit()
278
  return len(expired)
279
 
 
233
  log.info("demo purge removed %d expired demo tenant(s)", n)
234
 
235
 
236
+ async def _email_trial_ended(email: str, slug: str, tenant: Tenant | None = None) -> None:
237
+ # Defense-in-depth: a Shopify App Store install is billed ONLY via the
238
+ # Shopify Billing API and must never receive the off-platform Stripe
239
+ # TRIAL_PAY_LINK. Never email the Stripe link to such a tenant.
240
+ if tenant is not None and (
241
+ tenant.shopify_shop or tenant.shopify_install_source == "oauth"
242
+ ):
243
+ return
244
  body = (
245
  "Hola,\n\n"
246
  "Tu prueba gratis de Atendyo ha terminado y el bot se ha desactivado en tu "
 
272
  Tenant.trial_ends_at < now,
273
  Tenant.chatbot_enabled.is_(True),
274
  Tenant.stripe_subscription_id == "",
275
+ # Shopify App Store installs are billed ONLY via the Shopify
276
+ # Billing API — they must never be paused into the Stripe
277
+ # trial-ended flow nor emailed the off-platform Stripe link.
278
+ Tenant.shopify_shop == "",
279
+ Tenant.shopify_install_source != "oauth",
280
  )
281
  )
282
  ).scalars().all()
 
285
  tenant.chatbot_enabled = False
286
  tenant.disabled_reason = "trial"
287
  if tenant.support_email:
288
+ await _email_trial_ended(tenant.support_email, tenant.slug, tenant)
289
  await db.commit()
290
  return len(expired)
291
 
migrations/versions/0031_audit_results.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persistent public audit results for the free "store support audit" tool.
2
+
3
+ One row per store domain (re-audits update it), survives the ephemeral demo
4
+ tenant purge so /audit/<domain> is a permanent, crawlable, shareable SEO page.
5
+
6
+ Additive, idempotent on Postgres. No-op on other dialects (tests/dev use
7
+ create_all, which already has the table).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from alembic import op
13
+
14
+ revision = "0031_audit_results"
15
+ down_revision = "0030_image_embeddings"
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
+ """
25
+ CREATE TABLE IF NOT EXISTS audit_results (
26
+ id SERIAL PRIMARY KEY,
27
+ domain VARCHAR(255) NOT NULL UNIQUE,
28
+ brand VARCHAR(255) DEFAULT '',
29
+ answered INTEGER DEFAULT 0,
30
+ total INTEGER DEFAULT 12,
31
+ grade VARCHAR(2) DEFAULT '',
32
+ questions JSONB DEFAULT '[]'::jsonb,
33
+ lang VARCHAR(4) DEFAULT 'es',
34
+ created_at TIMESTAMPTZ DEFAULT now(),
35
+ updated_at TIMESTAMPTZ DEFAULT now()
36
+ )
37
+ """
38
+ )
39
+ op.execute("CREATE INDEX IF NOT EXISTS ix_audit_results_domain ON audit_results (domain)")
40
+
41
+
42
+ def downgrade() -> None:
43
+ if op.get_bind().dialect.name != "postgresql":
44
+ return
45
+ op.execute("DROP TABLE IF EXISTS audit_results")
scripts/build_static_site.py CHANGED
@@ -64,6 +64,7 @@ def _sitemap() -> str:
64
  f" <url><loc>{SITE}/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url>",
65
  f" <url><loc>{SITE}/blog</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>", # noqa: E501
66
  f" <url><loc>{SITE}/partners</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>", # noqa: E501
 
67
  ]
68
  for p in POSTS:
69
  rows.append(
@@ -87,6 +88,12 @@ def _redirects() -> str:
87
  f"/demo {BACKEND}/demo 302\n"
88
  f"/c/* {BACKEND}/c/:splat 302\n"
89
  f"/r/* {BACKEND}/r/:splat 302\n"
 
 
 
 
 
 
90
  )
91
 
92
 
 
64
  f" <url><loc>{SITE}/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url>",
65
  f" <url><loc>{SITE}/blog</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>", # noqa: E501
66
  f" <url><loc>{SITE}/partners</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>", # noqa: E501
67
+ f" <url><loc>{SITE}/audit</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>", # noqa: E501
68
  ]
69
  for p in POSTS:
70
  rows.append(
 
88
  f"/demo {BACKEND}/demo 302\n"
89
  f"/c/* {BACKEND}/c/:splat 302\n"
90
  f"/r/* {BACKEND}/r/:splat 302\n"
91
+ # /audit/* is a 200 REWRITE (proxy), not a redirect: the URL stays on
92
+ # atendyo.com so the per-store audit pages are indexed under our own
93
+ # domain (the whole SEO point). The interactive tool's API lives under
94
+ # /audit/* too (start/status/finalize), so this one rule serves it all.
95
+ f"/audit {BACKEND}/audit 200\n"
96
+ f"/audit/* {BACKEND}/audit/:splat 200\n"
97
  )
98
 
99
 
tests/routes/test_audit.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from app import audit as A
4
+ from app.models import AuditResult, Tenant
5
+
6
+
7
+ def test_grade_for_boundaries():
8
+ assert A.grade_for(12, 12) == "A"
9
+ assert A.grade_for(9, 12) == "B" # 0.75
10
+ assert A.grade_for(8, 12) == "C" # 0.667
11
+ assert A.grade_for(5, 12) == "D" # 0.417
12
+ assert A.grade_for(4, 12) == "F" # 0.333
13
+ assert A.grade_for(0, 0) == "F"
14
+
15
+
16
+ async def test_save_get_audit_upserts_by_domain(db_session):
17
+ rep = {
18
+ "brand": "MiTienda", "answered": 7, "total": 12,
19
+ "questions": [
20
+ {"q": "¿Envíos a Canarias?", "status": "miss"},
21
+ {"q": "¿Devoluciones?", "status": "hit"},
22
+ ],
23
+ }
24
+ r1 = await A.save_audit(db_session, "MiTienda.com", rep, lang="es")
25
+ assert r1.answered == 7 and r1.grade == "D" and r1.domain == "mitienda.com" # 7/12=58%->D
26
+ # re-audit (normalized https/www/slash variant) updates the SAME row
27
+ r2 = await A.save_audit(db_session, "https://www.mitienda.com/", dict(rep, answered=3))
28
+ assert r2.id == r1.id
29
+ got = await A.get_audit(db_session, "mitienda.com")
30
+ assert got.id == r1.id and got.answered == 3 and got.grade == "F"
31
+
32
+
33
+ def test_render_audit_page_has_score_gaps_and_og():
34
+ row = AuditResult(
35
+ domain="mitienda.com", brand="MiTienda", answered=6, total=12, grade="D",
36
+ questions=[
37
+ {"q": "¿Envíos a Canarias?", "status": "miss"},
38
+ {"q": "¿Devoluciones?", "status": "hit"},
39
+ ], lang="es",
40
+ )
41
+ h = A.render_audit_page(row)
42
+ assert "MiTienda" in h and "6/12" in h
43
+ assert 'property="og:image"' in h and "/audit/mitienda.com/og.png" in h
44
+ assert "¿Envíos a Canarias?" in h # the real gap is shown
45
+
46
+
47
+ def test_og_image_is_valid_png():
48
+ row = AuditResult(domain="x.com", brand="X", answered=5, total=12, grade="D",
49
+ questions=[], lang="es")
50
+ b = A.og_image_bytes(row)
51
+ assert b[:8] == b"\x89PNG\r\n\x1a\n"
52
+
53
+
54
+ async def test_audit_routes_landing_page_and_og(app_client, db_session):
55
+ _app, client = app_client
56
+ r = await client.get("/audit")
57
+ assert r.status_code == 200 and "Atendyo" in r.text
58
+
59
+ await A.save_audit(db_session, "seedstore.com", {
60
+ "brand": "SeedStore", "answered": 4, "total": 12,
61
+ "questions": [{"q": "¿Devoluciones?", "status": "miss"}],
62
+ })
63
+ await db_session.commit()
64
+
65
+ rp = await client.get("/audit/seedstore.com")
66
+ assert rp.status_code == 200 and "SeedStore" in rp.text and "4/12" in rp.text
67
+ ro = await client.get("/audit/seedstore.com/og.png")
68
+ assert ro.status_code == 200 and ro.content[:8] == b"\x89PNG\r\n\x1a\n"
69
+ # an un-audited domain serves the tool (200), never a 500
70
+ ru = await client.get("/audit/never-audited-xyz.com")
71
+ assert ru.status_code == 200
72
+
73
+
74
+ async def test_finalize_scores_and_persists(app_client, db_session, monkeypatch):
75
+ _app, client = app_client
76
+ t = Tenant(slug="demo-aud1", name="Demo finalstore.com",
77
+ brand_name="finalstore.com", is_demo=True)
78
+ db_session.add(t)
79
+ await db_session.commit()
80
+
81
+ async def fake_gap(db, tenant, lang="es"):
82
+ return {
83
+ "brand": tenant.brand_name, "total": 12, "answered": 8,
84
+ "questions": [{"q": "x", "status": "hit"}], "gaps": [],
85
+ }
86
+
87
+ monkeypatch.setattr(A, "generate_gap_report", fake_gap)
88
+ r = await client.post("/audit/demo-aud1/finalize")
89
+ assert r.status_code == 200
90
+ d = r.json()
91
+ assert d["domain"] == "finalstore.com" and d["answered"] == 8 and d["grade"] == "C"
92
+ got = await A.get_audit(db_session, "finalstore.com")
93
+ assert got is not None and got.answered == 8
94
+
95
+
96
+ async def test_finalize_404_on_non_demo(app_client, db_session):
97
+ _app, client = app_client
98
+ t = Tenant(slug="real1", name="Real", brand_name="real.com") # NOT is_demo
99
+ db_session.add(t)
100
+ await db_session.commit()
101
+ r = await client.post("/audit/real1/finalize")
102
+ assert r.status_code == 404
tests/routes/test_shopify_billing_strip.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression: the embedded Shopify panel (the /shopify/app iframe) must NEVER
2
+ contain an off-platform Stripe billing node (App Store policy 1.2.1)."""
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from app.routes.shopify_oauth import _strip_stripe
8
+
9
+
10
+ def test_strip_stripe_removes_block_and_link():
11
+ html = (
12
+ '<div id="sub-shopify" class="hidden"><div class="card">Shopify 29/mo</div></div>'
13
+ '<div id="sub-stripe"><div class="card">manage</div>'
14
+ '<div class="card"><a href="https://buy.stripe.com/eVq5kC5h6cQS9qa5ar5wI04">29/mo</a></div>'
15
+ '</div><footer>keep me</footer>'
16
+ )
17
+ out = _strip_stripe(html)
18
+ assert "buy.stripe.com" not in out
19
+ assert 'id="sub-stripe"' not in out
20
+ assert 'id="sub-shopify"' in out # Shopify Billing pane kept
21
+ assert "<footer>keep me</footer>" in out # nothing after the block lost
22
+ assert _strip_stripe(out) == out # idempotent
23
+
24
+
25
+ def test_real_portal_html_strips_clean():
26
+ """The actual portal HTML served inside the iframe must be Stripe-free."""
27
+ page = Path(__file__).resolve().parents[2] / "app" / "portal_ui" / "index.html"
28
+ stripped = _strip_stripe(page.read_text(encoding="utf-8"))
29
+ assert "buy.stripe.com" not in stripped
30
+ assert 'id="sub-stripe"' not in stripped
tests/test_scheduler_trial.py CHANGED
@@ -61,5 +61,43 @@ async def test_pause_is_idempotent(db_session, monkeypatch):
61
  assert await pause_expired_trials(db_session) == 0
62
 
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  async def _noop():
65
  return True
 
61
  assert await pause_expired_trials(db_session) == 0
62
 
63
 
64
+ async def test_pause_excludes_shopify_installs(db_session, monkeypatch):
65
+ """Shopify App Store installs are billed via the Shopify Billing API, never
66
+ Stripe — the trial sweep must NEVER pause them or email the Stripe pay link
67
+ (App Store policy 1.2.1). Direct/Stripe trials keep being swept as before."""
68
+ from app import mailer
69
+ sent = []
70
+
71
+ async def fake(settings, to, subject, body, **k):
72
+ sent.append(to)
73
+ return True
74
+
75
+ monkeypatch.setattr(mailer, "send_email", fake)
76
+ past = datetime.now(UTC) - timedelta(hours=1)
77
+ db_session.add_all([
78
+ Tenant(slug="t-sh-shop", trial_ends_at=past, chatbot_enabled=True,
79
+ support_email="s1@x.com", shopify_shop="s1.myshopify.com",
80
+ shopify_install_source="oauth"),
81
+ Tenant(slug="t-sh-src", trial_ends_at=past, chatbot_enabled=True,
82
+ support_email="s2@x.com", shopify_install_source="oauth"),
83
+ Tenant(slug="t-direct", trial_ends_at=past, chatbot_enabled=True,
84
+ support_email="d@x.com"), # direct/Stripe -> still swept
85
+ ])
86
+ await db_session.commit()
87
+
88
+ assert await pause_expired_trials(db_session) == 1 # only the direct one
89
+
90
+ db_session.expire_all()
91
+
92
+ async def _get(slug):
93
+ return (await db_session.execute(select(Tenant).where(Tenant.slug == slug))).scalar_one()
94
+
95
+ assert (await _get("t-sh-shop")).chatbot_enabled is True # Shopify -> untouched
96
+ assert (await _get("t-sh-src")).chatbot_enabled is True
97
+ assert (await _get("t-direct")).chatbot_enabled is False # direct -> paused
98
+ assert "s1@x.com" not in sent and "s2@x.com" not in sent # no Stripe email to Shopify
99
+ assert "d@x.com" in sent # direct still emailed
100
+
101
+
102
  async def _noop():
103
  return True