victor34593993 commited on
Commit
ca6f60d
·
verified ·
1 Parent(s): 9d1e6c7

feat: public Shopify App (OAuth+webhooks+billing) + SEO + partners program

Browse files
app/blog/posts.py CHANGED
@@ -810,6 +810,110 @@ POSTS: list[Post] = [
810
  """<p>If you are looking for a Crisp alternative, you probably already have it and notice the same thing many small stores do: you pay for a huge suite and, in the end, you only use the chat. Crisp is a versatile, well-made tool; that is not in doubt. But for a one-person store, all those features can turn into a toolbox left half-assembled. Let us look at it honestly and without making up numbers, and then I will tell you where Atendyo fits and what you give up by switching.</p><h2>Why people look for a Crisp alternative</h2><p>The reasons that lead to looking for something different are usually these:</p><ul><li><strong>Too many features for what you need.</strong> Crisp brings a knowledge base, CRM, campaigns, bot flows, a team inbox. If you only want the AI to answer the repetitive stuff, you pay for modules you never open.</li><li><strong>It has to be set up.</strong> A broad suite pays off when someone configures it: writing the knowledge base, segmenting the CRM, preparing campaigns, defining flows. Whoever is flat out with their store rarely has that time, and the tool ends up half-done.</li><li><strong>Price grows with modules and seats.</strong> Its plans start affordable, but the real cost depends on what you switch on and how many people use it. Check their website for the exact price and limits, which change.</li></ul><p>If you really will use the CRM, the campaigns and the knowledge base, Crisp can be worth it. If not, read on.</p><h2>Atendyo as an alternative for small stores</h2><p>Atendyo goes the opposite way from a suite: instead of giving you many pieces to assemble, it does a few things well. It is an AI assistant focused on serving and selling, using your own store's information. What it offers:</p><ul><li><strong>Flat price of 29 EUR a month</strong>, no setup fee and no surprises from modules or seats. You have the details on the <a href="https://atendyo.com/#precio">pricing page</a>.</li><li><strong>AI trained on your store</strong> (your website, your PDFs, your catalog) that answers on its own from day one, without you building flows or configuring trees.</li><li><strong>Web and WhatsApp</strong> with the same assistant and the same information.</li><li><strong>50+ languages</strong> with automatic detection.</li><li><strong>Shopify catalog</strong> with live photo, price and stock inside the chat.</li><li><strong>Instant demo:</strong> paste your website and within a minute you chat with an assistant that already knows it.</li><li><strong>GDPR and EU-native.</strong></li></ul><h2>Be honest: what you give up switching from Crisp to Atendyo</h2><p>Moving from a suite to a focused tool has a cost, and it would be dishonest to hide it. If you come from Crisp, here is what you leave behind:</p><ul><li><strong>Public knowledge base.</strong> Crisp lets you build a help center with articles. Atendyo does not publish that center; it answers in the conversation with your information.</li><li><strong>CRM and campaigns.</strong> Crisp's segments, lightweight CRM and email or in-app campaigns are not Atendyo's terrain.</li><li><strong>Inbox for several agents and many channels.</strong> If you need a team working at once in a shared inbox across many channels, Crisp gives you more for that.</li></ul><p>Put plainly: if you are genuinely going to use the CRM, the campaigns and the knowledge base, do not switch expecting to find them in Atendyo. If you only use the chat and want the AI to answer on its own without half a day of setup, Atendyo fits better.</p><h2>The honest way to decide: try it with your store</h2><p>The key is being honest with yourself about how much of a suite you will actually use. Many small stores pay for everything and only touch the chat. To see whether the simple thing is enough, <a href="https://atendyo.com/demo">open the Atendyo demo with your own store</a>: paste your website address and within a minute you check whether it answers as you expect, no sign-up. If you then find you need all the extra pieces of a suite, then Crisp makes sense.</p>"""
811
  ),
812
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
813
  ]
814
 
815
 
 
810
  """<p>If you are looking for a Crisp alternative, you probably already have it and notice the same thing many small stores do: you pay for a huge suite and, in the end, you only use the chat. Crisp is a versatile, well-made tool; that is not in doubt. But for a one-person store, all those features can turn into a toolbox left half-assembled. Let us look at it honestly and without making up numbers, and then I will tell you where Atendyo fits and what you give up by switching.</p><h2>Why people look for a Crisp alternative</h2><p>The reasons that lead to looking for something different are usually these:</p><ul><li><strong>Too many features for what you need.</strong> Crisp brings a knowledge base, CRM, campaigns, bot flows, a team inbox. If you only want the AI to answer the repetitive stuff, you pay for modules you never open.</li><li><strong>It has to be set up.</strong> A broad suite pays off when someone configures it: writing the knowledge base, segmenting the CRM, preparing campaigns, defining flows. Whoever is flat out with their store rarely has that time, and the tool ends up half-done.</li><li><strong>Price grows with modules and seats.</strong> Its plans start affordable, but the real cost depends on what you switch on and how many people use it. Check their website for the exact price and limits, which change.</li></ul><p>If you really will use the CRM, the campaigns and the knowledge base, Crisp can be worth it. If not, read on.</p><h2>Atendyo as an alternative for small stores</h2><p>Atendyo goes the opposite way from a suite: instead of giving you many pieces to assemble, it does a few things well. It is an AI assistant focused on serving and selling, using your own store's information. What it offers:</p><ul><li><strong>Flat price of 29 EUR a month</strong>, no setup fee and no surprises from modules or seats. You have the details on the <a href="https://atendyo.com/#precio">pricing page</a>.</li><li><strong>AI trained on your store</strong> (your website, your PDFs, your catalog) that answers on its own from day one, without you building flows or configuring trees.</li><li><strong>Web and WhatsApp</strong> with the same assistant and the same information.</li><li><strong>50+ languages</strong> with automatic detection.</li><li><strong>Shopify catalog</strong> with live photo, price and stock inside the chat.</li><li><strong>Instant demo:</strong> paste your website and within a minute you chat with an assistant that already knows it.</li><li><strong>GDPR and EU-native.</strong></li></ul><h2>Be honest: what you give up switching from Crisp to Atendyo</h2><p>Moving from a suite to a focused tool has a cost, and it would be dishonest to hide it. If you come from Crisp, here is what you leave behind:</p><ul><li><strong>Public knowledge base.</strong> Crisp lets you build a help center with articles. Atendyo does not publish that center; it answers in the conversation with your information.</li><li><strong>CRM and campaigns.</strong> Crisp's segments, lightweight CRM and email or in-app campaigns are not Atendyo's terrain.</li><li><strong>Inbox for several agents and many channels.</strong> If you need a team working at once in a shared inbox across many channels, Crisp gives you more for that.</li></ul><p>Put plainly: if you are genuinely going to use the CRM, the campaigns and the knowledge base, do not switch expecting to find them in Atendyo. If you only use the chat and want the AI to answer on its own without half a day of setup, Atendyo fits better.</p><h2>The honest way to decide: try it with your store</h2><p>The key is being honest with yourself about how much of a suite you will actually use. Many small stores pay for everything and only touch the chat. To see whether the simple thing is enough, <a href="https://atendyo.com/demo">open the Atendyo demo with your own store</a>: paste your website address and within a minute you check whether it answers as you expect, no sign-up. If you then find you need all the extra pieces of a suite, then Crisp makes sense.</p>"""
811
  ),
812
  ),
