real middleman: BTCPay payment gate + webhook + Printful adapter + checkout redirect

This commit is contained in:
drjones
2026-08-26 16:52:04 -07:00
parent f2482c72fb
commit 3037f3c996
11 changed files with 242 additions and 10 deletions

View File

@@ -110,7 +110,7 @@ def route_order(db, order):
db.commit() db.commit()
return {"routed": False, "reason": "no eligible supplier for any item"} return {"routed": False, "reason": "no eligible supplier for any item"}
order.status = "confirmed" order.status = "pending_payment"
db.commit() db.commit()
return { return {
"routed": True, "routed": True,

View File

@@ -57,7 +57,7 @@ def build_adapter(supplier, data_or_url=""):
cls = ADAPTERS.get(supplier.adapter_type) cls = ADAPTERS.get(supplier.adapter_type)
if cls is None: if cls is None:
# lazy-import concrete adapters so their @register_adapter side effects run # lazy-import concrete adapters so their @register_adapter side effects run
from app.engines.suppliers import csv_adapter, sample # noqa: F401 from app.engines.suppliers import csv_adapter, printful, sample # noqa: F401
cls = ADAPTERS.get(supplier.adapter_type) cls = ADAPTERS.get(supplier.adapter_type)
if cls is None: if cls is None:

View File

@@ -0,0 +1,118 @@
"""Printful supplier adapter — real dropshipping fulfillment via the Printful API.
Docs: https://developers.printful.com — OAuth token or personal access token via
env PRINTFUL_API_KEY. Without a key the adapter reports `configured=False` and
create_order raises so nothing fake is ever placed.
Catalog/product APIs require a Printful account; order placement is the critical
real-money call and is fully implemented here.
"""
import os
import urllib.request
import urllib.parse
import json
from app.engines.suppliers.base import SupplierAdapter, register_adapter
PRINTFUL_API = "https://api.printful.com"
PRINTFUL_KEY = os.getenv("PRINTFUL_API_KEY", "")
def _req(method, path, body=None):
if not PRINTFUL_KEY:
raise RuntimeError("PRINTFUL_API_KEY not configured")
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{PRINTFUL_API}{path}",
data=data,
method=method,
headers={"Authorization": f"Bearer {PRINTFUL_KEY}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
@register_adapter
class PrintfulAdapter(SupplierAdapter):
adapter_type = "printful"
@property
def configured(self):
return bool(PRINTFUL_KEY)
def get_products(self):
"""List catalog products (paginated). Returns normalized product dicts."""
if not self.configured:
return []
out = []
offset = 0
while True:
d = _req("GET", f"/store/products?offset={offset}&limit=100")
items = d.get("result", [])
for p in items:
out.append({
"sku": str(p.get("id")),
"title": p.get("name", "Untitled"),
"description": "",
"category": p.get("type", "printful"),
"brand": "Printful",
"cost": float(p.get("price") or 0),
"inventory": 9999, # print-on-demand: made to order
"shipping_cost": 0.0,
"shipping_time": "2-5 business days",
"image_url": p.get("thumbnail_url") or "",
})
total = d.get("paging", {}).get("total", 0)
offset += len(items)
if offset >= total or not items:
break
return out
def get_inventory(self):
# Print-on-demand has no stock limits.
return []
def get_price(self, sku):
# per-variant pricing; catalog price is the base. Return None to keep cached cost.
return None
def create_order(self, items):
"""Submit a real order to Printful. items = [{supplier_sku, qty, ...}]."""
if not self.configured:
raise RuntimeError("Printful not configured — cannot place a real order")
# recipient comes from the order; MVP uses a placeholder that the caller
# must override with real shipping data before go-live.
payload = {
"recipient": {
"name": os.getenv("PRINTFUL_RECIPIENT_NAME", "Polaris Customer"),
"address1": os.getenv("PRINTFUL_RECIPIENT_ADDR1", ""),
"city": os.getenv("PRINTFUL_RECIPIENT_CITY", ""),
"state_code": os.getenv("PRINTFUL_RECIPIENT_STATE", ""),
"country_code": os.getenv("PRINTFUL_RECIPIENT_COUNTRY", "US"),
"zip": os.getenv("PRINTFUL_RECIPIENT_ZIP", ""),
},
"items": [
{"sync_variant_id": int(it["supplier_sku"]), "quantity": int(it["qty"])}
for it in items
],
}
d = _req("POST", "/orders", payload)
res = d.get("result", {})
return {"ref": str(res.get("id")), "status": res.get("status", "pending")}
def get_order_status(self, ref):
d = _req("GET", f"/orders/{ref}")
return d.get("result", {}).get("status", "unknown")
def get_tracking(self, ref):
d = _req("GET", f"/orders/{ref}")
res = d.get("result", {})
shipments = res.get("shipments", []) or []
if shipments:
s = shipments[0]
return {"tracking": s.get("tracking_number") or "", "carrier": s.get("carrier") or ""}
return None
def cancel_order(self, ref):
_req("DELETE", f"/orders/{ref}")
return {"status": "cancelled"}

View File

@@ -6,7 +6,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.database import Base, engine from app.database import Base, engine
from app.routers import admin, analytics, auth, customers, health, orders, products, suppliers from app.routers import admin, analytics, auth, customers, health, orders, products, suppliers, webhooks
from app.version import VERSION from app.version import VERSION
# Idempotent — schema.sql is canonical and already applied; create_all only # Idempotent — schema.sql is canonical and already applied; create_all only
@@ -31,6 +31,7 @@ app.include_router(orders.router, prefix="/api/orders", tags=["orders"])
app.include_router(customers.router, prefix="/api/customers", tags=["customers"]) app.include_router(customers.router, prefix="/api/customers", tags=["customers"])
app.include_router(admin.router, prefix="/api/admin", tags=["admin"]) app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
app.include_router(analytics.router, prefix="/api/analytics", tags=["analytics"]) app.include_router(analytics.router, prefix="/api/analytics", tags=["analytics"])
app.include_router(webhooks.router, prefix="/api/webhooks", tags=["webhooks"])
@app.get("/") @app.get("/")

View File

@@ -119,6 +119,8 @@ class Order(Base):
status = Column(Text, nullable=False, default="new") status = Column(Text, nullable=False, default="new")
tracking = Column(Text) tracking = Column(Text)
carrier = Column(Text) carrier = Column(Text)
btcpay_invoice_id = Column(Text)
supplier_order_ref = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -15,10 +15,11 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.engines.payments import btcpay
from app.routers.auth import require_admin from app.routers.auth import require_admin
from app.engines.order_router import route_order from app.engines.order_router import route_order
from app.models import AuditLog, Customer, Order, Product from app.models import AuditLog, Customer, Order, Product
from app.schemas import CustomerOrderOut, OrderCreate, OrderOut, TrackingIn from app.schemas import CheckoutResponse, CustomerOrderOut, OrderCreate, OrderOut, TrackingIn
router = APIRouter() router = APIRouter()
@@ -36,7 +37,7 @@ def _new_order_number() -> str:
return f"POL-{datetime.utcnow():%Y%m%d}-{uuid.uuid4().hex[:6].upper()}" return f"POL-{datetime.utcnow():%Y%m%d}-{uuid.uuid4().hex[:6].upper()}"
@router.post("", response_model=OrderOut) @router.post("", response_model=CheckoutResponse)
def create_order(body: OrderCreate, db: Session = Depends(get_db)): def create_order(body: OrderCreate, db: Session = Depends(get_db)):
email = body.customer_email.strip().lower() email = body.customer_email.strip().lower()
customer = db.query(Customer).filter(Customer.email == email).first() customer = db.query(Customer).filter(Customer.email == email).first()
@@ -69,14 +70,27 @@ def create_order(body: OrderCreate, db: Session = Depends(get_db)):
customer_id=customer.id, customer_id=customer.id,
items=items, items=items,
retail_total=retail_total, retail_total=retail_total,
status="new", status="pending_payment",
) )
db.add(order) db.add(order)
db.flush() db.flush()
result = route_order(db, order) result = route_order(db, order)
if not result.get("routed"):
raise HTTPException(status_code=400, detail="no eligible supplier for this order")
checkout_url = None
try:
inv = btcpay.create_invoice(float(order.retail_total), order.order_number)
order.btcpay_invoice_id = inv.get("invoice_id")
checkout_url = inv.get("checkout_url")
db.commit()
except Exception as e:
db.rollback()
raise HTTPException(status_code=502, detail=f"payment provider unavailable: {e}")
db.refresh(order) db.refresh(order)
return order return CheckoutResponse(order=order, checkout_url=checkout_url)
@router.get("", response_model=List[OrderOut]) @router.get("", response_model=List[OrderOut])

View File

@@ -0,0 +1,77 @@
"""BTCPay webhook router — advances orders through the money flow.
POST /api/webhooks/btcpay (signed by BTCPay)
InvoiceSettled -> order paid -> place supplier order -> status supplier_order
InvoiceExpired -> order cancelled (no payment)
InvoiceInvalid -> order cancelled (payment failed)
"""
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.engines.payments import btcpay
from app.engines.suppliers.base import build_adapter
from app.models import AuditLog, Order, Supplier
router = APIRouter()
def _get_order_by_invoice(db: Session, invoice_id: str):
return db.query(Order).filter(Order.btcpay_invoice_id == invoice_id).first()
def _place_supplier_order(db: Session, order: Order):
"""Call the supplier's real create_order and store the reference."""
if order.supplier_id is None:
order.status = "supplier_order"
db.add(AuditLog(actor="webhook", action="no_supplier", entity="order",
entity_id=order.order_number))
return
supplier = db.query(Supplier).filter(Supplier.id == order.supplier_id).first()
if supplier is None:
order.status = "supplier_order"
return
try:
adapter = build_adapter(supplier)
items = [
{"supplier_sku": it.get("sku"), "qty": it.get("qty", 1)}
for it in (order.items or [])
]
result = adapter.create_order(items)
order.supplier_order_ref = str(result.get("ref", ""))
order.status = "supplier_order"
db.add(AuditLog(actor="webhook", action="supplier_order_placed", entity="order",
entity_id=order.order_number,
detail={"supplier": supplier.name, "ref": order.supplier_order_ref}))
except Exception as e:
order.status = "paid" # paid but supplier order failed -> needs manual retry
db.add(AuditLog(actor="webhook", action="supplier_order_failed", entity="order",
entity_id=order.order_number, detail={"error": str(e)}))
@router.post("/btcpay")
async def btcpay_webhook(request: Request, db: Session = Depends(get_db)):
raw = await request.body()
sig = request.headers.get("btcpay-sig", "")
if not btcpay.verify_webhook(raw, sig):
raise HTTPException(status_code=401, detail="invalid signature")
payload = await request.json()
event_type = payload.get("type")
invoice_id = payload.get("invoiceId") or payload.get("id")
order = _get_order_by_invoice(db, invoice_id) if invoice_id else None
if order is None:
return {"ok": True, "matched": False}
if event_type == "InvoiceSettled":
order.status = "paid"
_place_supplier_order(db, order)
elif event_type in ("InvoiceExpired", "InvoiceInvalid"):
order.status = "cancelled"
db.commit()
db.add(AuditLog(actor="btcpay", action=event_type, entity="order",
entity_id=order.order_number, detail={"invoice_id": invoice_id}))
db.commit()
return {"ok": True, "order": order.order_number, "status": order.status}

View File

@@ -131,6 +131,8 @@ class OrderOut(ORMModel):
status: str status: str
tracking: Optional[str] = None tracking: Optional[str] = None
carrier: Optional[str] = None carrier: Optional[str] = None
btcpay_invoice_id: Optional[str] = None
supplier_order_ref: Optional[str] = None
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
@@ -147,6 +149,11 @@ class CustomerOrderOut(ORMModel):
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
class CheckoutResponse(BaseModel):
order: OrderOut
checkout_url: Optional[str] = None
class TrackingIn(BaseModel): class TrackingIn(BaseModel):
tracking: str tracking: str
carrier: Optional[str] = None carrier: Optional[str] = None

View File

@@ -77,9 +77,11 @@ CREATE TABLE orders (
shipping_cost NUMERIC(12,2) DEFAULT 0, shipping_cost NUMERIC(12,2) DEFAULT 0,
fees NUMERIC(12,2) DEFAULT 0, fees NUMERIC(12,2) DEFAULT 0,
profit NUMERIC(12,2) DEFAULT 0, profit NUMERIC(12,2) DEFAULT 0,
status TEXT NOT NULL DEFAULT 'new', -- new|paid|fraud_check|supplier_order|confirmed|shipped|delivered|cancelled|refunded status TEXT NOT NULL DEFAULT 'new', -- new|pending_payment|paid|fraud_check|supplier_order|confirmed|shipped|delivered|cancelled|refunded
tracking TEXT, tracking TEXT,
carrier TEXT, carrier TEXT,
btcpay_invoice_id TEXT,
supplier_order_ref TEXT,
created_at TIMESTAMPTZ DEFAULT now(), created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now() updated_at TIMESTAMPTZ DEFAULT now()
); );

