diff --git a/backend/app/engines/order_router.py b/backend/app/engines/order_router.py index 3eeaaaa..547b5c4 100644 --- a/backend/app/engines/order_router.py +++ b/backend/app/engines/order_router.py @@ -110,7 +110,7 @@ def route_order(db, order): db.commit() return {"routed": False, "reason": "no eligible supplier for any item"} - order.status = "confirmed" + order.status = "pending_payment" db.commit() return { "routed": True, diff --git a/backend/app/engines/suppliers/base.py b/backend/app/engines/suppliers/base.py index a903f61..8b249c8 100644 --- a/backend/app/engines/suppliers/base.py +++ b/backend/app/engines/suppliers/base.py @@ -57,7 +57,7 @@ def build_adapter(supplier, data_or_url=""): cls = ADAPTERS.get(supplier.adapter_type) if cls is None: # 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) if cls is None: diff --git a/backend/app/engines/suppliers/printful.py b/backend/app/engines/suppliers/printful.py new file mode 100644 index 0000000..c8eaa5f --- /dev/null +++ b/backend/app/engines/suppliers/printful.py @@ -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"} diff --git a/backend/app/main.py b/backend/app/main.py index cec448b..fa79caa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware 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 # 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(admin.router, prefix="/api/admin", tags=["admin"]) app.include_router(analytics.router, prefix="/api/analytics", tags=["analytics"]) +app.include_router(webhooks.router, prefix="/api/webhooks", tags=["webhooks"]) @app.get("/") diff --git a/backend/app/models.py b/backend/app/models.py index c0d544d..cfc5c4b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -119,6 +119,8 @@ class Order(Base): status = Column(Text, nullable=False, default="new") tracking = 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()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/routers/orders.py b/backend/app/routers/orders.py index f288931..4a36f7f 100644 --- a/backend/app/routers/orders.py +++ b/backend/app/routers/orders.py @@ -15,10 +15,11 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from app.database import get_db +from app.engines.payments import btcpay from app.routers.auth import require_admin from app.engines.order_router import route_order 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() @@ -36,7 +37,7 @@ def _new_order_number() -> str: 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)): email = body.customer_email.strip().lower() 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, items=items, retail_total=retail_total, - status="new", + status="pending_payment", ) db.add(order) db.flush() 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) - return order + return CheckoutResponse(order=order, checkout_url=checkout_url) @router.get("", response_model=List[OrderOut]) diff --git a/backend/app/routers/webhooks.py b/backend/app/routers/webhooks.py new file mode 100644 index 0000000..46f0775 --- /dev/null +++ b/backend/app/routers/webhooks.py @@ -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} diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 2e62ff8..ba4f559 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -131,6 +131,8 @@ class OrderOut(ORMModel): status: str tracking: Optional[str] = None carrier: Optional[str] = None + btcpay_invoice_id: Optional[str] = None + supplier_order_ref: Optional[str] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None @@ -147,6 +149,11 @@ class CustomerOrderOut(ORMModel): updated_at: Optional[datetime] = None +class CheckoutResponse(BaseModel): + order: OrderOut + checkout_url: Optional[str] = None + + class TrackingIn(BaseModel): tracking: str carrier: Optional[str] = None diff --git a/db/schema.sql b/db/schema.sql index 31c0c46..0f1805a 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -77,9 +77,11 @@ CREATE TABLE orders ( shipping_cost NUMERIC(12,2) DEFAULT 0, fees 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, carrier TEXT, + btcpay_invoice_id TEXT, + supplier_order_ref TEXT, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); diff --git a/docker-compose.yml b/docker-compose.yml index 5e57442..0368c74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,12 @@ services: SECRET_KEY: ${SECRET_KEY:-polaris-dev-secret-change-me} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@polaris.local} 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: postgres: condition: service_healthy diff --git a/frontend/src/pages/Checkout.tsx b/frontend/src/pages/Checkout.tsx index 9a8b3ce..0d7142d 100644 --- a/frontend/src/pages/Checkout.tsx +++ b/frontend/src/pages/Checkout.tsx @@ -80,12 +80,17 @@ export default function Checkout() { setPlacing(true); setError(null); try { - const created = await api.post('/orders', { + const created = await api.post('/orders', { customer_email: email, items: lines.map((l) => ({ product_id: l.product.id, qty: l.qty })), }); 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) { setError(err instanceof Error ? err.message : 'Failed to place order'); } finally {