feat: replace Stripe with Mollie payments + TBFF revenue split to bonding curve

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-18 13:16:39 -07:00
parent 45287d66b3
commit b437709510
28 changed files with 536 additions and 183 deletions

View File

@ -1,10 +1,8 @@
# Database # Database
DB_PASSWORD=change_me_in_production DB_PASSWORD=change_me_in_production
# Stripe # Mollie Payments (https://my.mollie.com/dashboard)
STRIPE_SECRET_KEY=sk_test_xxx MOLLIE_API_KEY=test_xxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# POD Providers # POD Providers
PRODIGI_API_KEY=xxx PRODIGI_API_KEY=xxx
@ -20,6 +18,13 @@ CORS_ORIGINS=https://rswag.online
# AI Design Generation # AI Design Generation
GEMINI_API_KEY=xxx 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 Email
SMTP_HOST=mail.example.com SMTP_HOST=mail.example.com
SMTP_PORT=587 SMTP_PORT=587
@ -28,4 +33,3 @@ SMTP_PASS=changeme
# Frontend # Frontend
NEXT_PUBLIC_API_URL=https://rswag.online/api NEXT_PUBLIC_API_URL=https://rswag.online/api
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx

View File

@ -2,14 +2,14 @@
## Project Overview ## 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 ## Architecture
- **Frontend**: Next.js 15 App Router, shadcn/ui, Tailwind CSS, Geist font - **Frontend**: Next.js 15 App Router, shadcn/ui, Tailwind CSS, Geist font
- **Backend**: FastAPI, SQLAlchemy, Alembic - **Backend**: FastAPI, SQLAlchemy, Alembic
- **Database**: PostgreSQL - **Database**: PostgreSQL
- **Payments**: Stripe Checkout (redirect flow) - **Payments**: Mollie (redirect flow, Dutch data residency)
- **Fulfillment**: Printful (apparel), Prodigi (stickers/prints) - **Fulfillment**: Printful (apparel), Prodigi (stickers/prints)
- **AI Design**: Gemini API for design generation - **AI Design**: Gemini API for design generation
- **Deployment**: Docker on Netcup RS 8000, Traefik routing - **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/api/` | FastAPI route handlers |
| `backend/app/models/` | SQLAlchemy ORM models | | `backend/app/models/` | SQLAlchemy ORM models |
| `backend/app/schemas/` | Pydantic request/response schemas | | `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 | | `backend/app/pod/` | POD provider clients |
| `frontend/app/` | Next.js App Router pages | | `frontend/app/` | Next.js App Router pages |
| `frontend/components/` | React components | | `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`) - `GET /api/products` - List products with variants (optional: `?space=X`)
- `POST /api/cart` - Create cart - `POST /api/cart` - Create cart
- `GET/POST/DELETE /api/cart/{id}/items` - Cart operations - `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) - `GET /api/orders/{id}` - Order status (requires email)
- `POST /api/design/generate` - AI design generation - `POST /api/design/generate` - AI design generation
### Webhooks ### 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/prodigi` - Prodigi fulfillment updates
- `POST /api/webhooks/printful` - Printful fulfillment updates - `POST /api/webhooks/printful` - Printful fulfillment updates

View File

@ -7,7 +7,7 @@ Merchandise store for the **rSpace ecosystem** at **rswag.online**
- **Frontend**: Next.js 15 + shadcn/ui + Tailwind CSS + Geist font - **Frontend**: Next.js 15 + shadcn/ui + Tailwind CSS + Geist font
- **Backend**: FastAPI + SQLAlchemy + Alembic - **Backend**: FastAPI + SQLAlchemy + Alembic
- **Database**: PostgreSQL - **Database**: PostgreSQL
- **Payments**: Stripe Checkout - **Payments**: Mollie (EU data residency)
- **Fulfillment**: Printful (apparel) + Prodigi (stickers/prints) - **Fulfillment**: Printful (apparel) + Prodigi (stickers/prints)
- **AI Design**: Gemini API for on-demand design generation - **AI Design**: Gemini API for on-demand design generation
@ -24,7 +24,7 @@ rswag.online
┌───────────────┼───────────────┐ ┌───────────────┼───────────────┐
▼ ▼ ▼ ▼ ▼ ▼
PostgreSQL Stripe POD APIs PostgreSQL Mollie POD APIs
``` ```
## Development ## Development

