real middleman: BTCPay payment gate + webhook + Printful adapter + checkout redirect
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
118
backend/app/engines/suppliers/printful.py
Normal file
118
backend/app/engines/suppliers/printful.py
Normal 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"}
|
||||||
@@ -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("/")
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
77
backend/app/routers/webhooks.py
Normal file
77
backend/app/routers/webhooks.py
Normal 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}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user