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

View File

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

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

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."""
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"],
)

View File

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

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

View File

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

View File

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

View File

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

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."""
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:

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

View File

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

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

View File

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

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