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:
parent
45287d66b3
commit
b437709510
14
.env.example
14
.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
|
||||
|
|
|
|||
10
CLAUDE.md
10
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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"],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue