feat: public Shopify App (OAuth+webhooks+billing) + SEO + partners program
Browse files- app/blog/posts.py +104 -0
- app/config.py +22 -0
- app/main.py +6 -0
- app/models.py +12 -0
- app/routes/partners.py +110 -0
- app/routes/shopify_oauth.py +245 -0
- app/routes/shopify_webhooks.py +122 -0
- app/schemas.py +4 -0
- app/shopify/billing.py +97 -0
- app/shopify/oauth.py +94 -0
- app/shopify/token.py +7 -0
- app/tenancy.py +26 -1
- docs/SHOPIFY-APP-STORE-PLAN.md +53 -0
- migrations/versions/0024_shopify_oauth_app.py +33 -0
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'<script src="{origin}/widget.js?t={slug}" defer></script>'
|
| 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 </body> 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.
|
| 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")
|