View File

@ -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")

View File

@ -1,18 +1,18 @@
"""Checkout API endpoints.""" """Checkout API endpoints."""
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.schemas.order import CheckoutRequest, CheckoutResponse 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 from app.services.cart_service import CartService
router = APIRouter() router = APIRouter()
def get_stripe_service() -> StripeService: def get_mollie_service() -> MollieService:
return StripeService() return MollieService()
def get_cart_service(db: AsyncSession = Depends(get_db)) -> CartService: 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) @router.post("/session", response_model=CheckoutResponse)
async def create_checkout_session( async def create_checkout_session(
request: CheckoutRequest, checkout_request: CheckoutRequest,
stripe_service: StripeService = Depends(get_stripe_service), request: Request,
mollie_service: MollieService = Depends(get_mollie_service),
cart_service: CartService = Depends(get_cart_service), cart_service: CartService = Depends(get_cart_service),
): ):
"""Create a Stripe checkout session.""" """Create a Mollie payment session."""
# Get cart # Get cart
cart = await cart_service.get_cart(request.cart_id) cart = await cart_service.get_cart(checkout_request.cart_id)
if not cart: if not cart:
raise HTTPException(status_code=404, detail="Cart not found") raise HTTPException(status_code=404, detail="Cart not found")
if not cart.items: if not cart.items:
raise HTTPException(status_code=400, detail="Cart is empty") raise HTTPException(status_code=400, detail="Cart is empty")
# Create Stripe session # Build webhook URL from request origin
result = await stripe_service.create_checkout_session( 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, cart=cart,
success_url=request.success_url, success_url=checkout_request.success_url,
cancel_url=request.cancel_url, cancel_url=checkout_request.cancel_url,
webhook_url=webhook_url,
) )
return CheckoutResponse( return CheckoutResponse(
checkout_url=result["url"], checkout_url=result["url"],
session_id=result["session_id"], session_id=result["payment_id"],
) )

View File