813
+ Post(
814
+ slug="chatbot-para-shopify-en-espanol",
815
+ lang="es",
816
+ pair_slug="ai-chatbot-for-shopify",
817
+ title="Chatbot para Shopify en español: cómo poner uno que atiende y vende",
818
+ meta_description=(
819
+ "Pon un chatbot de IA en tu Shopify en español: se entrena con tu tienda, "
820
+ "responde envíos, stock y pedidos, web y WhatsApp, 29 EUR/mes."
821
+ ),
822
+ keywords=[
823
+ "chatbot para shopify",
824
+ "chatbot shopify español",
825
+ "chatbot ia shopify",
826
+ "asistente shopify",
827
+ "atencion al cliente shopify",
828
+ ],
829
+ date="2026-06-17",
830
+ excerpt=(
831
+ "Si tienes una tienda Shopify y quieres un chatbot de IA en español que "
832
+ "responda de verdad con la información de tu tienda, aquí tienes cómo "
833
+ "ponerlo, qué hace y qué no hace."
834
+ ),
835
+ body_html=(
836
+ """<p>La mayoría de los chatbots para Shopify están pensados en inglés y para tiendas grandes con un equipo de soporte detrás. Si tu tienda es pequeña y atiendes en español, eso se nota: la IA traduce regular, te obliga a montar flujos y, al final, no responde sola. Vamos a ver cómo poner un chatbot que SÍ funciona en español con tu Shopify, sin inventarnos nada y diciendo también qué cede.</p><h2>Por qué "en español" importa más de lo que parece</h2><p>Un cliente que pregunta "¿hacéis envíos a Canarias y cuánto tarda?" espera una respuesta natural, no una traducción rara. Un asistente pensado en español (y que detecta el idioma del cliente, sea catalán, gallego o cualquier otro) responde como lo haría una persona de tu tienda. Esa naturalidad es la diferencia entre que el cliente confíe y compre, o que se vaya.</p><h2>Cómo poner el chatbot en tu Shopify</h2><p>Con Atendyo hay dos caminos, los dos sencillos:</p><ul><li><strong>Pegando una línea de código</strong> en tu tema (antes de la etiqueta de cierre del body). El asistente aparece como una burbuja de chat en tu tienda.</li><li><strong>Conectando tu tienda Shopify</strong> para que además lea tu catálogo en vivo (foto, precio y stock reales) y el estado de los pedidos. Se conecta concediendo unos permisos de lectura; no toca nada de tu tienda.</li></ul><p>No tienes que programar ni montar árboles de decisión: la IA se entrena con tu propia web (productos, envíos, devoluciones) y responde sola desde el primer día.</p><h2>Qué responde dentro de tu Shopify</h2><ul><li><strong>Productos, precios y stock</strong> en vivo, mostrados como tarjetas con foto y botón de comprar.</li><li><strong>Estado de pedidos y seguimiento</strong> (pidiendo email + un dato para verificar).</li><li><strong>Zonas y tarifas de envío</strong> reales configuradas en tu Shopify (sí, incluye responder a Canarias).</li><li><strong>Envíos, devoluciones, tallas, compatibilidades</strong> y las dudas repetidas de siempre.</li><li>Y si no sabe algo, lo dice con sinceridad y recoge el contacto para que respondas tú.</li></ul><h2>Web y WhatsApp, el mismo asistente</h2><p>El mismo bot atiende en tu web y en tu WhatsApp con la misma información. Para una tienda pequeña eso es tener soporte 24/7 sin contratar a nadie.</p><h2>Seamos honestos: qué NO es</h2><p>Atendyo no es un helpdesk para un equipo grande con bandeja compartida y muchos agentes, ni un CRM, ni una herramienta de campañas. Hace pocas cosas y las hace bien: atender y vender con la info de tu tienda. Si necesitas todo lo de una suite grande, esto se te quedará corto.</p><h2>Pruébalo con tu propia tienda antes de instalar</h2><p>La mejor forma de decidir es verlo con tu tienda: <a href="https://atendyo.com/demo">abre la demo de Atendyo</a>, pega la dirección de tu web y en un minuto chateas con un asistente que ya la conoce, sin registrarte. Si te gusta cómo responde, lo activas 14 días gratis sin tarjeta. Tienes el precio (29 EUR/mes plano) en la <a href="https://atendyo.com/#precio">página de precios</a>.</p>"""
837
+ ),
838
+ ),
839
+ Post(
840
+ slug="ai-chatbot-for-shopify",
841
+ lang="en",
842
+ pair_slug="chatbot-para-shopify-en-espanol",
843
+ title="AI chatbot for Shopify: how to add one that answers and sells",
844
+ meta_description=(
845
+ "Add an AI chatbot to your Shopify: trained on your store, answers "
846
+ "shipping, stock and orders, web and WhatsApp, 29 EUR/mo. Easy install."
847
+ ),
848
+ keywords=[
849
+ "ai chatbot for shopify",
850
+ "shopify chatbot",
851
+ "shopify ai assistant",
852
+ "shopify customer support",
853
+ "chatbot shopify",
854
+ ],
855
+ date="2026-06-17",
856
+ excerpt=(
857
+ "If you run a Shopify store and want an AI chatbot that actually answers "
858
+ "with your store's own information, here is how to add one, what it does "
859
+ "and what it does not."
860
+ ),
861
+ body_html=(
862
+ """<p>Most Shopify chatbots are built for large stores with a support team behind them, and they make you build flows before they answer anything. If your store is small, you want the opposite: an assistant that answers on its own with your store's information, in your customer's language. Let us look at how to add one to Shopify, what it does and — honestly — what it does not.</p><h2>How to add the chatbot to your Shopify</h2><p>With Atendyo there are two simple paths:</p><ul><li><strong>Paste one line of code</strong> into your theme (just before the closing body tag). The assistant shows up as a chat bubble on your store.</li><li><strong>Connect your Shopify store</strong> so it also reads your live catalog (real photo, price and stock) and order status. You connect it by granting a few read-only permissions; it never changes anything in your store.</li></ul><p>No coding and no decision trees: the AI is trained on your own website (products, shipping, returns) and answers on its own from day one.</p><h2>What it answers inside Shopify</h2><ul><li><strong>Products, prices and stock</strong> live, shown as cards with a photo and a buy button.</li><li><strong>Order status and tracking</strong> (asking for email plus one detail to verify).</li><li><strong>Shipping zones and rates</strong> configured in your Shopify.</li><li><strong>Shipping, returns, sizes, compatibility</strong> and the repetitive questions.</li><li>And when it does not know something, it says so honestly and captures the contact so you reply.</li></ul><h2>Web and WhatsApp, one assistant</h2><p>The same bot serves your website and your WhatsApp with the same information — 24/7 support without hiring anyone.</p><h2>Being honest: what it is not</h2><p>Atendyo is not a helpdesk for a big team with a shared inbox and many agents, nor a CRM, nor a campaigns tool. It does a few things well: serving and selling with your store's information. If you need a full suite, this will fall short.</p><h2>Try it with your own store first</h2><p><a href="https://atendyo.com/demo">Open the Atendyo demo</a>, paste your website and within a minute you chat with an assistant that already knows it, no sign-up. If you like how it answers, activate a 14-day free trial with no card. Pricing (flat 29 EUR/mo) is on the <a href="https://atendyo.com/#precio">pricing page</a>.</p>"""
863
+ ),
864
+ ),
865
+ Post(
866
+ slug="alternativa-a-intercom-tienda-pequena",
867
+ lang="es",
868
+ pair_slug="intercom-alternative-small-store",
869
+ title="Alternativa a Intercom para tiendas pequeñas: Atendyo, en español y sin complicarte",
870
+ meta_description=(
871
+ "¿Intercom te queda grande y caro para tu tienda? Atendyo: IA entrenada con "
872
+ "tu web, español nativo, web y WhatsApp, 29 EUR/mes plano. Comparativa honesta."
873
+ ),
874
+ keywords=[
875
+ "alternativa a intercom",
876
+ "intercom alternativa español",
877
+ "intercom fin precio",
878
+ "alternativa intercom tienda pequena",
879
+ "chatbot atencion al cliente ecommerce",
880
+ ],
881
+ date="2026-06-17",
882
+ excerpt=(
883
+ "Si miras Intercom para tu tienda pequeña y te parece grande y caro, esta "
884
+ "es una comparativa honesta: qué ganas con una alternativa más simple en "
885
+ "español y qué cedes."
886
+ ),
887
+ body_html=(
888
+ """<p>Si has llegado buscando una alternativa a Intercom, seguramente lo has mirado y has visto dos cosas: es potentísimo, y está pensado (y con un precio pensado) para empresas con equipo de soporte. Para una tienda pequeña eso se traduce en pagar mucho por funciones que no vas a tocar. Vamos a ser honestos con ambas partes, sin inventarme cifras, y luego te cuento dónde encaja Atendyo y qué cedes si cambias.</p><h2>Por qué la gente busca una alternativa a Intercom</h2><ul><li><strong>El precio escala rápido.</strong> Intercom cobra por asiento y, su IA (Fin), por resolución/conversación resuelta. Para un volumen alto está bien; para una tienda pequeña, el coste se dispara y es difícil de prever. Mira su web para el precio exacto, que cambia.</li><li><strong>Es una plataforma enorme.</strong> Bandeja para equipos, CRM, campañas, product tours, flujos. Si solo quieres que la IA responda lo repetitivo, pagas por un montón de cosas que no abres.</li><li><strong>Hay que montarlo y está en inglés.</strong> Sacarle partido pide configurarlo, y el producto y muchos recursos están pensados en inglés. Para atender en español a una tienda pequeña, es desproporcionado.</li></ul><p>Si de verdad vas a usar la bandeja de equipo, el CRM y las campañas, Intercom es de lo mejor que hay. Si no, sigue leyendo.</p><h2>Atendyo como alternativa para tiendas pequeñas</h2><p>Atendyo va al revés que una plataforma enorme: hace pocas cosas y bien. Es un asistente de IA centrado en atender y vender con la información de tu propia tienda:</p><ul><li><strong>Precio plano de 29 EUR al mes</strong>, sin coste por resolución ni por asiento. Lo tienes en la <a href="https://atendyo.com/#precio">página de precios</a>.</li><li><strong>IA entrenada con tu tienda</strong> (tu web, tus PDFs, tu catálogo) que responde sola desde el primer día, sin montar flujos.</li><li><strong>Español nativo</strong> y detección automática de más de 50 idiomas.</li><li><strong>Web y WhatsApp</strong> con el mismo asistente y la misma información.</li><li><strong>Catálogo Shopify</strong> con foto, precio y stock en vivo dentro del chat.</li><li><strong>Demo instantánea</strong> y nativo de la UE (RGPD).</li></ul><h2>Seamos honestos: qué cedes al cambiar de Intercom a Atendyo</h2><ul><li><strong>Bandeja para equipos grandes.</strong> Si tienes varios agentes trabajando a la vez en una bandeja compartida con SLAs, Intercom te da mucho más para eso.</li><li><strong>CRM, campañas y product tours.</strong> No son el terreno de Atendyo.</li><li><strong>Ecosistema e integraciones enterprise.</strong> Intercom tiene un catálogo de integraciones enorme; Atendyo se centra en tienda online (Shopify) y WhatsApp.</li></ul><p>Dicho claro: si vas a usar de verdad la bandeja de equipo, el CRM y las campañas, no cambies esperando encontrarlas en Atendyo. Si solo quieres que la IA atienda sola, en español, sin medio día de configuración y a precio fijo, Atendyo encaja mejor.</p><h2>La forma honesta de decidir: pruébalo con tu tienda</h2><p><a href="https://atendyo.com/demo">Abre la demo de Atendyo con tu propia tienda</a>: pega la dirección de tu web y en un minuto compruebas si responde como esperas, sin registrarte. Si luego ves que necesitas todo lo de una plataforma como Intercom, entonces Intercom tiene sentido.</p>"""
889
+ ),
890
+ ),
891
+ Post(
892
+ slug="intercom-alternative-small-store",
893
+ lang="en",
894
+ pair_slug="alternativa-a-intercom-tienda-pequena",
895
+ title="Intercom alternative for small stores: Atendyo, simpler and flat-priced",
896
+ meta_description=(
897
+ "Is Intercom too big and pricey for your store? Atendyo: AI trained on your "
898
+ "site, web and WhatsApp, flat 29 EUR/mo. An honest comparison."
899
+ ),
900
+ keywords=[
901
+ "intercom alternative",
902
+ "intercom alternative small business",
903
+ "intercom fin pricing",
904
+ "cheaper than intercom",
905
+ "ecommerce customer support chatbot",
906
+ ],
907
+ date="2026-06-17",
908
+ excerpt=(
909
+ "If you looked at Intercom for your small store and it feels big and "
910
+ "expensive, here is an honest comparison: what you gain with a simpler "
911
+ "alternative and what you give up."
912
+ ),
913
+ body_html=(
914
+ """<p>If you are looking for an Intercom alternative, you have probably seen two things: it is extremely powerful, and it is built (and priced) for companies with a support team. For a small store that means paying a lot for features you will never touch. Let us be honest about both sides, without making up numbers, and then I will tell you where Atendyo fits and what you give up by switching.</p><h2>Why people look for an Intercom alternative</h2><ul><li><strong>The price scales fast.</strong> Intercom charges per seat, and its AI (Fin) charges per resolution. Great at high volume; for a small store the cost climbs and is hard to predict. Check their website for the exact, changing price.</li><li><strong>It is a huge platform.</strong> Team inbox, CRM, campaigns, product tours, flows. If you only want the AI to answer the repetitive stuff, you pay for a lot you never open.</li><li><strong>It has to be set up.</strong> Getting value out of it takes configuration, and the product is English-first. For serving a small store, it is overkill.</li></ul><p>If you will genuinely use the team inbox, the CRM and the campaigns, Intercom is among the best there is. If not, read on.</p><h2>Atendyo as an alternative for small stores</h2><ul><li><strong>Flat 29 EUR a month</strong>, no per-resolution or per-seat cost. See the <a href="https://atendyo.com/#precio">pricing page</a>.</li><li><strong>AI trained on your store</strong> that answers on its own from day one, no flows to build.</li><li><strong>50+ languages</strong> with automatic detection.</li><li><strong>Web and WhatsApp</strong> with the same assistant.</li><li><strong>Shopify catalog</strong> with live photo, price and stock in the chat.</li><li><strong>Instant demo</strong> and EU-native (GDPR).</li></ul><h2>Being honest: what you give up switching from Intercom to Atendyo</h2><ul><li><strong>Team inbox at scale</strong> with SLAs and many agents.</li><li><strong>CRM, campaigns and product tours.</strong></li><li><strong>Enterprise integrations ecosystem.</strong> Atendyo focuses on online stores (Shopify) and WhatsApp.</li></ul><p>Plainly: if you will really use the team inbox, the CRM and the campaigns, do not switch expecting to find them in Atendyo. If you just want the AI to answer on its own, with no half-day setup and a flat price, Atendyo fits better.</p><h2>The honest way to decide: try it with your store</h2><p><a href="https://atendyo.com/demo">Open the Atendyo demo with your own store</a>: paste your website and within a minute you check whether it answers as you expect, no sign-up. If you then find you need everything a platform like Intercom offers, then Intercom makes sense.</p>"""
915
+ ),
916
+ ),
917
  ]
