0%
Comisión por transacción
↓ vs 2% Shopify
120
SKUs migrados
4 variantes × origen
79€
Ahorro mensual
+ sin comisiones Stripe
~90ms
Latencia API P95
Railway · EU West
100%
Control de código
Apache 2.0 license
Arquitectura del Sistema
Storefront
Next.js 14 · App Router
next@14.2
@tanstack/query
Zustand (cart)
Tailwind CSS
Medusa Backend
API REST · Port 9000
@medusajs/medusa
Workflows + Events
Custom API Routes
Admin :7001
Datos
Railway · Managed
PostgreSQL 16
Redis 7 (sessions)
Migrations auto
Integraciones
Plugins oficiales
Stripe (pagos)
Holded (facturas)
SendGrid (email)
Plausible (stats)
Flujo de Checkout — Medusa Workflow con Rollback Automático
01
Crear Carrito
POST /store/carts
→
02
Añadir Items
/carts/:id/line-items
→
03
Email + Dirección
PATCH /store/carts/:id
→
04
Envío
/shipping-methods
→
05
Pago Stripe
Payment Intent
→
06
Completar Pedido
order.placed event
medusa-config.ts
TypeScript
// Configuración principal: módulos, plugins, DB import { defineConfig } from "@medusajs/framework" export default defineConfig({ projectConfig: { databaseUrl: process.env.DATABASE_URL, redisUrl: process.env.REDIS_URL, http: { storeCors: "https://cafenorigen.es", adminCors: "https://admin.cafeorigen.es", }, }, modules: [ { resolve: "@medusajs/payment", options: { providers: [{ resolve: "@medusajs/payment-stripe", options: { apiKey: process.env.STRIPE_SECRET_KEY, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET, }, }], }, }, { resolve: "@medusajs/fulfillment", options: { providers: [ { resolve: "./src/modules/correos-esp" }, { resolve: "./src/modules/mrw" }, ], }, }, ], })
src/scripts/seed-cafes.ts
TypeScript
// Seed: café Colombia con variantes 4×3 (12 SKUs) const cafes = [{ title: "Colombia Huila — Geisha Natural", description: "Notas de maracuyá, flor de azahar...", status: "published", options: [ { title: "Gramaje", values: [ "250g", "500g", "1kg" ]}, { title: "Molienda", values: [ "Grano", "Espresso", "Filtro", "Moka" ]}, ], variants: [ { title: "250g · Grano", sku: "COL-HUI-250-GRN", prices: [{ amount: 1490, // 14,90€ currency_code: "eur" }], options: { Gramaje: "250g", Molienda: "Grano" }, manage_inventory: true, inventory_quantity: 48, }, // ... 11 variantes más (250/500/1kg × 4 moliendas) ], }]
src/subscribers/order-placed.ts
TypeScript
// Suscriptor: al confirmar pedido → // 1) email de confirmación (SendGrid) // 2) webhook a Holded para crear factura import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework" export default async function orderPlacedHandler( { event, container }: SubscriberArgs ) { const order = event.data const notif = container.resolve("notification") // 1. Email confirmación await notif.send("order-confirmation-es", { to: order.email, data: { nombre: order.shipping_address.first_name, pedido_id: order.display_id, items: order.items.map(i => ({ nombre: i.title, cantidad: i.quantity, precio: (i.unit_price / 100).toFixed(2) + "€", })), total: (order.total / 100).toFixed(2) + "€", }, }) // 2. Webhook Holded — crear factura automática await fetch("https://api.holded.com/api/invoices", { method: "POST", headers: { "key": process.env.HOLDED_API_KEY!, "Content-Type": "application/json", }, body: JSON.stringify({ contactName: order.customer?.email, date: Math.floor(Date.now() / 1000), items: order.items.map(item => ({ name: item.title, units: item.quantity, price: item.unit_price / 100, tax: 21, // IVA estándar })), }), }) } export const config: SubscriberConfig = { event: "order.placed", }
src/api/store/custom-search/route.ts
TypeScript
// Endpoint de búsqueda personalizado: // GET /store/cafe-search?q=colombia&molienda=espresso import type { MedusaRequest, MedusaResponse } from "@medusajs/framework" export async function GET( req: MedusaRequest, res: MedusaResponse ) { const { q, molienda, origen } = req.query const productService = req.scope.resolve("product") const [products, count] = await productService.listAndCount( { q: q as string, status: "published", tags: [origen, molienda] .filter(Boolean) as string[], }, { take: 20, relations: [ "variants", "variants.prices", "images", "options", ], } ) res.json({ products, count, filters_applied: { q: q || null, molienda: molienda || null, origen: origen || null, }, }) }
storefront/lib/cart.ts — Lógica de carrito para Next.js 14
TypeScript
const MEDUSA_URL = process.env.NEXT_PUBLIC_MEDUSA_URL ?? "https://api.cafeorigen.es" /** Crea carrito con región EUR */ export async function createCart() { const res = await fetch( `${MEDUSA_URL}/store/carts`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ region_id: "reg_eur_es" }), }) return res.json() // → { cart: { id, items: [], total: 0 } } } /** Añadir variante al carrito */ export async function addToCart( cartId: string, variantId: string, qty: number = 1 ) { return fetch( `${MEDUSA_URL}/store/carts/${cartId}/line-items`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ variant_id: variantId, quantity: qty }), } ).then(r => r.json()) }/** Checkout completo en 4 pasos */ export async function completeCheckout( cartId: string, customer: { email: string; address: Address } ) { // Paso 1: asignar email + dirección await fetch(`${MEDUSA_URL}/store/carts/${cartId}`, { method: "POST", body: JSON.stringify({ email: customer.email, shipping_address: customer.address }) }) // Paso 2: seleccionar envío await fetch( `${MEDUSA_URL}/store/carts/${cartId}/shipping-methods`, { method: "POST", body: JSON.stringify({ option_id: "so_correos_48h" }) }) // Paso 3: inicializar pago Stripe await fetch( `${MEDUSA_URL}/store/carts/${cartId}/payment-sessions`, { method: "POST" }) // Paso 4: completar pedido const res = await fetch( `${MEDUSA_URL}/store/carts/${cartId}/complete`, { method: "POST" } ) const { type, data } = await res.json() // type === "order" → pedido confirmado // type === "cart" → falta algún dato return { type, order: data } }
API Endpoints + Eventos del Sistema
| Método | Ruta | Descripción | Evento disparado | Status |
|---|---|---|---|---|
| GET | /store/products | Listado de cafés con filtros (origen, molienda, precio) | — | ✓ Activo |
| POST | /store/carts | Crear carrito con región EUR | cart.created | ✓ Activo |
| POST | /store/carts/:id/complete | Confirmar pedido tras pago Stripe | order.placed | ✓ Activo |
| POST | /store/cafe-search | Búsqueda por q + filtros molienda/origen (custom route) | — | ✓ Activo |
| GET | /admin/orders | Panel admin: pedidos con estado fulfillment | — | ✓ Activo |
| WEBHOOK | /webhooks/stripe | Confirmar pago y pasar pedido a "paid" | payment.captured | ✓ Activo |
| EVENT | order.placed | Subscriber → email confirmación + factura Holded automática | Async subscriber | ✓ Activo |
| EVENT | order.fulfillment_created | Subscriber → SMS tracking Correos al cliente | Async subscriber | En progreso |
Pipeline CI/CD — GitHub Actions → Railway
.github/workflows/deploy.yml
YAML
name: Deploy Medusa on: push: branches: [main] jobs: deploy-backend: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Railway CLI run: npm i -g @railway/cli - name: Run DB migrations run: railway run npx medusa db:migrate env: RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} - name: Deploy backend run: railway up --service medusa-backend deploy-storefront: needs: deploy-backend runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }} working-directory: ./storefront
docker-compose.dev.yml
YAML
version: "3.9" services: postgres: image: postgres:16-alpine environment: POSTGRES_DB: cafeorigen POSTGRES_USER: medusa POSTGRES_PASSWORD: dev_password ports: ["5432:5432"] volumes: ["pgdata:/var/lib/postgresql"] redis: image: redis:7-alpine ports: ["6379:6379"] command: redis-server --appendonly yes medusa: build: . ports: ["9000:9000", "7001:7001"] depends_on: [postgres, redis] env_file: .env.local command: npx medusa develop volumes: ["./src:/app/src"] volumes: pgdata:
Costes de Infraestructura
| Railway Postgres | 5€/mes |
| Railway Redis | 5€/mes |
| Railway Backend | 10€/mes |
| Vercel Storefront | 0€/mes |
| Total infra | 20€/mes |
| Ahorro vs Shopify Plus | +59€/mes |
Checklist de Seguridad
✓ Admin bajo auth JWT + IP allowlist
✓ CORS restringido a dominio propio
✓ Stripe webhook signature verify
✓ Secrets en Railway env vars
✓ Rate limiting en rutas /store
⏳ WAF (Cloudflare) — próximo sprint