real middleman: BTCPay payment gate + webhook + Printful adapter + checkout redirect
This commit is contained in:
@@ -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])
|
||||
|
||||
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}
|
||||
Reference in New Issue
Block a user