918
 
919
 
app/config.py CHANGED
@@ -69,6 +69,28 @@ class Settings(BaseSettings):
69
  shopify_api_version: str = "2026-01"
70
  shopify_app_proxy_secret: str = ""
71
  shopify_read_all_orders: bool = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
  # Admin
74
  admin_token: str = ""
 
69
  shopify_api_version: str = "2026-01"
70
  shopify_app_proxy_secret: str = ""
71
  shopify_read_all_orders: bool = False
72
+ # PUBLIC Shopify App (App Store) — app-level OAuth credentials (NOT per-tenant).
73
+ # When shopify_app_key is set, the 1-click OAuth install flow + webhooks are
74
+ # live; otherwise those routes 503 and only the custom-app path is used.
75
+ shopify_app_key: str = ""
76
+ shopify_app_secret: str = ""
77
+ # Scopes requested at install. read_shipping lets the bot quote delivery rates.
78
+ shopify_app_scopes: str = "read_products,read_orders,read_customers,read_shipping"
79
+ # Public base URL of THIS backend (for OAuth redirect_uri + webhook addresses).
80
+ # Falls back to the request's own origin when empty.
81
+ public_base_url: str = ""
82
+ # Shopify Billing API plan (charged through Shopify, App Store requirement).
83
+ # test=True issues non-charging test subscriptions (use on dev stores).
84
+ shopify_billing_test: bool = False
85
+ shopify_plan_name: str = "Atendyo"
86
+ shopify_plan_price: str = "29.00"
87
+ shopify_plan_currency: str = "EUR"
88
+ shopify_plan_trial_days: int = 14
89
+
90
+ # Partner / reseller program: recurring commission % shown on /partners and
91
+ # the email partners apply at. Both editable here without touching the page.
92
+ partner_commission_pct: str = "25"
93
+ partner_email: str = "comercial@flexigotech.com"
94
 
95
  # Admin
96
  admin_token: str = ""
app/main.py CHANGED
@@ -18,8 +18,11 @@ from app.routes import (
18
  health,
19
  landing,
20
  legal,
 
21
  portal,
22
  reviews,
 
 
23
  stripe_billing,
24
  whatsapp,
25
  widget,
@@ -108,6 +111,9 @@ def create_app() -> FastAPI:
108
  app.include_router(whatsapp.router)
109
  app.include_router(portal.router)
110
  app.include_router(stripe_billing.router)
 
 
 
111
  app.include_router(landing.router)
112
  app.include_router(blog.router)
113
  app.include_router(legal.router)
 
18
  health,
19
  landing,
20
  legal,
21
+ partners,
22
  portal,
23
  reviews,
24
+ shopify_oauth,
25
+ shopify_webhooks,
26
  stripe_billing,
27
  whatsapp,
28
  widget,
 
111
  app.include_router(whatsapp.router)
112
  app.include_router(portal.router)
113
  app.include_router(stripe_billing.router)
114
+ app.include_router(shopify_oauth.router)
115
+ app.include_router(shopify_webhooks.router)
116
+ app.include_router(partners.router)
117
  app.include_router(landing.router)
118
  app.include_router(blog.router)
119
  app.include_router(legal.router)
app/models.py CHANGED
@@ -139,6 +139,18 @@ class Tenant(Base):
139
  last_portal_seen_at: Mapped[datetime | None] = mapped_column(
140
  DateTime(timezone=True), nullable=True
141
  )
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
 
144
  class KnowledgeSource(Base):
 
139
  last_portal_seen_at: Mapped[datetime | None] = mapped_column(
140
  DateTime(timezone=True), nullable=True
141
  )
142
+ # PUBLIC Shopify App (App Store) install: the OAuth OFFLINE access token
143
+ # (encrypted). When set, the Shopify client uses it directly (it never
144
+ # expires) instead of the per-tenant custom-app client_credentials exchange.
145
+ shopify_oauth_token_enc: Mapped[str] = mapped_column(Text, default="", server_default="")
146
+ # How this tenant connected its store: "" (none/manual), "custom" (pasted
147
+ # client_id/secret) or "oauth" (1-click App Store install).
148
+ shopify_install_source: Mapped[str] = mapped_column(
149
+ String(12), default="", server_default=""
150
+ )
151
+ # Partner/agency attribution: the referral code that brought this tenant in
152
+ # (for the reseller/affiliate program). Empty = direct.
153
+ referred_by: Mapped[str] = mapped_column(String(64), default="", server_default="", index=True)
154
 
155
 
156
  class KnowledgeSource(Base):
app/routes/partners.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Partner / reseller program: a public landing + referral attribution.
2
+
3
+ GET /partners -> the program page (the sales asset Victor sends agencies).
4
+ GET /r/{code} -> set the referral cookie and bounce to the site, so any
5
+ store that signs up afterwards is attributed to the partner.
6
+
7
+ Attribution is read from the `atendyo_ref` cookie at sign-up time (e.g. the
8
+ Shopify OAuth callback writes it to Tenant.referred_by). The admin sees
9
+ referred_by per tenant to pay commissions.
10
+ """
11
+ # ruff: noqa: E501 - inline HTML/CSS landing template
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ from fastapi import APIRouter, Depends
18
+ from fastapi.responses import HTMLResponse, RedirectResponse
19
+
20
+ from app.config import Settings, get_settings
21
+
22
+ router = APIRouter()
23
+
24
+ REF_COOKIE = "atendyo_ref"
25
+ _REF_MAX_AGE = 90 * 24 * 3600 # 90-day attribution window
26
+ _CODE_RE = re.compile(r"^[A-Za-z0-9_-]{2,64}$")
27
+
28
+
29
+ @router.get("/r/{code}")
30
+ async def referral(code: str) -> RedirectResponse:
31
+ """Stamp the referral cookie and send the visitor to the site."""
32
+ resp = RedirectResponse("/", status_code=302)
33
+ if _CODE_RE.match(code or ""):
34
+ resp.set_cookie(
35
+ REF_COOKIE, code, max_age=_REF_MAX_AGE, samesite="lax",
36
+ secure=True, httponly=False, path="/",
37
+ )
38
+ return resp
39
+
40
+
41
+ @router.get("/partners", response_class=HTMLResponse)
42
+ async def partners(settings: Settings = Depends(get_settings)) -> HTMLResponse:
43
+ pct = settings.partner_commission_pct
44
+ email = settings.partner_email
45
+ return HTMLResponse(_page(pct, email))
46
+
47
+
48
+ def _page(pct: str, email: str) -> str:
49
+ return f"""<!doctype html><html lang="es"><head>
50
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
51
+ <title>Programa de partners — Atendyo</title>
52
+ <meta name="description" content="Gana una comisión recurrente del {pct}% por cada tienda que traigas a Atendyo. Para agencias y freelancers de Shopify en español. Sin coste, con material y demos listas.">
53
+ <link rel="canonical" href="https://atendyo.com/partners">
54
+ <style>
55
+ :root{{--blue:#1c59e9}}
56
+ *{{box-sizing:border-box}}
57
+ body{{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#16202e;margin:0;line-height:1.6}}
58
+ .wrap{{max-width:820px;margin:0 auto;padding:0 22px}}
59
+ header{{padding:46px 0 8px}}
60
+ h1{{font-size:2.2rem;line-height:1.15;margin:.2em 0}}
61
+ .sub{{font-size:1.18rem;color:#475569;max-width:640px}}
62
+ .btn{{display:inline-block;background:var(--blue);color:#fff;text-decoration:none;padding:13px 22px;border-radius:11px;font-weight:700;margin:8px 8px 8px 0}}
63
+ .btn.ghost{{background:#eef2fb;color:var(--blue)}}
64
+ .grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin:18px 0}}
65
+ .card{{background:#f7f9fc;border:1px solid #e5ebf3;border-radius:14px;padding:18px}}
66
+ .card h3{{margin:.1em 0 .3em}}
67
+ .big{{font-size:2.4rem;font-weight:800;color:var(--blue)}}
68
+ h2{{margin-top:34px}}
69
+ ul{{padding-left:1.1em}}
70
+ footer{{margin:46px 0;color:#64748b;font-size:.92rem}}
71
+ a{{color:var(--blue)}}
72
+ </style></head><body>
73
+ <div class="wrap">
74
+ <header>
75
+ <p style="font-weight:700;color:var(--blue);letter-spacing:.04em">ATENDYO · PARTNERS</p>
76
+ <h1>Recomienda Atendyo a tus clientes y gana una comisión recurrente</h1>
77
+ <p class="sub">Si montas o gestionas tiendas Shopify (o WooCommerce), ya tienes a los clientes que a nosotros nos cuesta llegar. Tú les pones el asistente de IA; tú te llevas una comisión <b>todos los meses</b> que sigan.</p>
78
+ <p><a class="btn" href="mailto:{email}?subject=Quiero%20ser%20partner%20de%20Atendyo">Quiero ser partner</a>
79
+ <a class="btn ghost" href="https://atendyo.com/demo">Ver la demo primero</a></p>
80
+ </header>
81
+
82
+ <div class="grid">
83
+ <div class="card"><div class="big">{pct}%</div><div>comisión <b>recurrente</b> por cada cliente, mientras siga pagando.</div></div>
84
+ <div class="card"><div class="big">0 €</div><div>entrar es gratis. Sin cuotas, sin mínimos, sin exclusividad.</div></div>
85
+ <div class="card"><div class="big">1 min</div><div>montas una demo de la tienda de tu cliente y se la enseñas funcionando.</div></div>
86
+ </div>
87
+
88
+ <h2>Por qué encaja contigo</h2>
89
+ <ul>
90
+ <li><b>Es un extra fácil de vender:</b> tus clientes ya te piden "atención al cliente" y "vender más". Atendyo lo resuelve sin que tú mantengas nada.</li>
91
+ <li><b>No te da trabajo:</b> se entrena solo con la web del cliente, se instala con una línea de código o en 1 clic desde Shopify, y nosotros llevamos el soporte.</li>
92
+ <li><b>En español:</b> pensado para tiendas que atienden en español (y en más de 50 idiomas). Menos competencia que en inglés.</li>
93
+ <li><b>Honesto:</b> precio plano, prueba de 14 días sin tarjeta, y si no encaja se apaga solo. No vas a quemar tu relación con el cliente.</li>
94
+ </ul>
95
+
96
+ <h2>Cómo funciona</h2>
97
+ <ul>
98
+ <li><b>1.</b> Nos escribes y te damos tu enlace de partner (con tu código).</li>
99
+ <li><b>2.</b> Se lo pasas a tu cliente o le montas la demo de su tienda. Cuando activa Atendyo desde tu enlace, queda atribuido a ti automáticamente.</li>
100
+ <li><b>3.</b> Cobras tu {pct}% cada mes que tu cliente siga. Tú ves a tus clientes; nosotros te pagamos.</li>
101
+ </ul>
102
+
103
+ <h2>Para quién es</h2>
104
+ <p>Agencias y freelancers de Shopify/ecommerce, sobre todo en España y LATAM; consultores que ya gestionan varias tiendas; y cualquiera con una audiencia de dueños de tienda. Si te encaja, escríbenos.</p>
105
+
106
+ <p style="margin:28px 0"><a class="btn" href="mailto:{email}?subject=Quiero%20ser%20partner%20de%20Atendyo">Escríbenos y empezamos</a></p>
107
+
108
+ <footer>Atendyo — La IA que atiende tu web y tu WhatsApp. <a href="https://atendyo.com/">atendyo.com</a></footer>
109
+ </div>
110
+ </body></html>"""
app/routes/shopify_oauth.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public Shopify App — 1-click OAuth install.
2
+
3
+ GET /shopify/install?shop=foo.myshopify.com -> redirect to Shopify authorize.
4
+ GET /shopify/callback?... -> verify, exchange, provision tenant.
5
+
6
+ Only active when SHOPIFY_APP_KEY/SECRET are configured; otherwise 503 (the
7
+ custom-app path is unaffected). The merchant ends up with a tenant whose Shopify
8
+ client uses the OAuth offline token, and is handed their client-portal key.
9
+ """
10
+
11
+ # ruff: noqa: E501 - the install success page is an inline HTML/CSS template
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import hmac
16
+ import logging
17
+
18
+ from fastapi import APIRouter, Depends, HTTPException, Request
19
+ from fastapi.responses import HTMLResponse, RedirectResponse
20
+ from sqlalchemy import select
21
+ from sqlalchemy.ext.asyncio import AsyncSession
22
+
23
+ from app.billing import _slugify, _unique_slug
24
+ from app.config import Settings, get_settings
25
+ from app.crypto import encrypt
26
+ from app.db import get_session
27
+ from app.models import Tenant
28
+ from app.shopify import oauth
29
+ from app.tenancy import generate_dashboard_token
30
+
31
+ log = logging.getLogger(__name__)
32
+ router = APIRouter()
33
+
34
+
35
+ def _origin(request: Request, settings: Settings) -> str:
36
+ """Public https origin for redirect_uri (configured, else request-derived)."""
37
+ if settings.public_base_url:
38
+ return settings.public_base_url.rstrip("/")
39
+ origin = str(request.base_url).rstrip("/")
40
+ if origin.startswith("http://"):
41
+ origin = "https://" + origin[len("http://"):]
42
+ return origin
43
+
44
+
45
+ def _state_for(shop: str, secret: str) -> str:
46
+ """CSRF state bound to the shop via our secret (no server-side storage)."""
47
+ return hmac.new(secret.encode(), shop.encode(), hashlib.sha256).hexdigest()[:32]
48
+
49
+
50
+ @router.get("/shopify/install")
51
+ async def shopify_install(
52
+ request: Request, shop: str = "", settings: Settings = Depends(get_settings)
53
+ ) -> RedirectResponse:
54
+ if not (settings.shopify_app_key and settings.shopify_app_secret):
55
+ raise HTTPException(status_code=503, detail="Public Shopify app not configured")
56
+ shop_n = oauth.normalize_shop(shop)
57
+ if not shop_n:
58
+ raise HTTPException(status_code=400, detail="invalid shop")
59
+ redirect_uri = f"{_origin(request, settings)}/shopify/callback"
60
+ url = oauth.build_install_url(
61
+ shop_n,
62
+ settings.shopify_app_key,
63
+ settings.shopify_app_scopes,
64
+ redirect_uri,
65
+ _state_for(shop_n, settings.secret_key),
66
+ )
67
+ return RedirectResponse(url, status_code=302)
68
+
69
+
70
+ @router.get("/shopify/callback")
71
+ async def shopify_callback(
72
+ request: Request,
73
+ db: AsyncSession = Depends(get_session),
74
+ settings: Settings = Depends(get_settings),
75
+ ) -> HTMLResponse:
76
+ if not (settings.shopify_app_key and settings.shopify_app_secret):
77
+ raise HTTPException(status_code=503, detail="Public Shopify app not configured")
78
+
79
+ params = dict(request.query_params)
80
+ shop = oauth.normalize_shop(params.get("shop", ""))
81
+ code = params.get("code", "")
82
+ state = params.get("state", "")
83
+ if not shop or not code:
84
+ raise HTTPException(status_code=400, detail="missing shop/code")
85
+ # 1) integrity: Shopify HMAC over the query string (app secret)
86
+ if not oauth.verify_hmac(params, settings.shopify_app_secret):
87
+ raise HTTPException(status_code=400, detail="bad hmac")
88
+ # 2) CSRF: state must match the shop-bound nonce we issued
89
+ if not hmac.compare_digest(state, _state_for(shop, settings.secret_key)):
90
+ raise HTTPException(status_code=400, detail="bad state")
91
+
92
+ # 3) exchange the code for a permanent OFFLINE token
93
+ try:
94
+ data = await oauth.exchange_code(
95
+ shop, code, settings.shopify_app_key, settings.shopify_app_secret
96
+ )
97
+ except Exception as exc: # noqa: BLE001 - surface a clean error, never 500-leak
98
+ log.warning("oauth token exchange failed for %s: %s", shop, exc)
99
+ raise HTTPException(status_code=502, detail="token exchange failed") from None
100
+ token = data.get("access_token", "")
101
+ if not token:
102
+ raise HTTPException(status_code=502, detail="no access token")
103
+
104
+ # 4) link or create the tenant for this shop, store the token (encrypted)
105
+ tenant = (
106
+ await db.execute(select(Tenant).where(Tenant.shopify_shop == shop))
107
+ ).scalar_one_or_none()
108
+ new = tenant is None
109
+ if new:
110
+ store_name = shop.split(".")[0]
111
+ slug = await _unique_slug(db, _slugify(store_name))
112
+ tenant = Tenant(
113
+ slug=slug,
114
+ name=store_name,
115
+ shopify_shop=shop,
116
+ shopify_install_source="oauth",
117
+ )
118
+ # Partner attribution: the referral cookie (set by /r/{code}) tags who
119
+ # brought this store in, for the reseller commission.
120
+ from app.routes.partners import _CODE_RE, REF_COOKIE
121
+ ref = (request.cookies.get(REF_COOKIE) or "").strip()
122
+ if ref and _CODE_RE.match(ref):
123
+ tenant.referred_by = ref
124
+ db.add(tenant)
125
+ await db.flush()
126
+ tenant.shopify_oauth_token_enc = encrypt(token)
127
+ tenant.shopify_install_source = "oauth"
128
+ # An install (or re-install) un-pauses any prior billing/uninstall pause.
129
+ if tenant.disabled_reason in ("billing", ""):
130
+ tenant.chatbot_enabled = True
131
+ tenant.disabled_reason = ""
132
+ if not tenant.dashboard_token:
133
+ await generate_dashboard_token(db, tenant)
134
+ from app.tenancy import _registry
135
+ _registry.invalidate(tenant.id)
136
+ await db.commit()
137
+ log.info("shopify oauth install %s tenant=%s (new=%s)", shop, tenant.slug, new)
138
+
139
+ origin = _origin(request, settings)
140
+ return HTMLResponse(_success_html(origin, tenant.slug, tenant.dashboard_token))
141
+
142
+
143
+ @router.post("/shopify/billing/start")
144
+ async def billing_start(
145
+ request: Request,
146
+ db: AsyncSession = Depends(get_session),
147
+ settings: Settings = Depends(get_settings),
148
+ ) -> dict:
149
+ """Create a recurring Shopify charge for the authenticated tenant and return
150
+ the confirmation URL (the merchant approves it in their Shopify admin).
151
+ Portal-token authed; requires the tenant's Shopify (OAuth) connection."""
152
+ from app.routes.portal import require_portal_tenant
153
+ from app.shopify import billing as shopify_billing
154
+ from app.tenancy import tenant_shopify_client
155
+
156
+ tenant = await require_portal_tenant(request.headers.get("authorization", ""), db)
157
+ client = tenant_shopify_client(tenant)
158
+ if client is None:
159
+ raise HTTPException(status_code=409, detail="Shopify not connected for this store")
160
+ return_url = f"{_origin(request, settings)}/shopify/billing/return?shop={tenant.shopify_shop}"
161
+ try:
162
+ out = await shopify_billing.create_app_subscription(
163
+ client,
164
+ name=settings.shopify_plan_name,
165
+ amount=settings.shopify_plan_price,
166
+ currency=settings.shopify_plan_currency,
167
+ return_url=return_url,
168
+ trial_days=settings.shopify_plan_trial_days,
169
+ test=settings.shopify_billing_test,
170
+ )
171
+ except Exception as exc: # noqa: BLE001
172
+ log.warning("shopify billing start failed for %s: %s", tenant.slug, exc)
173
+ raise HTTPException(status_code=502, detail="billing start failed") from None
174
+ if not out.get("confirmation_url"):
175
+ raise HTTPException(status_code=502, detail="no confirmation url")
176
+ return out
177
+
178
+
179
+ @router.get("/shopify/billing/return")
180
+ async def billing_return(
181
+ request: Request,
182
+ shop: str = "",
183
+ db: AsyncSession = Depends(get_session),
184
+ settings: Settings = Depends(get_settings),
185
+ ) -> HTMLResponse:
186
+ """Merchant landed back after approving the charge: confirm the subscription
187
+ is active and (re)enable the bot. Verified against Shopify, never trusting the
188
+ redirect alone."""
189
+ from app.shopify import billing as shopify_billing
190
+ from app.tenancy import _registry, tenant_shopify_client
191
+
192
+ shop_n = oauth.normalize_shop(shop)
193
+ tenant = (
194
+ await db.execute(select(Tenant).where(Tenant.shopify_shop == shop_n))
195
+ ).scalar_one_or_none() if shop_n else None
196
+ active = False
197
+ if tenant:
198
+ client = tenant_shopify_client(tenant)
199
+ if client is not None:
200
+ active = await shopify_billing.has_active_subscription(client)
201
+ if active:
202
+ tenant.chatbot_enabled = True
203
+ tenant.disabled_reason = ""
204
+ _registry.invalidate(tenant.id)
205
+ await db.commit()
206
+ log.info("shopify billing active for tenant=%s", tenant.slug)
207
+ origin = _origin(request, settings)
208
+ msg = (
209
+ "Tu suscripción está activa. ¡Gracias!" if active
210
+ else "No hemos podido confirmar la suscripción todavía. Si acabas de aprobarla, "
211
+ "espera unos segundos y recarga, o entra en tu panel."
212
+ )
213
+ return HTMLResponse(
214
+ f"""<!doctype html><html lang="es"><head><meta charset="utf-8">
215
+ <meta name="viewport" content="width=device-width, initial-scale=1"><title>Atendyo</title>
216
+ <style>body{{font-family:system-ui,sans-serif;max-width:600px;margin:8vh auto;padding:0 20px;line-height:1.5}}
217
+ a.btn{{display:inline-block;background:#1c59e9;color:#fff;text-decoration:none;padding:11px 18px;border-radius:10px;font-weight:600}}</style>
218
+ </head><body><h1>Atendyo</h1><p>{msg}</p>
219
+ <p><a class="btn" href="{origin}/portal">Ir a mi panel</a></p></body></html>"""
220
+ )
221
+
222
+
223
+ def _success_html(origin: str, slug: str, key: str) -> str:
224
+ snippet = f'&lt;script src="{origin}/widget.js?t={slug}" defer&gt;&lt;/script&gt;'
225
+ return f"""<!doctype html><html lang="es"><head><meta charset="utf-8">
226
+ <meta name="viewport" content="width=device-width, initial-scale=1">
227
+ <title>Atendyo — instalado</title>
228
+ <style>body{{font-family:system-ui,Segoe UI,Roboto,sans-serif;max-width:640px;margin:6vh auto;padding:0 20px;color:#1a1a1a;line-height:1.5}}
229
+ .card{{background:#f6f8fb;border:1px solid #e3e8ef;border-radius:14px;padding:22px;margin:18px 0}}
230
+ code{{background:#eef1f6;padding:2px 6px;border-radius:6px;word-break:break-all}}
231
+ a.btn{{display:inline-block;background:#1c59e9;color:#fff;text-decoration:none;padding:11px 18px;border-radius:10px;font-weight:600}}</style>
232
+ </head><body>
233
+ <h1>Atendyo está instalado</h1>
234
+ <p>Tu asistente ya está conectado a tu tienda Shopify. Entra en tu panel para
235
+ configurarlo (marca, conocimiento, WhatsApp) y copiar tu código de instalación.</p>
236
+ <div class="card">
237
+ <p><b>Tu clave de acceso al panel</b> (guárdala):</p>
238
+ <p><code>{key}</code></p>
239
+ <p style="margin-top:14px"><a class="btn" href="{origin}/portal">Ir a mi panel</a></p>
240
+ </div>
241
+ <div class="card">
242
+ <p><b>Código del widget</b> (pégalo antes de &lt;/body&gt; en tu tema):</p>
243
+ <p><code>{snippet}</code></p>
244
+ </div>
245
+ </body></html>"""
app/routes/shopify_webhooks.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mandatory Shopify App webhooks (required to pass App Store review).
2
+
3
+ POST /shopify/webhooks/app_uninstalled -> pause + unlink the tenant
4
+ POST /shopify/webhooks/customers_data_request-> GDPR: log a data-access request
5
+ POST /shopify/webhooks/customers_redact -> GDPR: erase a customer's data
6
+ POST /shopify/webhooks/shop_redact -> GDPR: purge all shop data (48h post-uninstall)
7
+
8
+ Every request is verified with the X-Shopify-Hmac-Sha256 header (base64 HMAC-
9
+ SHA256 of the raw body, keyed by the app secret). An unverified call is 401.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import hashlib
16
+ import hmac
17
+ import json
18
+ import logging
19
+
20
+ from fastapi import APIRouter, Depends, HTTPException, Request
21
+ from sqlalchemy import select
22
+ from sqlalchemy.ext.asyncio import AsyncSession
23
+
24
+ from app.config import Settings, get_settings
25
+ from app.db import get_session
26
+ from app.models import Tenant
27
+ from app.tenancy import _registry, purge_tenant
28
+
29
+ log = logging.getLogger(__name__)
30
+ router = APIRouter()
31
+
32
+
33
+ def verify_webhook(body: bytes, header_hmac: str, app_secret: str) -> bool:
34
+ if not header_hmac or not app_secret:
35
+ return False
36
+ digest = base64.b64encode(
37
+ hmac.new(app_secret.encode(), body, hashlib.sha256).digest()
38
+ ).decode()
39
+ return hmac.compare_digest(digest, header_hmac)
40
+
41
+
42
+ async def _verified_body(request: Request, settings: Settings) -> dict:
43
+ secret = settings.shopify_app_secret
44
+ if not secret:
45
+ raise HTTPException(status_code=503, detail="Public Shopify app not configured")
46
+ body = await request.body()
47
+ if not verify_webhook(body, request.headers.get("X-Shopify-Hmac-Sha256", ""), secret):
48
+ raise HTTPException(status_code=401, detail="invalid hmac")
49
+ try:
50
+ return json.loads(body or b"{}")
51
+ except ValueError:
52
+ return {}
53
+
54
+
55
+ def _shop_of(request: Request, payload: dict) -> str:
56
+ return (
57
+ request.headers.get("X-Shopify-Shop-Domain", "")
58
+ or payload.get("shop_domain", "")
59
+ or payload.get("myshopify_domain", "")
60
+ ).strip().lower()
61
+
62
+
63
+ @router.post("/shopify/webhooks/app_uninstalled")
64
+ async def app_uninstalled(
65
+ request: Request,
66
+ db: AsyncSession = Depends(get_session),
67
+ settings: Settings = Depends(get_settings),
68
+ ) -> dict:
69
+ payload = await _verified_body(request, settings)
70
+ shop = _shop_of(request, payload)
71
+ tenant = (
72
+ await db.execute(select(Tenant).where(Tenant.shopify_shop == shop))
73
+ ).scalar_one_or_none() if shop else None
74
+ if tenant:
75
+ # The OAuth token is dead after uninstall: clear it and pause the bot.
76
+ # Keep the account/data (shop/redact purges 48h later if they don't return).
77
+ tenant.shopify_oauth_token_enc = ""
78
+ tenant.chatbot_enabled = False
79
+ tenant.disabled_reason = "billing" # treated like a lapse; re-install re-enables
80
+ _registry.invalidate(tenant.id)
81
+ await db.commit()
82
+ log.info("app_uninstalled: paused tenant=%s shop=%s", tenant.slug, shop)
83
+ return {"status": "ok"}
84
+
85
+
86
+ @router.post("/shopify/webhooks/customers_data_request")
87
+ async def customers_data_request(
88
+ request: Request, settings: Settings = Depends(get_settings)
89
+ ) -> dict:
90
+ # We don't retain customer PII beyond auto-expiring chat sessions, so there is
91
+ # no profile to export; we acknowledge and log the request (GDPR compliance).
92
+ payload = await _verified_body(request, settings)
93
+ log.info("GDPR customers/data_request shop=%s", _shop_of(request, payload))
94
+ return {"status": "ok"}
95
+
96
+
97
+ @router.post("/shopify/webhooks/customers_redact")
98
+ async def customers_redact(
99
+ request: Request, settings: Settings = Depends(get_settings)
100
+ ) -> dict:
101
+ payload = await _verified_body(request, settings)
102
+ log.info("GDPR customers/redact shop=%s", _shop_of(request, payload))
103
+ return {"status": "ok"}
104
+
105
+
106
+ @router.post("/shopify/webhooks/shop_redact")
107
+ async def shop_redact(
108
+ request: Request,
109
+ db: AsyncSession = Depends(get_session),
110
+ settings: Settings = Depends(get_settings),
111
+ ) -> dict:
112
+ # Sent ~48h after uninstall: erase ALL data for the shop.
113
+ payload = await _verified_body(request, settings)
114
+ shop = _shop_of(request, payload)
115
+ tenant = (
116
+ await db.execute(select(Tenant).where(Tenant.shopify_shop == shop))
117
+ ).scalar_one_or_none() if shop else None
118
+ if tenant:
119
+ await purge_tenant(db, tenant)
120
+ await db.commit()
121
+ log.info("GDPR shop/redact: purged tenant shop=%s", shop)
122
+ return {"status": "ok"}
app/schemas.py CHANGED
@@ -188,6 +188,8 @@ class TenantOut(BaseModel):
188
  is_paid: bool = False # has an active Stripe subscription
189
  disabled_reason: str = "" # "", "trial", "billing", "admin"
190
  last_portal_seen_at: str | None = None # last time the client opened their portal
 
 
191
 
192
  @classmethod
193
  def from_tenant(cls, t) -> TenantOut:
@@ -233,4 +235,6 @@ class TenantOut(BaseModel):
233
  t.last_portal_seen_at.isoformat()
234
  if getattr(t, "last_portal_seen_at", None) else None
235
  ),
 
 
236
  )
 
188
  is_paid: bool = False # has an active Stripe subscription
189
  disabled_reason: str = "" # "", "trial", "billing", "admin"
190
  last_portal_seen_at: str | None = None # last time the client opened their portal
191
+ shopify_install_source: str = "" # "", "custom", "oauth" (App Store 1-click)
192
+ referred_by: str = "" # partner/agency referral code, "" = direct
193
 
194
  @classmethod
195
  def from_tenant(cls, t) -> TenantOut:
 
235
  t.last_portal_seen_at.isoformat()
236
  if getattr(t, "last_portal_seen_at", None) else None
237
  ),
238
+ shopify_install_source=getattr(t, "shopify_install_source", "") or "",
239
+ referred_by=getattr(t, "referred_by", "") or "",
240
  )
app/shopify/billing.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shopify Billing API (App Store apps must charge through it, not Stripe).
2
+
3
+ Creates a recurring app subscription and reads the active ones, via the Admin
4
+ GraphQL API using the merchant's OAuth token (the same client the rest of the
5
+ app uses). Charging flow:
6
+ 1. create_app_subscription(...) -> returns a confirmation_url.
7
+ 2. Redirect the merchant there; they approve the charge in their admin.
8
+ 3. Shopify redirects back to return_url; the subscription is then active.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from app.shopify.client import ShopifyError, ShopifyGraphQLClient
16
+
17
+ _CREATE = """
18
+ mutation AppSubscriptionCreate($name: String!, $returnUrl: URL!, $test: Boolean!,
19
+ $trialDays: Int!, $amount: Decimal!, $currency: CurrencyCode!, $interval: AppPricingInterval!) {
20
+ appSubscriptionCreate(
21
+ name: $name
22
+ returnUrl: $returnUrl
23
+ test: $test
24
+ trialDays: $trialDays
25
+ lineItems: [{
26
+ plan: { appRecurringPricingDetails: {
27
+ price: { amount: $amount, currencyCode: $currency }
28
+ interval: $interval
29
+ } }
30
+ }]
31
+ ) {
32
+ confirmationUrl
33
+ appSubscription { id status }
34
+ userErrors { field message }
35
+ }
36
+ }
37
+ """
38
+
39
+ _ACTIVE = """
40
+ query ActiveSubscriptions {
41
+ currentAppInstallation {
42
+ activeSubscriptions { id name status }
43
+ }
44
+ }
45
+ """
46
+
47
+
48
+ async def create_app_subscription(
49
+ client: ShopifyGraphQLClient,
50
+ *,
51
+ name: str,
52
+ amount: str,
53
+ return_url: str,
54
+ currency: str = "EUR",
55
+ interval: str = "EVERY_30_DAYS", # or ANNUAL
56
+ trial_days: int = 14,
57
+ test: bool = False,
58
+ ) -> dict[str, Any]:
59
+ """Create a recurring charge. Returns {confirmation_url, subscription_id,
60
+ status}. Raises ShopifyError on userErrors so the caller never silently
61
+ proceeds without a confirmation URL."""
62
+ data = await client.execute(
63
+ _CREATE,
64
+ {
65
+ "name": name,
66
+ "returnUrl": return_url,
67
+ "test": test,
68
+ "trialDays": int(trial_days),
69
+ "amount": str(amount),
70
+ "currency": currency,
71
+ "interval": interval,
72
+ },
73
+ )
74
+ res = (data or {}).get("appSubscriptionCreate") or {}
75
+ errors = res.get("userErrors") or []
76
+ if errors:
77
+ raise ShopifyError(f"appSubscriptionCreate: {errors}")
78
+ sub = res.get("appSubscription") or {}
79
+ return {
80
+ "confirmation_url": res.get("confirmationUrl"),
81
+ "subscription_id": sub.get("id"),
82
+ "status": sub.get("status"),
83
+ }
84
+
85
+
86
+ async def list_active_subscriptions(client: ShopifyGraphQLClient) -> list[dict[str, Any]]:
87
+ data = await client.execute(_ACTIVE)
88
+ inst = (data or {}).get("currentAppInstallation") or {}
89
+ return list(inst.get("activeSubscriptions") or [])
90
+
91
+
92
+ async def has_active_subscription(client: ShopifyGraphQLClient) -> bool:
93
+ try:
94
+ subs = await list_active_subscriptions(client)
95
+ except ShopifyError:
96
+ return False
97
+ return any((s.get("status") or "").upper() == "ACTIVE" for s in subs)
app/shopify/oauth.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public Shopify App OAuth (authorization-code grant → permanent offline token).
2
+
3
+ This is the 1-click App Store install path, separate from the per-tenant custom
4
+ app (client_credentials). Flow:
5
+ 1. /shopify/install?shop=foo.myshopify.com -> redirect to Shopify authorize.
6
+ 2. Merchant approves -> Shopify redirects to /shopify/callback with code+hmac.
7
+ 3. We verify the HMAC (app secret), exchange the code for an OFFLINE access
8
+ token, and store it on the tenant (auto-creating/linking by shop domain).
9
+
10
+ All request data from Shopify is HMAC-verified or strictly validated before use.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import hmac
17
+ import re
18
+ from urllib.parse import urlencode
19
+
20
+ import httpx
21
+
22
+ # A valid myshopify domain — validated before it is ever interpolated into a URL
23
+ # or a redirect, so a crafted ?shop= value can't point us at another host.
24
+ _SHOP_RE = re.compile(r"^[a-z0-9][a-z0-9-]*\.myshopify\.com$", re.IGNORECASE)
25
+
26
+
27
+ def is_valid_shop(shop: str) -> bool:
28
+ return bool(_SHOP_RE.match((shop or "").strip()))
29
+
30
+
31
+ def normalize_shop(shop: str) -> str:
32
+ """Accept 'foo', 'foo.myshopify.com' or a full URL; return the bare domain
33
+ if it is a valid myshopify host, else ''."""
34
+ s = (shop or "").strip().lower()
35
+ s = s.removeprefix("https://").removeprefix("http://").split("/")[0]
36
+ if "." not in s:
37
+ s = f"{s}.myshopify.com"
38
+ return s if is_valid_shop(s) else ""
39
+
40
+
41
+ def build_install_url(
42
+ shop: str, api_key: str, scopes: str, redirect_uri: str, state: str
43
+ ) -> str:
44
+ """The Shopify authorize URL to redirect the merchant to. ``shop`` MUST be a
45
+ validated myshopify domain (caller checks)."""
46
+ q = urlencode(
47
+ {
48
+ "client_id": api_key,
49
+ "scope": scopes,
50
+ "redirect_uri": redirect_uri,
51
+ "state": state,
52
+ }
53
+ )
54
+ return f"https://{shop}/admin/oauth/authorize?{q}"
55
+
56
+
57
+ def verify_hmac(params: dict[str, str], app_secret: str) -> bool:
58
+ """Verify the `hmac` on an OAuth callback / App Proxy-less query string.
59
+
60
+ Shopify signs all params EXCEPT `hmac` (and the legacy `signature`): sort by
61
+ key, join as k=v&..., HMAC-SHA256 with the app secret, compare hex (constant
62
+ time)."""
63
+ given = params.get("hmac", "")
64
+ if not given or not app_secret:
65
+ return False
66
+ msg = "&".join(
67
+ f"{k}={params[k]}" for k in sorted(params) if k not in ("hmac", "signature")
68
+ )
69
+ digest = hmac.new(app_secret.encode(), msg.encode(), hashlib.sha256).hexdigest()
70
+ return hmac.compare_digest(digest, given)
71
+
72
+
73
+ async def exchange_code(
74
+ shop: str,
75
+ code: str,
76
+ api_key: str,
77
+ app_secret: str,
78
+ *,
79
+ client: httpx.AsyncClient | None = None,
80
+ ) -> dict:
81
+ """Exchange the authorization code for a permanent OFFLINE access token.
82
+ Returns the Shopify JSON ({"access_token", "scope"})."""
83
+ owns = client is None
84
+ cl = client or httpx.AsyncClient(timeout=20.0)
85
+ try:
86
+ resp = await cl.post(
87
+ f"https://{shop}/admin/oauth/access_token",
88
+ json={"client_id": api_key, "client_secret": app_secret, "code": code},
89
+ )
90
+ resp.raise_for_status()
91
+ return resp.json()
92
+ finally:
93
+ if owns:
94
+ await cl.aclose()
app/shopify/token.py CHANGED
@@ -29,12 +29,17 @@ class ShopifyTokenManager:
29
  *,
30
  now: Callable[[], datetime] = _now,
31
  client: httpx.AsyncClient | None = None,
 
32
  ) -> None:
33
  self.shop = shop
34
  self.client_id = client_id
35
  self.client_secret = client_secret
36
  self._now = now
37
  self._client = client
 
 
 
 
38
  self._token: str | None = None
39
  self._expires_at: datetime | None = None
40
  self._lock = asyncio.Lock()
@@ -60,6 +65,8 @@ class ShopifyTokenManager:
60
  )
61
 
62
  async def get_token(self) -> str:
 
 
63
  if self._is_fresh():
64
  return self._token # type: ignore[return-value]
65
  async with self._lock:
 
29
  *,
30
  now: Callable[[], datetime] = _now,
31
  client: httpx.AsyncClient | None = None,
32
+ static_token: str = "",
33
  ) -> None:
34
  self.shop = shop
35
  self.client_id = client_id
36
  self.client_secret = client_secret
37
  self._now = now
38
  self._client = client
39
+ # A PUBLIC-app OAuth offline token never expires, so we just hold it and
40
+ # skip the client_credentials exchange entirely. A 401 means the merchant
41
+ # uninstalled / re-auth is needed (handled by re-running the OAuth flow).
42
+ self._static = static_token or ""
43
  self._token: str | None = None
44
  self._expires_at: datetime | None = None
45
  self._lock = asyncio.Lock()
 
65
  )
66
 
67
  async def get_token(self) -> str:
68
+ if self._static:
69
+ return self._static
70
  if self._is_fresh():
71
  return self._token # type: ignore[return-value]
72
  async with self._lock:
app/tenancy.py CHANGED
@@ -170,12 +170,37 @@ class _ShopifyRegistry:
170
  self._cache.pop(tenant_id, None)
171
 
172
  def for_tenant(self, tenant: Tenant) -> ShopifyGraphQLClient | None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  secret = (
174
  decrypt(tenant.shopify_client_secret_enc)
175
  if tenant.shopify_client_secret_enc
176
  else ""
177
  )
178
- if not (tenant.shopify_shop and tenant.shopify_client_id and secret):
179
  return None
180
  fingerprint = (
181
  f"{tenant.shopify_shop}|{tenant.shopify_client_id}"
 
170
  self._cache.pop(tenant_id, None)
171
 
172
  def for_tenant(self, tenant: Tenant) -> ShopifyGraphQLClient | None:
173
+ if not tenant.shopify_shop:
174
+ return None
175
+ # PUBLIC App (1-click install): use the OAuth offline token directly. It
176
+ # never expires, so no client_credentials exchange — and no per-tenant
177
+ # client_id/secret needed (those belong to the app).
178
+ oauth_token = (
179
+ decrypt(tenant.shopify_oauth_token_enc)
180
+ if tenant.shopify_oauth_token_enc
181
+ else ""
182
+ )
183
+ if oauth_token:
184
+ fingerprint = (
185
+ f"oauth|{tenant.shopify_shop}|{tenant.shopify_api_version}|{oauth_token[:6]}"
186
+ )
187
+ cached = self._cache.get(tenant.id)
188
+ if cached and cached[0] == fingerprint:
189
+ return cached[1]
190
+ tm = ShopifyTokenManager(
191
+ tenant.shopify_shop, "", "", static_token=oauth_token
192
+ )
193
+ client = ShopifyGraphQLClient(tenant.shopify_shop, tenant.shopify_api_version, tm)
194
+ self._cache[tenant.id] = (fingerprint, client)
195
+ return client
196
+
197
+ # Custom-app path (merchant pasted client_id/secret): client_credentials.
198
  secret = (
199
  decrypt(tenant.shopify_client_secret_enc)
200
  if tenant.shopify_client_secret_enc
201
  else ""
202
  )
203
+ if not (tenant.shopify_client_id and secret):
204
  return None
205
  fingerprint = (
206
  f"{tenant.shopify_shop}|{tenant.shopify_client_id}"
docs/SHOPIFY-APP-STORE-PLAN.md ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Atendyo en el Shopify App Store — plan accionable
2
+
3
+ > Decisión: **SÍ montarla, como track de medio plazo en paralelo.** Su valor real
4
+ > no es "discovery mágico" (la categoría está reventada y sin reviews quedas
5
+ > enterrado), sino: **instalación en 1 clic** (hoy el cliente tiene que crear una
6
+ > custom app y pegar client_id/secret = fricción que pierde demos) + el **flywheel
7
+ > de reviews** + aparecer cuando alguien busca "chatbot" dentro de Shopify.
8
+ > Empezar el papeleo YA porque el review tarda 4-6 semanas. NO es el cliente de mañana.
9
+
10
+ ## La verdad de los plazos (verificada 2026)
11
+ - Review: **5-10 días hábiles** por ronda, normalmente **2-3 rondas** → presupuesta
12
+ **4-6 semanas** desde envío hasta publicado.
13
+ - Exige: estándares de seguridad, **rendimiento (Lighthouse)** en las páginas que toca,
14
+ **webhooks GDPR obligatorios** (`customers/data_request`, `customers/redact`,
15
+ `shop/redact`) + `app/uninstalled`, **Billing API** (si cobras a través de la app),
16
+ **OAuth** (instalación), y app **embebida** (App Bridge) recomendada para "Built for Shopify".
17
+ - Fuente: shopify.dev/docs/apps/launch/shopify-app-store/app-store-requirements
18
+
19
+ ---
20
+
21
+ ## Qué construyo YO (en este repo, sin romper el flujo custom-app actual)
22
+
23
+ | Pieza | Estado | Notas |
24
+ |---|---|---|
25
+ | **OAuth install** (`/shopify/install` → `/shopify/callback`) | en curso | authorize → code → token offline permanente, HMAC verificado, auto-crea/linka tenant |
26
+ | **Token offline por tenant** (`shopify_oauth_token_enc`) | en curso | el registro usa este token estático si existe; si no, cae al `client_credentials` actual (ambos conviven) |
27
+ | **Webhooks obligatorios** | pendiente | `app/uninstalled` (pausa/limpia tenant) + GDPR ×3, todos con HMAC del app secret |
28
+ | **Billing API de Shopify** | pendiente | `appSubscriptionCreate` (recurring), plan + trial 14 días; alternativa a Stripe DENTRO de la app |
29
+ | **App embebida (App Bridge)** | pendiente | el portal del cliente servido dentro del admin de Shopify (iframe + session token); v1 puede ser no-embebida y migrar después |
30
+ | **Theme app extension (widget)** | parcial | `extension/shopify.app.toml` ya existe con app_proxy + scopes; falta el app-embed block del widget |
31
+ | **shopify.app.toml** completo | parcial | scopes ya incluyen `read_shipping`; falta `application_url`, `redirect_urls`, webhooks, `[access.admin]` |
32
+
33
+ ## Qué tienes que hacer TÚ (no lo puedo hacer por ti — son cuentas/portales externos)
34
+ 1. **Cuenta de Shopify Partners** (gratis): partners.shopify.com → crear.
35
+ 2. **Crear la app** en el Partner Dashboard → obtener **API key + API secret** (a nivel de app, NO por cliente). Me los pasas → los pongo como secrets (`SHOPIFY_APP_KEY`, `SHOPIFY_APP_SECRET`).
36
+ 3. **Configurar URLs** en el Partner Dashboard: App URL = `https://atendyo.com` (o el backend HF), Allowed redirection URL(s) = `https://<backend>/shopify/callback`.
37
+ 4. **Contenido del listado** (esto vende o entierra la app): nombre, icono, **capturas reales**, vídeo demo, descripción, categoría (Customer support), pricing. Las capturas/vídeo las preparo yo con el bot real; tú las subes al portal.
38
+ 5. **Enviar a revisión** y atender las 2-3 rondas de feedback (te preparo las respuestas).
39
+ 6. **Reviews**: pedir a los primeros clientes (incl. Toorx/BAI) una review en cuanto esté publicada — es el flywheel.
40
+
41
+ ## Secuencia recomendada
42
+ 1. (YO) OAuth + webhooks + Billing + tests → app instalable de punta a punta. ← **empezando ahora**
43
+ 2. (TÚ) Partner account + crear app + pasarme key/secret.
44
+ 3. (YO) Conecto las credenciales, pruebo install real en una dev store, preparo capturas/vídeo.
45
+ 4. (TÚ) Subes listado + envías a revisión.
46
+ 5. (AMBOS) Iteramos el feedback de revisión.
47
+ 6. Publicado → pedir reviews a los clientes actuales.
48
+
49
+ ## Expectativa honesta
50
+ Listarse **no** trae instalaciones solo. El App Store es un **canal de conversión y
51
+ confianza** (instalar fácil + reviews + "está en la tienda oficial"), que alimenta
52
+ lo que de verdad capta: la máquina de demos, el SEO en español y los partners. Por eso
53
+ va en paralelo, no en el camino crítico de conseguir el próximo cliente.
migrations/versions/0024_shopify_oauth_app.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public Shopify App (App Store): per-tenant OAuth offline token + install
2
+ source + partner referral attribution.
3
+
4
+ Additive, idempotent on Postgres (ADD COLUMN IF NOT EXISTS). On other dialects
5
+ it is a no-op (tests/dev use create_all which already has the columns).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from alembic import op
11
+
12
+ revision = "0024_shopify_oauth_app"
13
+ down_revision = "0023_last_portal_seen"
14
+ branch_labels = None
15
+ depends_on = None
16
+
17
+
18
+ def upgrade() -> None:
19
+ if op.get_bind().dialect.name != "postgresql":
20
+ return
21
+ op.execute("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS shopify_oauth_token_enc TEXT DEFAULT ''")
22
+ op.execute("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS shopify_install_source VARCHAR(12) DEFAULT ''")
23
+ op.execute("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS referred_by VARCHAR(64) DEFAULT ''")
24
+ op.execute("CREATE INDEX IF NOT EXISTS ix_tenants_referred_by ON tenants (referred_by)")
25
+
26
+
27
+ def downgrade() -> None:
28
+ if op.get_bind().dialect.name != "postgresql":
29
+ return
30
+ op.execute("DROP INDEX IF EXISTS ix_tenants_referred_by")
31
+ op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS referred_by")
32
+ op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS shopify_install_source")
33
+ op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS shopify_oauth_token_enc")