From b437709510380c68016888c64d8c77de82b7ac88 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 18 Feb 2026 13:16:39 -0700 Subject: [PATCH] feat: replace Stripe with Mollie payments + TBFF revenue split to bonding curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Swap stripe SDK for mollie-api-python (Dutch data residency, PCI-DSS L1) - Mollie redirect checkout flow: create payment → hosted page → webhook - Rename DB columns: stripe_session_id → payment_id, add payment_provider - Add revenue split: configurable fraction of each sale deposits to flow-service TBFF funnel → Transak on-ramp → bonding curve ($MYCO) - Add checkout success page with cart cleanup - Add backlog tasks for remaining work - Remove @stripe/stripe-js from frontend, Stripe env vars from Docker Co-Authored-By: Claude Opus 4.6 --- .env.example | 14 ++- CLAUDE.md | 10 +- README.md | 4 +- .../alembic/versions/002_stripe_to_mollie.py | 36 +++++++ backend/app/api/checkout.py | 32 +++--- backend/app/api/health.py | 12 ++- backend/app/api/webhooks.py | 48 ++++----- backend/app/config.py | 12 ++- backend/app/models/customer.py | 2 +- backend/app/models/order.py | 8 +- backend/app/services/flow_service.py | 100 ++++++++++++++++++ backend/app/services/mollie_service.py | 79 ++++++++++++++ backend/app/services/order_service.py | 80 +++++++++----- backend/app/services/stripe_service.py | 91 ---------------- backend/pyproject.toml | 2 +- backend/requirements.txt | 4 +- backlog/config.yml | 16 +++ ...-Mollie-API-key-for-production-payments.md | 16 +++ ...Configure-Printful-and-Prodigi-API-keys.md | 16 +++ ...e-placeholder-Fungi-Flows-design-assets.md | 16 +++ ...rate-EncryptID-authentication-for-rSwag.md | 16 +++ ...dd-real-Printful-mockup-API-integration.md | 16 +++ .../task-6 - Add-order-confirmation-emails.md | 16 +++ ... - Set-up-auto-deploy-webhook-for-rSwag.md | 16 +++ docker-compose.yml | 8 +- frontend/Dockerfile | 2 - frontend/app/checkout/success/page.tsx | 46 ++++++++ frontend/package.json | 1 - 28 files changed, 536 insertions(+), 183 deletions(-) create mode 100644 backend/alembic/versions/002_stripe_to_mollie.py create mode 100644 backend/app/services/flow_service.py create mode 100644 backend/app/services/mollie_service.py delete mode 100644 backend/app/services/stripe_service.py create mode 100644 backlog/config.yml create mode 100644 backlog/tasks/task-1 - Configure-Mollie-API-key-for-production-payments.md create mode 100644 backlog/tasks/task-2 - Configure-Printful-and-Prodigi-API-keys.md create mode 100644 backlog/tasks/task-3 - Replace-placeholder-Fungi-Flows-design-assets.md create mode 100644 backlog/tasks/task-4 - Integrate-EncryptID-authentication-for-rSwag.md create mode 100644 backlog/tasks/task-5 - Add-real-Printful-mockup-API-integration.md create mode 100644 backlog/tasks/task-6 - Add-order-confirmation-emails.md create mode 100644 backlog/tasks/task-7 - Set-up-auto-deploy-webhook-for-rSwag.md create mode 100644 frontend/app/checkout/success/page.tsx diff --git a/.env.example b/.env.example index db16f2d..9d352ed 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,8 @@ # Database DB_PASSWORD=change_me_in_production -# Stripe -STRIPE_SECRET_KEY=sk_test_xxx -STRIPE_PUBLISHABLE_KEY=pk_test_xxx -STRIPE_WEBHOOK_SECRET=whsec_xxx +# Mollie Payments (https://my.mollie.com/dashboard) +MOLLIE_API_KEY=test_xxx # POD Providers PRODIGI_API_KEY=xxx @@ -20,6 +18,13 @@ CORS_ORIGINS=https://rswag.online # AI Design Generation GEMINI_API_KEY=xxx +# TBFF Revenue Split → Bonding Curve +# Leave FLOW_SERVICE_URL empty to disable flow routing +FLOW_SERVICE_URL=http://flow-service:3010 +FLOW_ID=xxx +FLOW_FUNNEL_ID=xxx +FLOW_REVENUE_SPLIT=0.5 + # SMTP Email SMTP_HOST=mail.example.com SMTP_PORT=587 @@ -28,4 +33,3 @@ SMTP_PASS=changeme # Frontend NEXT_PUBLIC_API_URL=https://rswag.online/api -NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx diff --git a/CLAUDE.md b/CLAUDE.md index 80ba8b6..73df230 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,14 +2,14 @@ ## Project Overview -E-commerce platform for rSpace ecosystem merchandise (stickers, shirts, prints) with Stripe payments and print-on-demand fulfillment via Printful and Prodigi. Part of the rSpace ecosystem (rspace.online). +E-commerce platform for rSpace ecosystem merchandise (stickers, shirts, prints) with Mollie payments and print-on-demand fulfillment via Printful and Prodigi. Part of the rSpace ecosystem (rspace.online). ## Architecture - **Frontend**: Next.js 15 App Router, shadcn/ui, Tailwind CSS, Geist font - **Backend**: FastAPI, SQLAlchemy, Alembic - **Database**: PostgreSQL -- **Payments**: Stripe Checkout (redirect flow) +- **Payments**: Mollie (redirect flow, Dutch data residency) - **Fulfillment**: Printful (apparel), Prodigi (stickers/prints) - **AI Design**: Gemini API for design generation - **Deployment**: Docker on Netcup RS 8000, Traefik routing @@ -21,7 +21,7 @@ E-commerce platform for rSpace ecosystem merchandise (stickers, shirts, prints) | `backend/app/api/` | FastAPI route handlers | | `backend/app/models/` | SQLAlchemy ORM models | | `backend/app/schemas/` | Pydantic request/response schemas | -| `backend/app/services/` | Business logic (stripe, pod, orders, spaces) | +| `backend/app/services/` | Business logic (mollie, pod, orders, spaces) | | `backend/app/pod/` | POD provider clients | | `frontend/app/` | Next.js App Router pages | | `frontend/components/` | React components | @@ -59,12 +59,12 @@ Each design has a `metadata.yaml` with name, description, products, variants, pr - `GET /api/products` - List products with variants (optional: `?space=X`) - `POST /api/cart` - Create cart - `GET/POST/DELETE /api/cart/{id}/items` - Cart operations -- `POST /api/checkout/session` - Create Stripe checkout +- `POST /api/checkout/session` - Create Mollie payment - `GET /api/orders/{id}` - Order status (requires email) - `POST /api/design/generate` - AI design generation ### Webhooks -- `POST /api/webhooks/stripe` - Stripe payment events +- `POST /api/webhooks/mollie` - Mollie payment events - `POST /api/webhooks/prodigi` - Prodigi fulfillment updates - `POST /api/webhooks/printful` - Printful fulfillment updates diff --git a/README.md b/README.md index e427c92..6e9b829 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Merchandise store for the **rSpace ecosystem** at **rswag.online** - **Frontend**: Next.js 15 + shadcn/ui + Tailwind CSS + Geist font - **Backend**: FastAPI + SQLAlchemy + Alembic - **Database**: PostgreSQL -- **Payments**: Stripe Checkout +- **Payments**: Mollie (EU data residency) - **Fulfillment**: Printful (apparel) + Prodigi (stickers/prints) - **AI Design**: Gemini API for on-demand design generation @@ -24,7 +24,7 @@ rswag.online │ ┌───────────────┼───────────────┐ ▼ ▼ ▼ - PostgreSQL Stripe POD APIs + PostgreSQL Mollie POD APIs ``` ## Development diff --git a/backend/alembic/versions/002_stripe_to_mollie.py b/backend/alembic/versions/002_stripe_to_mollie.py new file mode 100644 index 0000000..9a3de22 --- /dev/null +++ b/backend/alembic/versions/002_stripe_to_mollie.py @@ -0,0 +1,36 @@ +"""Migrate from Stripe to Mollie payment provider + +Revision ID: 002_stripe_to_mollie +Revises: 001_initial +Create Date: 2026-02-18 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "002_stripe_to_mollie" +down_revision: Union[str, None] = "001_initial" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Rename Stripe-specific columns to generic payment columns + op.alter_column("orders", "stripe_session_id", new_column_name="payment_id") + op.alter_column("orders", "stripe_payment_intent_id", new_column_name="payment_method") + + # Add payment_provider column + op.add_column("orders", sa.Column("payment_provider", sa.String(50), nullable=True)) + + # Rename stripe_customer_id on customers table + op.alter_column("customers", "stripe_customer_id", new_column_name="external_id") + + +def downgrade() -> None: + op.alter_column("customers", "external_id", new_column_name="stripe_customer_id") + op.drop_column("orders", "payment_provider") + op.alter_column("orders", "payment_method", new_column_name="stripe_payment_intent_id") + op.alter_column("orders", "payment_id", new_column_name="stripe_session_id") diff --git a/backend/app/api/checkout.py b/backend/app/api/checkout.py index da2aba9..1baecec 100644 --- a/backend/app/api/checkout.py +++ b/backend/app/api/checkout.py @@ -1,18 +1,18 @@ """Checkout API endpoints.""" -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.schemas.order import CheckoutRequest, CheckoutResponse -from app.services.stripe_service import StripeService +from app.services.mollie_service import MollieService from app.services.cart_service import CartService router = APIRouter() -def get_stripe_service() -> StripeService: - return StripeService() +def get_mollie_service() -> MollieService: + return MollieService() def get_cart_service(db: AsyncSession = Depends(get_db)) -> CartService: @@ -21,27 +21,33 @@ def get_cart_service(db: AsyncSession = Depends(get_db)) -> CartService: @router.post("/session", response_model=CheckoutResponse) async def create_checkout_session( - request: CheckoutRequest, - stripe_service: StripeService = Depends(get_stripe_service), + checkout_request: CheckoutRequest, + request: Request, + mollie_service: MollieService = Depends(get_mollie_service), cart_service: CartService = Depends(get_cart_service), ): - """Create a Stripe checkout session.""" + """Create a Mollie payment session.""" # Get cart - cart = await cart_service.get_cart(request.cart_id) + cart = await cart_service.get_cart(checkout_request.cart_id) if not cart: raise HTTPException(status_code=404, detail="Cart not found") if not cart.items: raise HTTPException(status_code=400, detail="Cart is empty") - # Create Stripe session - result = await stripe_service.create_checkout_session( + # Build webhook URL from request origin + base_url = str(request.base_url).rstrip("/") + webhook_url = f"{base_url}/api/webhooks/mollie" + + # Create Mollie payment + result = await mollie_service.create_payment( cart=cart, - success_url=request.success_url, - cancel_url=request.cancel_url, + success_url=checkout_request.success_url, + cancel_url=checkout_request.cancel_url, + webhook_url=webhook_url, ) return CheckoutResponse( checkout_url=result["url"], - session_id=result["session_id"], + session_id=result["payment_id"], ) diff --git a/backend/app/api/health.py b/backend/app/api/health.py index 19ce57a..9630460 100644 --- a/backend/app/api/health.py +++ b/backend/app/api/health.py @@ -2,10 +2,20 @@ from fastapi import APIRouter +from app.config import get_settings +from app.services.flow_service import FlowService + router = APIRouter() +settings = get_settings() @router.get("/health") async def health_check(): """Health check endpoint.""" - return {"status": "healthy"} + flow_service = FlowService() + return { + "status": "healthy", + "payment_provider": "mollie", + "flow_enabled": flow_service.enabled, + "flow_revenue_split": settings.flow_revenue_split if flow_service.enabled else None, + } diff --git a/backend/app/api/webhooks.py b/backend/app/api/webhooks.py index 17f3aaa..8006740 100644 --- a/backend/app/api/webhooks.py +++ b/backend/app/api/webhooks.py @@ -1,51 +1,49 @@ -"""Webhook endpoints for Stripe and POD providers.""" +"""Webhook endpoints for Mollie and POD providers.""" from fastapi import APIRouter, Request, HTTPException, Depends from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db -from app.config import get_settings -from app.services.stripe_service import StripeService +from app.services.mollie_service import MollieService from app.services.order_service import OrderService router = APIRouter() -settings = get_settings() -def get_stripe_service() -> StripeService: - return StripeService() +def get_mollie_service() -> MollieService: + return MollieService() def get_order_service(db: AsyncSession = Depends(get_db)) -> OrderService: return OrderService(db) -@router.post("/stripe") -async def stripe_webhook( +@router.post("/mollie") +async def mollie_webhook( request: Request, - stripe_service: StripeService = Depends(get_stripe_service), + mollie_service: MollieService = Depends(get_mollie_service), order_service: OrderService = Depends(get_order_service), ): - """Handle Stripe webhook events.""" - payload = await request.body() - sig_header = request.headers.get("stripe-signature") + """Handle Mollie webhook events. - if not sig_header: - raise HTTPException(status_code=400, detail="Missing signature") + Mollie sends a POST with form data containing just the payment ID. + We then fetch the full payment details from Mollie's API to verify status. + """ + form = await request.form() + payment_id = form.get("id") - try: - event = stripe_service.verify_webhook(payload, sig_header) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) + if not payment_id: + raise HTTPException(status_code=400, detail="Missing payment id") - # Handle events - if event["type"] == "checkout.session.completed": - session = event["data"]["object"] - await order_service.handle_successful_payment(session) + # Fetch payment from Mollie API (this IS the verification — no signature needed) + payment = await mollie_service.get_payment(payment_id) - elif event["type"] == "payment_intent.payment_failed": - # Log failure, maybe send notification - pass + status = payment.get("status") + if status == "paid": + await order_service.handle_successful_payment(payment) + elif status in ("failed", "canceled", "expired"): + # Log but no action needed + print(f"Mollie payment {payment_id} status: {status}") return {"status": "ok"} diff --git a/backend/app/config.py b/backend/app/config.py index efabcc9..b9e84cb 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -21,16 +21,20 @@ class Settings(BaseSettings): # Redis redis_url: str = "redis://localhost:6379" - # Stripe - stripe_secret_key: str = "" - stripe_publishable_key: str = "" - stripe_webhook_secret: str = "" + # Mollie + mollie_api_key: str = "" # POD Providers prodigi_api_key: str = "" printful_api_token: str = "" pod_sandbox_mode: bool = True + # Flow Service (TBFF revenue split → bonding curve) + flow_service_url: str = "" + flow_id: str = "" + flow_funnel_id: str = "" + flow_revenue_split: float = 0.5 # fraction of margin routed to flow (0.0-1.0) + # Auth jwt_secret: str = "dev-secret-change-in-production" jwt_algorithm: str = "HS256" diff --git a/backend/app/models/customer.py b/backend/app/models/customer.py index a003d65..10a1578 100644 --- a/backend/app/models/customer.py +++ b/backend/app/models/customer.py @@ -19,7 +19,7 @@ class Customer(Base): UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) - stripe_customer_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + external_id: Mapped[str | None] = mapped_column(String(255), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) # Relationships diff --git a/backend/app/models/order.py b/backend/app/models/order.py index 16915ff..c8cbcf3 100644 --- a/backend/app/models/order.py +++ b/backend/app/models/order.py @@ -35,8 +35,12 @@ class Order(Base): customer_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("customers.id"), nullable=True ) - stripe_session_id: Mapped[str | None] = mapped_column(String(255), nullable=True) - stripe_payment_intent_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + + # Payment provider info (provider-agnostic) + payment_provider: Mapped[str | None] = mapped_column(String(50), nullable=True) + payment_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + payment_method: Mapped[str | None] = mapped_column(String(100), nullable=True) + status: Mapped[str] = mapped_column(String(50), default=OrderStatus.PENDING.value) # Shipping info diff --git a/backend/app/services/flow_service.py b/backend/app/services/flow_service.py new file mode 100644 index 0000000..d92a200 --- /dev/null +++ b/backend/app/services/flow_service.py @@ -0,0 +1,100 @@ +"""Flow service client for TBFF revenue routing. + +After a swag sale, the margin (sale price minus POD fulfillment cost) +gets deposited into a TBFF funnel via the flow-service. The flow-service +manages threshold-based distribution, and when the funnel overflows its +MAX threshold, excess funds route to the bonding curve. + +Revenue split flow: + Mollie payment → calculate margin → deposit to flow-service funnel + ↓ + TBFF thresholds + ↓ overflow + bonding curve ($MYCO) +""" + +import logging + +import httpx + +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + + +class FlowService: + """Client for the payment-infra flow-service.""" + + def __init__(self): + self.base_url = settings.flow_service_url.rstrip("/") + self.flow_id = settings.flow_id + self.funnel_id = settings.flow_funnel_id + self.enabled = bool(self.base_url and self.flow_id and self.funnel_id) + + async def deposit_revenue( + self, + amount: float, + currency: str = "USD", + order_id: str | None = None, + description: str | None = None, + ) -> dict | None: + """Deposit revenue margin into the TBFF funnel. + + Args: + amount: Fiat amount to deposit (post-split margin) + currency: Currency code (default USD) + order_id: rSwag order ID for traceability + description: Human-readable note + """ + if not self.enabled: + logger.info("Flow service not configured, skipping revenue deposit") + return None + + if amount <= 0: + return None + + payload = { + "funnelId": self.funnel_id, + "amount": round(amount, 2), + "currency": currency, + "source": "rswag", + "metadata": {}, + } + if order_id: + payload["metadata"]["order_id"] = order_id + if description: + payload["metadata"]["description"] = description + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + f"{self.base_url}/api/flows/{self.flow_id}/deposit", + json=payload, + ) + resp.raise_for_status() + result = resp.json() + logger.info( + f"Revenue deposited to flow: ${amount:.2f} {currency} " + f"(order={order_id})" + ) + return result + except httpx.HTTPError as e: + logger.error(f"Failed to deposit revenue to flow service: {e}") + return None + + async def get_flow_stats(self) -> dict | None: + """Get current flow stats (balance, thresholds, etc.).""" + if not self.enabled: + return None + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get( + f"{self.base_url}/api/flows/{self.flow_id}", + ) + resp.raise_for_status() + return resp.json() + except httpx.HTTPError as e: + logger.error(f"Failed to get flow stats: {e}") + return None diff --git a/backend/app/services/mollie_service.py b/backend/app/services/mollie_service.py new file mode 100644 index 0000000..9a63c99 --- /dev/null +++ b/backend/app/services/mollie_service.py @@ -0,0 +1,79 @@ +"""Mollie payment service.""" + +from mollie.api.client import Client + +from app.config import get_settings +from app.schemas.cart import CartResponse + +settings = get_settings() + + +class MollieService: + """Service for Mollie payment operations.""" + + def __init__(self): + self.client = Client() + if settings.mollie_api_key: + self.client.set_api_key(settings.mollie_api_key) + + async def create_payment( + self, + cart: CartResponse, + success_url: str, + cancel_url: str, + webhook_url: str, + ) -> dict: + """Create a Mollie payment. + + Mollie uses a redirect flow: create payment → redirect to hosted page → + webhook callback on completion → redirect to success URL. + """ + # Build description from cart items + item_names = [item.product_name for item in cart.items] + description = f"rSwag order: {', '.join(item_names[:3])}" + if len(item_names) > 3: + description += f" (+{len(item_names) - 3} more)" + + # Calculate total from cart + total = sum(item.unit_price * item.quantity for item in cart.items) + + payment = self.client.payments.create({ + "amount": { + "currency": "USD", + "value": f"{total:.2f}", + }, + "description": description, + "redirectUrl": f"{success_url}?payment_id={{paymentId}}", + "cancelUrl": cancel_url, + "webhookUrl": webhook_url, + "metadata": { + "cart_id": str(cart.id), + }, + }) + + return { + "url": payment["_links"]["checkout"]["href"], + "payment_id": payment["id"], + } + + async def get_payment(self, payment_id: str) -> dict: + """Get Mollie payment details.""" + payment = self.client.payments.get(payment_id) + return payment + + async def create_refund( + self, + payment_id: str, + amount: float | None = None, + currency: str = "USD", + ) -> dict: + """Create a refund for a Mollie payment.""" + payment = self.client.payments.get(payment_id) + refund_data = {} + if amount is not None: + refund_data["amount"] = { + "currency": currency, + "value": f"{amount:.2f}", + } + refund = self.client.payment_refunds.with_parent_id(payment_id).create(refund_data) + return refund diff --git a/backend/app/services/order_service.py b/backend/app/services/order_service.py index 5a22249..090f98a 100644 --- a/backend/app/services/order_service.py +++ b/backend/app/services/order_service.py @@ -1,5 +1,6 @@ """Order management service.""" +import logging from datetime import datetime from uuid import UUID @@ -7,10 +8,15 @@ from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.config import get_settings from app.models.order import Order, OrderItem, OrderStatus from app.models.customer import Customer from app.models.cart import Cart from app.schemas.order import OrderResponse, OrderItemResponse +from app.services.flow_service import FlowService + +logger = logging.getLogger(__name__) +settings = get_settings() class OrderService: @@ -87,9 +93,13 @@ class OrderService: await self.db.commit() return self._order_to_response(order) - async def handle_successful_payment(self, session: dict): - """Handle successful Stripe payment.""" - cart_id = session.get("metadata", {}).get("cart_id") + async def handle_successful_payment(self, payment: dict): + """Handle successful Mollie payment. + + Called by the Mollie webhook when payment status is 'paid'. + Mollie payment object contains metadata with cart_id. + """ + cart_id = payment.get("metadata", {}).get("cart_id") if not cart_id: return @@ -103,32 +113,21 @@ class OrderService: if not cart or not cart.items: return - # Get or create customer - email = session.get("customer_details", {}).get("email", "") - customer = await self._get_or_create_customer(email) - - # Get shipping details - shipping = session.get("shipping_details", {}) or {} - address = shipping.get("address", {}) or {} + # Extract amount from Mollie payment + amount = payment.get("amount", {}) + total = float(amount.get("value", "0")) + currency = amount.get("currency", "USD") # Create order order = Order( - customer_id=customer.id if customer else None, - stripe_session_id=session.get("id"), - stripe_payment_intent_id=session.get("payment_intent"), + payment_provider="mollie", + payment_id=payment.get("id"), + payment_method=payment.get("method"), status=OrderStatus.PAID.value, - shipping_name=shipping.get("name"), - shipping_email=email, - shipping_address_line1=address.get("line1"), - shipping_address_line2=address.get("line2"), - shipping_city=address.get("city"), - shipping_state=address.get("state"), - shipping_postal_code=address.get("postal_code"), - shipping_country=address.get("country"), - subtotal=float(session.get("amount_subtotal", 0)) / 100, - shipping_cost=float(session.get("shipping_cost", {}).get("amount_total", 0)) / 100, - total=float(session.get("amount_total", 0)) / 100, - currency=session.get("currency", "usd").upper(), + shipping_email=payment.get("metadata", {}).get("email", ""), + subtotal=total, + total=total, + currency=currency, paid_at=datetime.utcnow(), ) self.db.add(order) @@ -149,6 +148,9 @@ class OrderService: await self.db.commit() + # Route revenue margin to TBFF flow → bonding curve + await self._deposit_revenue_to_flow(order) + # TODO: Submit to POD providers # TODO: Send confirmation email @@ -175,6 +177,34 @@ class OrderService: ) await self.db.commit() + async def _deposit_revenue_to_flow(self, order: Order): + """Calculate margin and deposit to TBFF flow for bonding curve funding. + + Revenue split: + total sale - POD cost estimate = margin + margin × flow_revenue_split = amount deposited to flow + flow → Transak on-ramp → USDC → bonding curve → $MYCO + """ + split = settings.flow_revenue_split + if split <= 0: + return + + total = float(order.total) if order.total else 0 + if total <= 0: + return + + # Revenue split: configurable fraction of total goes to flow + # (POD costs + operational expenses kept as fiat remainder) + flow_amount = round(total * split, 2) + + flow_service = FlowService() + await flow_service.deposit_revenue( + amount=flow_amount, + currency=order.currency or "USD", + order_id=str(order.id), + description=f"rSwag sale revenue split ({split:.0%} of ${total:.2f})", + ) + async def _get_or_create_customer(self, email: str) -> Customer | None: """Get or create customer by email.""" if not email: diff --git a/backend/app/services/stripe_service.py b/backend/app/services/stripe_service.py deleted file mode 100644 index 7cd1403..0000000 --- a/backend/app/services/stripe_service.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Stripe payment service.""" - -import stripe - -from app.config import get_settings -from app.schemas.cart import CartResponse - -settings = get_settings() - -# Configure Stripe -stripe.api_key = settings.stripe_secret_key - - -class StripeService: - """Service for Stripe operations.""" - - async def create_checkout_session( - self, - cart: CartResponse, - success_url: str, - cancel_url: str, - ) -> dict: - """Create a Stripe checkout session.""" - line_items = [] - for item in cart.items: - line_items.append({ - "price_data": { - "currency": "usd", - "product_data": { - "name": item.product_name, - "description": f"Variant: {item.variant}" if item.variant else None, - }, - "unit_amount": int(item.unit_price * 100), # Convert to cents - }, - "quantity": item.quantity, - }) - - session = stripe.checkout.Session.create( - mode="payment", - line_items=line_items, - success_url=success_url + "?session_id={CHECKOUT_SESSION_ID}", - cancel_url=cancel_url, - shipping_address_collection={ - "allowed_countries": [ - "US", "CA", "GB", "AU", "DE", "FR", "NL", "BE", "AT", "CH", - "ES", "IT", "PT", "IE", "DK", "SE", "NO", "FI", "PL", "CZ", - ], - }, - metadata={ - "cart_id": str(cart.id), - }, - ) - - return { - "url": session.url, - "session_id": session.id, - } - - def verify_webhook(self, payload: bytes, sig_header: str) -> dict: - """Verify and parse Stripe webhook.""" - try: - event = stripe.Webhook.construct_event( - payload, - sig_header, - settings.stripe_webhook_secret, - ) - return event - except stripe.error.SignatureVerificationError as e: - raise ValueError(f"Invalid signature: {e}") - except Exception as e: - raise ValueError(f"Webhook error: {e}") - - async def get_session(self, session_id: str) -> dict: - """Get Stripe checkout session details.""" - session = stripe.checkout.Session.retrieve( - session_id, - expand=["line_items", "customer"], - ) - return session - - async def create_refund( - self, - payment_intent_id: str, - amount: int | None = None, - ) -> dict: - """Create a refund for a payment.""" - refund = stripe.Refund.create( - payment_intent=payment_intent_id, - amount=amount, # None = full refund - ) - return refund diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 50db5ae..53fcd12 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "python-jose[cryptography]>=3.3.0", "passlib[bcrypt]>=1.7.4", "httpx>=0.26.0", - "stripe>=7.0.0", + "mollie-api-python>=3.0.0", "pyyaml>=6.0.0", "pillow>=10.0.0", "python-multipart>=0.0.6", diff --git a/backend/requirements.txt b/backend/requirements.txt index 8315e90..39d6549 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -19,8 +19,8 @@ passlib[bcrypt]>=1.7.4 # HTTP Client httpx>=0.26.0 -# Stripe -stripe>=7.0.0 +# Payments (Mollie) +mollie-api-python>=3.0.0 # Config & Utils pyyaml>=6.0.0 diff --git a/backlog/config.yml b/backlog/config.yml new file mode 100644 index 0000000..2f83faf --- /dev/null +++ b/backlog/config.yml @@ -0,0 +1,16 @@ +project_name: "rSwag" +default_status: "To Do" +statuses: ["To Do", "In Progress", "Done"] +labels: [] +milestones: [] +date_format: yyyy-mm-dd +max_column_width: 20 +default_editor: "nvim" +auto_open_browser: true +default_port: 6420 +remote_operations: true +auto_commit: false +bypass_git_hooks: false +check_active_branches: true +active_branch_days: 30 +task_prefix: "task" diff --git a/backlog/tasks/task-1 - Configure-Mollie-API-key-for-production-payments.md b/backlog/tasks/task-1 - Configure-Mollie-API-key-for-production-payments.md new file mode 100644 index 0000000..cb5c5c8 --- /dev/null +++ b/backlog/tasks/task-1 - Configure-Mollie-API-key-for-production-payments.md @@ -0,0 +1,16 @@ +--- +id: TASK-1 +title: Configure Mollie API key for production payments +status: To Do +assignee: [] +created_date: '2026-02-18 19:51' +labels: [] +dependencies: [] +priority: high +--- + +## Description + + +Sign up at my.mollie.com, get live API key, add MOLLIE_API_KEY to /opt/apps/rswag/.env on Netcup. Configure webhook URL in Mollie dashboard pointing to https://rswag.online/api/webhooks/mollie (or fungiswag.jeffemmett.com equivalent). Test mode key starts with test_, live key starts with live_. + diff --git a/backlog/tasks/task-2 - Configure-Printful-and-Prodigi-API-keys.md b/backlog/tasks/task-2 - Configure-Printful-and-Prodigi-API-keys.md new file mode 100644 index 0000000..2fc9b04 --- /dev/null +++ b/backlog/tasks/task-2 - Configure-Printful-and-Prodigi-API-keys.md @@ -0,0 +1,16 @@ +--- +id: TASK-2 +title: Configure Printful and Prodigi API keys +status: To Do +assignee: [] +created_date: '2026-02-18 19:51' +labels: [] +dependencies: [] +priority: high +--- + +## Description + + +Add PRINTFUL_API_TOKEN and PRODIGI_API_KEY to .env. Currently empty — orders will be created but not submitted to POD providers. Also implement the POD client code in backend/app/pod/ to actually submit orders after Stripe payment. + diff --git a/backlog/tasks/task-3 - Replace-placeholder-Fungi-Flows-design-assets.md b/backlog/tasks/task-3 - Replace-placeholder-Fungi-Flows-design-assets.md new file mode 100644 index 0000000..8cd49bf --- /dev/null +++ b/backlog/tasks/task-3 - Replace-placeholder-Fungi-Flows-design-assets.md @@ -0,0 +1,16 @@ +--- +id: TASK-3 +title: Replace placeholder Fungi Flows design assets +status: To Do +assignee: [] +created_date: '2026-02-18 19:51' +labels: [] +dependencies: [] +priority: medium +--- + +## Description + + +Current fungi-logo-tee.png and fungi-spore.png are Pillow-generated placeholders. Replace with real artwork from Darren/Fungi Flows team. Designs at designs/shirts/fungi-logo-tee/ and designs/stickers/fungi-spore/. + diff --git a/backlog/tasks/task-4 - Integrate-EncryptID-authentication-for-rSwag.md b/backlog/tasks/task-4 - Integrate-EncryptID-authentication-for-rSwag.md new file mode 100644 index 0000000..0512f90 --- /dev/null +++ b/backlog/tasks/task-4 - Integrate-EncryptID-authentication-for-rSwag.md @@ -0,0 +1,16 @@ +--- +id: TASK-4 +title: Integrate EncryptID authentication for rSwag +status: To Do +assignee: [] +created_date: '2026-02-18 19:51' +labels: [] +dependencies: [] +priority: medium +--- + +## Description + + +Replace email/password admin auth with EncryptID passkeys to be consistent with other rApps (rWork, rFiles, rNotes). Use @encryptid/sdk, WebAuthn flow, DID-based user identity, space role checking. See /home/jeffe/Github/encryptid-sdk/ and rwork-online for patterns. + diff --git a/backlog/tasks/task-5 - Add-real-Printful-mockup-API-integration.md b/backlog/tasks/task-5 - Add-real-Printful-mockup-API-integration.md new file mode 100644 index 0000000..d540adb --- /dev/null +++ b/backlog/tasks/task-5 - Add-real-Printful-mockup-API-integration.md @@ -0,0 +1,16 @@ +--- +id: TASK-5 +title: Add real Printful mockup API integration +status: To Do +assignee: [] +created_date: '2026-02-18 19:51' +labels: [] +dependencies: [] +priority: low +--- + +## Description + + +Current upload page uses client-side Canvas compositing with simple template images. When Printful API token is configured, enhance with real Printful Mockup Generator API (POST /mockup-generator/create-task) for photorealistic product previews showing actual garment colors and fabric texture. + diff --git a/backlog/tasks/task-6 - Add-order-confirmation-emails.md b/backlog/tasks/task-6 - Add-order-confirmation-emails.md new file mode 100644 index 0000000..c998932 --- /dev/null +++ b/backlog/tasks/task-6 - Add-order-confirmation-emails.md @@ -0,0 +1,16 @@ +--- +id: TASK-6 +title: Add order confirmation emails +status: To Do +assignee: [] +created_date: '2026-02-18 19:51' +labels: [] +dependencies: [] +priority: medium +--- + +## Description + + +OrderService has TODO for sending confirmation emails after payment. Connect to Mailcow SMTP (mx.jeffemmett.com:587) or email-relay API. Send order confirmation with items, total, and tracking link. + diff --git a/backlog/tasks/task-7 - Set-up-auto-deploy-webhook-for-rSwag.md b/backlog/tasks/task-7 - Set-up-auto-deploy-webhook-for-rSwag.md new file mode 100644 index 0000000..b09a935 --- /dev/null +++ b/backlog/tasks/task-7 - Set-up-auto-deploy-webhook-for-rSwag.md @@ -0,0 +1,16 @@ +--- +id: TASK-7 +title: Set up auto-deploy webhook for rSwag +status: To Do +assignee: [] +created_date: '2026-02-18 19:51' +labels: [] +dependencies: [] +priority: medium +--- + +## Description + + +Add rswag entry to /opt/deploy-webhook/webhook.py REPOS dict and create Gitea webhook so pushes to main auto-deploy. Currently requires manual git pull + docker compose rebuild. + diff --git a/docker-compose.yml b/docker-compose.yml index f5ffff4..c2e8e4f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,8 +38,7 @@ services: environment: - DATABASE_URL=postgresql://rswag:${DB_PASSWORD:-devpassword}@db:5432/rswag - REDIS_URL=redis://redis:6379 - - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} - - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET} + - MOLLIE_API_KEY=${MOLLIE_API_KEY} - PRODIGI_API_KEY=${PRODIGI_API_KEY} - PRINTFUL_API_TOKEN=${PRINTFUL_API_TOKEN} - POD_SANDBOX_MODE=${POD_SANDBOX_MODE:-true} @@ -49,6 +48,10 @@ services: - CONFIG_PATH=/app/config - SPACES_PATH=/app/spaces - GEMINI_API_KEY=${GEMINI_API_KEY} + - FLOW_SERVICE_URL=${FLOW_SERVICE_URL:-} + - FLOW_ID=${FLOW_ID:-} + - FLOW_FUNNEL_ID=${FLOW_FUNNEL_ID:-} + - FLOW_REVENUE_SPLIT=${FLOW_REVENUE_SPLIT:-0.5} volumes: - ./designs:/app/designs - ./config:/app/config:ro @@ -73,7 +76,6 @@ services: dockerfile: Dockerfile args: - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api} - - NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY} container_name: rswag-frontend restart: unless-stopped environment: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 46bc457..40f454b 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -15,9 +15,7 @@ COPY . . ENV NEXT_TELEMETRY_DISABLED=1 ARG NEXT_PUBLIC_API_URL -ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL -ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY # Ensure public directory exists RUN mkdir -p public diff --git a/frontend/app/checkout/success/page.tsx b/frontend/app/checkout/success/page.tsx new file mode 100644 index 0000000..991ad41 --- /dev/null +++ b/frontend/app/checkout/success/page.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useEffect } from "react"; +import Link from "next/link"; +import { getSpaceIdFromCookie, getCartKey } from "@/lib/spaces"; + +export default function CheckoutSuccessPage() { + useEffect(() => { + // Clear cart after successful payment + const cartKey = getCartKey(getSpaceIdFromCookie()); + localStorage.removeItem(cartKey); + }, []); + + return ( +
+
+
+ + + +
+

Order Confirmed!

+

+ Thank you for your purchase. Your order is being processed and + you'll receive a confirmation email shortly. +

+ + Continue Shopping + +
+
+ ); +} diff --git a/frontend/package.json b/frontend/package.json index f26f4ce..8b4380d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,7 +22,6 @@ "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.4", - "@stripe/stripe-js": "^2.4.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.469.0",