View File

@@ -34,6 +34,12 @@ services:
SECRET_KEY: ${SECRET_KEY:-polaris-dev-secret-change-me} SECRET_KEY: ${SECRET_KEY:-polaris-dev-secret-change-me}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@polaris.local} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@polaris.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme}
BTCPAY_URL: https://10.30.20.140
BTCPAY_KEY: ${BTCPAY_KEY}
BTCPAY_STORE_ID: 3KJ1r5HK9MAiD5xvBapkv1iTHU1Ro9MdBc4XuPivQsev
BTCPAY_WEBHOOK_SECRET: polaris_btcpay_wh_9f3k2m8v7x
PRINTFUL_API_KEY: ${PRINTFUL_API_KEY:-}
STOREFRONT_URL: https://polaris.thetempleofdoom.com
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

View File

@@ -80,12 +80,17 @@ export default function Checkout() {
setPlacing(true); setPlacing(true);
setError(null); setError(null);
try { try {
const created = await api.post<Order>('/orders', { const created = await api.post<any>('/orders', {
customer_email: email, customer_email: email,
items: lines.map((l) => ({ product_id: l.product.id, qty: l.qty })), items: lines.map((l) => ({ product_id: l.product.id, qty: l.qty })),
}); });
clear(); clear();
setOrder(created); // redirect straight to the BTCPay payment page so the customer actually pays
if (created?.checkout_url) {
window.location.href = created.checkout_url;
return;
}
setOrder(created.order);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to place order'); setError(err instanceof Error ? err.message : 'Failed to place order');
} finally { } finally {