@ -2,10 +2,20 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.config import get_settings
from app.services.flow_service import FlowService
router = APIRouter() router = APIRouter()
settings = get_settings()
@router.get("/health") @router.get("/health")
async def health_check(): async def health_check():
"""Health check endpoint.""" """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,
}

View File

@ -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 fastapi import APIRouter, Request, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.config import get_settings from app.services.mollie_service import MollieService
from app.services.stripe_service import StripeService
from app.services.order_service import OrderService from app.services.order_service import OrderService
router = APIRouter() router = APIRouter()
settings = get_settings()
def get_stripe_service() -> StripeService: def get_mollie_service() -> MollieService:
return StripeService() return MollieService()
def get_order_service(db: AsyncSession = Depends(get_db)) -> OrderService: def get_order_service(db: AsyncSession = Depends(get_db)) -> OrderService:
return OrderService(db) return OrderService(db)
@router.post("/stripe") @router.post("/mollie")
async def stripe_webhook( async def mollie_webhook(
request: Request, request: Request,
stripe_service: StripeService = Depends(get_stripe_service), mollie_service: MollieService = Depends(get_mollie_service),
order_service: OrderService = Depends(get_order_service), order_service: OrderService = Depends(get_order_service),
): ):
"""Handle Stripe webhook events.""" """Handle Mollie webhook events.
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
if not sig_header: Mollie sends a POST with form data containing just the payment ID.
raise HTTPException(status_code=400, detail="Missing signature") We then fetch the full payment details from Mollie's API to verify status.
"""
form = await request.form()
payment_id = form.get("id")
try: if not payment_id:
event = stripe_service.verify_webhook(payload, sig_header) raise HTTPException(status_code=400, detail="Missing payment id")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Handle events # Fetch payment from Mollie API (this IS the verification — no signature needed)
if event["type"] == "checkout.session.completed": payment = await mollie_service.get_payment(payment_id)
session = event["data"]["object"]
await order_service.handle_successful_payment(session)
elif event["type"] == "payment_intent.payment_failed": status = payment.get("status")
# Log failure, maybe send notification if status == "paid":
pass 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"} return {"status": "ok"}

View File

@ -21,16 +21,20 @@ class Settings(BaseSettings):
# Redis # Redis
redis_url: str = "redis://localhost:6379" redis_url: str = "redis://localhost:6379"
# Stripe # Mollie
stripe_secret_key: str = "" mollie_api_key: str = ""
stripe_publishable_key: str = ""
stripe_webhook_secret: str = ""
# POD Providers # POD Providers
prodigi_api_key: str = "" prodigi_api_key: str = ""
printful_api_token: str = "" printful_api_token: str = ""
pod_sandbox_mode: bool = True 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 # Auth
jwt_secret: str = "dev-secret-change-in-production" jwt_secret: str = "dev-secret-change-in-production"
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"

View File

@ -19,7 +19,7 @@ class Customer(Base):
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
) )
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) 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) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships # Relationships

View File

@ -35,8 +35,12 @@ class Order(Base):
customer_id: Mapped[uuid.UUID | None] = mapped_column( customer_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("customers.id"), nullable=True 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) status: Mapped[str] = mapped_column(String(50), default=OrderStatus.PENDING.value)
# Shipping info # Shipping info

View File

@ -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

View File

@ -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

View File

@ -1,5 +1,6 @@
"""Order management service.""" """Order management service."""
import logging
from datetime import datetime from datetime import datetime
from uuid import UUID from uuid import UUID
@ -7,10 +8,15 @@ from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.config import get_settings
from app.models.order import Order, OrderItem, OrderStatus from app.models.order import Order, OrderItem, OrderStatus
from app.models.customer import Customer from app.models.customer import Customer
from app.models.cart import Cart from app.models.cart import Cart
from app.schemas.order import OrderResponse, OrderItemResponse from app.schemas.order import OrderResponse, OrderItemResponse
from app.services.flow_service import FlowService
logger = logging.getLogger(__name__)
settings = get_settings()
class OrderService: class OrderService:
@ -87,9 +93,13 @@ class OrderService:
await self.db.commit() await self.db.commit()
return self._order_to_response(order) return self._order_to_response(order)
async def handle_successful_payment(self, session: dict): async def handle_successful_payment(self, payment: dict):
"""Handle successful Stripe payment.""" """Handle successful Mollie payment.
cart_id = session.get("metadata", {}).get("cart_id")
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: if not cart_id:
return return
@ -103,32 +113,21 @@ class OrderService:
if not cart or not cart.items: if not cart or not cart.items:
return return
# Get or create customer # Extract amount from Mollie payment
email = session.get("customer_details", {}).get("email", "") amount = payment.get("amount", {})
customer = await self._get_or_create_customer(email) total = float(amount.get("value", "0"))
currency = amount.get("currency", "USD")
# Get shipping details
shipping = session.get("shipping_details", {}) or {}
address = shipping.get("address", {}) or {}
# Create order # Create order
order = Order( order = Order(
customer_id=customer.id if customer else None, payment_provider="mollie",
stripe_session_id=session.get("id"), payment_id=payment.get("id"),
stripe_payment_intent_id=session.get("payment_intent"), payment_method=payment.get("method"),
status=OrderStatus.PAID.value, status=OrderStatus.PAID.value,
shipping_name=shipping.get("name"), shipping_email=payment.get("metadata", {}).get("email", ""),
shipping_email=email, subtotal=total,
shipping_address_line1=address.get("line1"), total=total,
shipping_address_line2=address.get("line2"), currency=currency,
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(),
paid_at=datetime.utcnow(), paid_at=datetime.utcnow(),
) )
self.db.add(order) self.db.add(order)
@ -149,6 +148,9 @@ class OrderService:
await self.db.commit() 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: Submit to POD providers
# TODO: Send confirmation email # TODO: Send confirmation email
@ -175,6 +177,34 @@ class OrderService:
) )
await self.db.commit() 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: async def _get_or_create_customer(self, email: str) -> Customer | None:
"""Get or create customer by email.""" """Get or create customer by email."""
if not email: if not email:

View File

@ -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

View File

@ -17,7 +17,7 @@ dependencies = [
"python-jose[cryptography]>=3.3.0", "python-jose[cryptography]>=3.3.0",
"passlib[bcrypt]>=1.7.4", "passlib[bcrypt]>=1.7.4",
"httpx>=0.26.0", "httpx>=0.26.0",
"stripe>=7.0.0", "mollie-api-python>=3.0.0",
"pyyaml>=6.0.0", "pyyaml>=6.0.0",
"pillow>=10.0.0", "pillow>=10.0.0",
"python-multipart>=0.0.6", "python-multipart>=0.0.6",

View File

@ -19,8 +19,8 @@ passlib[bcrypt]>=1.7.4
# HTTP Client # HTTP Client
httpx>=0.26.0 httpx>=0.26.0
# Stripe # Payments (Mollie)
stripe>=7.0.0 mollie-api-python>=3.0.0
# Config & Utils # Config & Utils
pyyaml>=6.0.0 pyyaml>=6.0.0

16
backlog/config.yml Normal file
View File

@ -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"

View File

@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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_.
<!-- SECTION:DESCRIPTION:END -->

View File

@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->

View File

@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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/.
<!-- SECTION:DESCRIPTION:END -->

View File

@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->

View File

@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->

View File

@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->

View File

@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->

View File

@ -38,8 +38,7 @@ services:
environment: environment:
- DATABASE_URL=postgresql://rswag:${DB_PASSWORD:-devpassword}@db:5432/rswag - DATABASE_URL=postgresql://rswag:${DB_PASSWORD:-devpassword}@db:5432/rswag
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} - MOLLIE_API_KEY=${MOLLIE_API_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- PRODIGI_API_KEY=${PRODIGI_API_KEY} - PRODIGI_API_KEY=${PRODIGI_API_KEY}
- PRINTFUL_API_TOKEN=${PRINTFUL_API_TOKEN} - PRINTFUL_API_TOKEN=${PRINTFUL_API_TOKEN}
- POD_SANDBOX_MODE=${POD_SANDBOX_MODE:-true} - POD_SANDBOX_MODE=${POD_SANDBOX_MODE:-true}
@ -49,6 +48,10 @@ services:
- CONFIG_PATH=/app/config - CONFIG_PATH=/app/config
- SPACES_PATH=/app/spaces - SPACES_PATH=/app/spaces
- GEMINI_API_KEY=${GEMINI_API_KEY} - 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: volumes:
- ./designs:/app/designs - ./designs:/app/designs
- ./config:/app/config:ro - ./config:/app/config:ro
@ -73,7 +76,6 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api} - 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 container_name: rswag-frontend
restart: unless-stopped restart: unless-stopped
environment: environment:

View File

@ -15,9 +15,7 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_API_URL ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
# Ensure public directory exists # Ensure public directory exists
RUN mkdir -p public RUN mkdir -p public

View File

@ -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 (
<div className="container mx-auto px-4 py-16 text-center">
<div className="max-w-md mx-auto">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg
className="w-8 h-8 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h1 className="text-2xl font-bold mb-4">Order Confirmed!</h1>
<p className="text-muted-foreground mb-8">
Thank you for your purchase. Your order is being processed and
you&apos;ll receive a confirmation email shortly.
</p>
<Link
href="/products"
className="inline-flex items-center justify-center rounded-md bg-primary px-6 py-3 font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Continue Shopping
</Link>
</div>
</div>
);
}

View File

@ -22,7 +22,6 @@
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-toast": "^1.2.4",
"@stripe/stripe-js": "^2.4.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",