feat(audit): free public store-support audit tool + programmatic-SEO pages (/audit)
Browse files- _shopify-review/FIX-PLAN.md +134 -0
- _shopify-review/TESTING-INSTRUCTIONS.txt +42 -0
- app/audit.py +352 -0
- app/legal_ui/condiciones.html +4 -1
- app/legal_ui/cookies.html +2 -1
- app/legal_ui/privacidad.html +3 -2
- app/main.py +2 -0
- app/models.py +22 -0
- app/portal_ui/index.html +13 -2
- app/routes/audit.py +100 -0
- app/routes/shopify_oauth.py +40 -0
- app/routes/shopify_webhooks.py +3 -0
- app/routes/widget.py +22 -1
- app/scheduler.py +14 -2
- migrations/versions/0031_audit_results.py +45 -0
- scripts/build_static_site.py +7 -0
- tests/routes/test_audit.py +102 -0
- tests/routes/test_shopify_billing_strip.py +30 -0
- tests/test_scheduler_trial.py +38 -0
_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>
|
|
|
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
| 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>
|
| 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
|