Merge branch 'agent/bc90d3f481b4-purchase-escrow-service' into main
checkpoint / checkpoint (push) Successful in 5m41s

This commit is contained in:
2026-09-15 20:41:15 +00:00
4 changed files with 674 additions and 8 deletions
+185 -1
View File
@@ -6,7 +6,7 @@ docs/planning/economy.md US-3 (task f51c7d95e237).
from datetime import datetime from datetime import datetime
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,11 +15,24 @@ from app.database import get_db
from app.dependencies import CurrentUser, OptionalUser from app.dependencies import CurrentUser, OptionalUser
from app.models.marketplace import ( from app.models.marketplace import (
CatalogItem, CatalogItem,
CreatorBalance,
ItemType, ItemType,
ListingStatus, ListingStatus,
MarketplaceListing, MarketplaceListing,
Purchase,
PurchaseStatus,
) )
from app.models.user import User from app.models.user import User
from app.services.purchase import (
InvalidEscrowStateError,
ListingNotAvailableError,
ListingNotFoundError,
PurchaseNotFoundError,
PurchaseService,
SelfPurchaseError,
purchase_service,
)
from app.services.wallet import InsufficientFundsError
router = APIRouter() router = APIRouter()
@@ -80,6 +93,36 @@ class ListingListResponse(BaseModel):
offset: int offset: int
class PurchaseResponse(BaseModel):
id: int
listing_id: int | None
buyer_id: int
seller_id: int
price: int
platform_fee: int
seller_net: int
status: PurchaseStatus
escrow_hold_until: datetime | None
created_at: datetime
granted_item_id: int | None = None
model_config = {"from_attributes": True}
class CreatorBalanceResponse(BaseModel):
user_id: int
available: int
pending: int
lifetime_earned: int
updated_at: datetime
model_config = {"from_attributes": True}
class RefundRequest(BaseModel):
reason: str | None = Field(default=None, max_length=500)
# ── Catalog Items ── # ── Catalog Items ──
@router.post("/items", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED) @router.post("/items", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED)
@@ -326,3 +369,144 @@ async def delete_or_delist_listing(
listing.updated_at = datetime.now() listing.updated_at = datetime.now()
await db.commit() await db.commit()
return None return None
# ── Purchases & Escrow ──
@router.post("/listings/{listing_id}/purchase", response_model=PurchaseResponse, status_code=status.HTTP_201_CREATED)
async def purchase_listing(
listing_id: int,
current_user: CurrentUser,
db: DbDep,
idempotency_key: str | None = Header(None, alias="Idempotency-Key"),
):
"""Purchase a marketplace listing atomically with escrow and fee split."""
try:
purchase, granted_item = await purchase_service.purchase_listing(
db=db,
buyer_id=current_user.id,
listing_id=listing_id,
idempotency_key=idempotency_key,
)
except ListingNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except (ListingNotAvailableError, SelfPurchaseError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
except InsufficientFundsError as exc:
raise HTTPException(status_code=400, detail=str(exc))
resp = PurchaseResponse.model_validate(purchase)
resp.granted_item_id = granted_item.id
return resp
@router.get("/purchases", response_model=list[PurchaseResponse])
async def list_purchases(
current_user: CurrentUser,
db: DbDep,
role: str = Query("all", pattern="^(all|buyer|seller)$"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""List purchases where the authenticated user is the buyer or seller."""
stmt = select(Purchase)
if role == "buyer":
stmt = stmt.where(Purchase.buyer_id == current_user.id)
elif role == "seller":
stmt = stmt.where(Purchase.seller_id == current_user.id)
else:
stmt = stmt.where(
or_(Purchase.buyer_id == current_user.id, Purchase.seller_id == current_user.id)
)
stmt = stmt.order_by(Purchase.created_at.desc(), Purchase.id.desc()).offset(offset).limit(limit)
res = await db.execute(stmt)
return list(res.scalars().all())
@router.get("/purchases/{purchase_id}", response_model=PurchaseResponse)
async def get_purchase(
purchase_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Get details of a purchase record."""
purchase = await db.get(Purchase, purchase_id)
if purchase is None:
raise HTTPException(status_code=404, detail="Purchase not found")
if (
purchase.buyer_id != current_user.id
and purchase.seller_id != current_user.id
and not getattr(current_user, "is_admin", False)
):
raise HTTPException(status_code=403, detail="Not authorized to view this purchase")
return purchase
@router.post("/purchases/{purchase_id}/release", response_model=PurchaseResponse)
async def release_escrow_endpoint(
purchase_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Release escrow hold for a purchase to seller's available balance."""
purchase = await db.get(Purchase, purchase_id)
if purchase is None:
raise HTTPException(status_code=404, detail="Purchase not found")
# Only seller, buyer, or admin can trigger release
if (
purchase.seller_id != current_user.id
and purchase.buyer_id != current_user.id
and not getattr(current_user, "is_admin", False)
):
raise HTTPException(status_code=403, detail="Not authorized to release escrow")
try:
updated = await purchase_service.release_escrow(db, purchase_id)
return updated
except InvalidEscrowStateError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.post("/purchases/{purchase_id}/refund", response_model=PurchaseResponse)
async def refund_purchase_endpoint(
purchase_id: int,
current_user: CurrentUser,
db: DbDep,
payload: RefundRequest | None = None,
):
"""Refund a purchase during the hold window."""
purchase = await db.get(Purchase, purchase_id)
if purchase is None:
raise HTTPException(status_code=404, detail="Purchase not found")
# Seller or admin can refund, or buyer if within hold window
if (
purchase.seller_id != current_user.id
and purchase.buyer_id != current_user.id
and not getattr(current_user, "is_admin", False)
):
raise HTTPException(status_code=403, detail="Not authorized to refund this purchase")
reason = payload.reason if payload else None
try:
refunded = await purchase_service.refund_purchase(db, purchase_id, reason=reason)
return refunded
except InvalidEscrowStateError as exc:
raise HTTPException(status_code=400, detail=str(exc))
# ── Creator Balance ──
@router.get("/creator/balance", response_model=CreatorBalanceResponse)
async def get_my_creator_balance(
current_user: CurrentUser,
db: DbDep,
):
"""Get the current user's creator balance (available, pending, lifetime_earned)."""
cb = await purchase_service.get_or_create_creator_balance(db, current_user.id)
return cb
+251
View File
@@ -0,0 +1,251 @@
"""Marketplace purchase service: atomic escrow, fee split, item granting, refunds.
docs/planning/economy.md US-4 (task bc90d3f481b4).
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import math
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.marketplace import (
CatalogItem,
CreatorBalance,
ListingStatus,
MarketplaceListing,
Purchase,
PurchaseStatus,
)
from app.models.wallet import TransactionType
from app.services.wallet import InsufficientFundsError, WalletService
MARKETPLACE_FEE_RATE = 0.30 # 30% platform fee
DEFAULT_ESCROW_HOLD_HOURS = 72
class ListingNotFoundError(Exception):
"""Raised when the specified listing does not exist."""
pass
class ListingNotAvailableError(Exception):
"""Raised when the listing is not in active status."""
pass
class SelfPurchaseError(Exception):
"""Raised when a seller attempts to buy their own listing."""
pass
class PurchaseNotFoundError(Exception):
"""Raised when the specified purchase does not exist."""
pass
class InvalidEscrowStateError(Exception):
"""Raised when an operation cannot be performed on the current escrow state."""
pass
class PurchaseService:
@staticmethod
def compute_fee_split(price: int, fee_rate: float = MARKETPLACE_FEE_RATE) -> tuple[int, int]:
"""Compute platform fee and seller net amount.
Returns (platform_fee, seller_net).
"""
platform_fee = math.floor(price * fee_rate)
seller_net = price - platform_fee
return platform_fee, seller_net
@classmethod
async def get_or_create_creator_balance(
cls, db: AsyncSession, user_id: int, for_update: bool = False
) -> CreatorBalance:
"""Fetch or lazily create a creator balance row for user_id."""
stmt = select(CreatorBalance).where(CreatorBalance.user_id == user_id)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
cb = result.scalar_one_or_none()
if cb is not None:
return cb
new_cb = CreatorBalance(
user_id=user_id,
available=0,
pending=0,
lifetime_earned=0,
)
db.add(new_cb)
await db.flush()
return new_cb
@classmethod
async def purchase_listing(
cls,
db: AsyncSession,
buyer_id: int,
listing_id: int,
idempotency_key: str | None = None,
escrow_hours: int = DEFAULT_ESCROW_HOLD_HOURS,
) -> tuple[Purchase, CatalogItem]:
"""Perform atomic purchase of a marketplace listing.
Debits buyer, holds escrow, grants item to buyer, records purchase,
and updates creator pending balance.
"""
# 1. Lock and validate listing
stmt_listing = (
select(MarketplaceListing)
.where(MarketplaceListing.id == listing_id)
.with_for_update()
)
res_listing = await db.execute(stmt_listing)
listing = res_listing.scalar_one_or_none()
if listing is None:
raise ListingNotFoundError(f"Listing {listing_id} not found")
if listing.status != ListingStatus.active:
raise ListingNotAvailableError(f"Listing {listing_id} is not active ({listing.status.value})")
if listing.seller_id == buyer_id:
raise SelfPurchaseError("Cannot purchase your own listing")
# 2. Fetch catalog item
catalog_item = await db.get(CatalogItem, listing.catalog_item_id)
if catalog_item is None:
raise ListingNotFoundError("Catalog item referenced by listing does not exist")
# 3. Calculate fee split
price = listing.price
platform_fee, seller_net = cls.compute_fee_split(price)
# 4. Debit buyer wallet atomically (commit=False)
tx_idemp = f"mkt-purchase-{idempotency_key}" if idempotency_key else None
await WalletService.debit(
db=db,
user_id=buyer_id,
amount=price,
tx_type=TransactionType.marketplace_purchase,
reference_type="marketplace_listing",
reference_id=str(listing.id),
idempotency_key=tx_idemp,
commit=False,
)
# 5. Grant catalog item to buyer
granted_item = CatalogItem(
owner_id=buyer_id,
asset_id=catalog_item.asset_id,
item_type=catalog_item.item_type,
name=catalog_item.name,
description=catalog_item.description,
metadata_json=catalog_item.metadata_json,
)
db.add(granted_item)
# 6. Mark listing as sold
now = datetime.now(timezone.utc)
listing.status = ListingStatus.sold
listing.updated_at = now
# 7. Create purchase record
hold_until = now + timedelta(hours=escrow_hours)
purchase = Purchase(
listing_id=listing.id,
buyer_id=buyer_id,
seller_id=listing.seller_id,
price=price,
platform_fee=platform_fee,
seller_net=seller_net,
status=PurchaseStatus.pending_escrow,
escrow_hold_until=hold_until,
)
db.add(purchase)
# 8. Update seller's CreatorBalance (pending escrow)
creator_balance = await cls.get_or_create_creator_balance(
db, listing.seller_id, for_update=True
)
creator_balance.pending += seller_net
creator_balance.updated_at = now
# Commit everything atomically
await db.commit()
await db.refresh(purchase)
await db.refresh(granted_item)
return purchase, granted_item
@classmethod
async def release_escrow(cls, db: AsyncSession, purchase_id: int) -> Purchase:
"""Release funds from escrow to seller's available creator balance."""
stmt = select(Purchase).where(Purchase.id == purchase_id).with_for_update()
res = await db.execute(stmt)
purchase = res.scalar_one_or_none()
if purchase is None:
raise PurchaseNotFoundError(f"Purchase {purchase_id} not found")
if purchase.status != PurchaseStatus.pending_escrow:
raise InvalidEscrowStateError(
f"Cannot release escrow in status {purchase.status.value}"
)
cb = await cls.get_or_create_creator_balance(db, purchase.seller_id, for_update=True)
cb.pending = max(0, cb.pending - purchase.seller_net)
cb.available += purchase.seller_net
cb.lifetime_earned += purchase.seller_net
cb.updated_at = datetime.now(timezone.utc)
purchase.status = PurchaseStatus.completed
await db.commit()
await db.refresh(purchase)
return purchase
@classmethod
async def refund_purchase(
cls, db: AsyncSession, purchase_id: int, reason: str | None = None
) -> Purchase:
"""Refund a purchase during the escrow hold window."""
stmt = select(Purchase).where(Purchase.id == purchase_id).with_for_update()
res = await db.execute(stmt)
purchase = res.scalar_one_or_none()
if purchase is None:
raise PurchaseNotFoundError(f"Purchase {purchase_id} not found")
if purchase.status != PurchaseStatus.pending_escrow:
raise InvalidEscrowStateError(
f"Cannot refund purchase in status {purchase.status.value}"
)
# Reverse seller's pending creator balance
cb = await cls.get_or_create_creator_balance(db, purchase.seller_id, for_update=True)
cb.pending = max(0, cb.pending - purchase.seller_net)
cb.updated_at = datetime.now(timezone.utc)
# Credit buyer wallet
await WalletService.credit(
db=db,
user_id=purchase.buyer_id,
amount=purchase.price,
tx_type=TransactionType.refund,
reference_type="purchase_refund",
reference_id=str(purchase.id),
metadata={"reason": reason} if reason else None,
commit=False,
)
purchase.status = PurchaseStatus.refunded
await db.commit()
await db.refresh(purchase)
return purchase
purchase_service = PurchaseService()
+9
View File
@@ -78,6 +78,7 @@ class WalletService:
reference_id: str | None = None, reference_id: str | None = None,
idempotency_key: str | None = None, idempotency_key: str | None = None,
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
commit: bool = True,
) -> tuple[Wallet, Transaction]: ) -> tuple[Wallet, Transaction]:
"""Credit a user's wallet atomically with row locking. """Credit a user's wallet atomically with row locking.
@@ -113,9 +114,12 @@ class WalletService:
metadata_json=metadata, metadata_json=metadata,
) )
db.add(tx) db.add(tx)
if commit:
await db.commit() await db.commit()
await db.refresh(wallet) await db.refresh(wallet)
await db.refresh(tx) await db.refresh(tx)
else:
await db.flush()
return wallet, tx return wallet, tx
@classmethod @classmethod
@@ -129,6 +133,7 @@ class WalletService:
reference_id: str | None = None, reference_id: str | None = None,
idempotency_key: str | None = None, idempotency_key: str | None = None,
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
commit: bool = True,
) -> tuple[Wallet, Transaction]: ) -> tuple[Wallet, Transaction]:
"""Debit a user's wallet atomically with row locking and balance check. """Debit a user's wallet atomically with row locking and balance check.
@@ -154,6 +159,7 @@ class WalletService:
wallet = await cls.get_or_create_wallet(db, user_id, for_update=True) wallet = await cls.get_or_create_wallet(db, user_id, for_update=True)
cur_balance = wallet.balance cur_balance = wallet.balance
if cur_balance < amount: if cur_balance < amount:
if commit:
await db.rollback() await db.rollback()
raise InsufficientFundsError( raise InsufficientFundsError(
f"Insufficient funds: available {cur_balance}, required {amount}" f"Insufficient funds: available {cur_balance}, required {amount}"
@@ -172,9 +178,12 @@ class WalletService:
metadata_json=metadata, metadata_json=metadata,
) )
db.add(tx) db.add(tx)
if commit:
await db.commit() await db.commit()
await db.refresh(wallet) await db.refresh(wallet)
await db.refresh(tx) await db.refresh(tx)
else:
await db.flush()
return wallet, tx return wallet, tx
@staticmethod @staticmethod
+222
View File
@@ -0,0 +1,222 @@
"""Integration tests for Marketplace Purchase Service, Escrow, and Fee Splitting.
docs/planning/economy.md US-4 (task bc90d3f481b4).
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.services.purchase import purchase_service
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
def test_fee_split_calculation():
# 30% platform fee
fee, net = purchase_service.compute_fee_split(100)
assert fee == 30
assert net == 70
fee, net = purchase_service.compute_fee_split(250)
assert fee == 75
assert net == 175
def test_marketplace_purchase_atomic_flow():
client = TestClient(app)
seller_id, seller_headers = _register(client, "purchase_seller_1")
buyer_id, buyer_headers = _register(client, "purchase_buyer_1")
# 1. Seller creates catalog item and lists it for 200 Credits
item_resp = client.post(
"/api/marketplace/items",
headers=seller_headers,
json={
"name": "Dragon Armor",
"description": "Legendary chestplate",
"item_type": "avatar_item",
},
)
assert item_resp.status_code == 201, item_resp.text
item_id = item_resp.json()["id"]
list_resp = client.post(
"/api/marketplace/listings",
headers=seller_headers,
json={
"catalog_item_id": item_id,
"price": 200,
},
)
assert list_resp.status_code == 201, list_resp.text
listing_id = list_resp.json()["id"]
# 2. Seller cannot purchase own listing
self_buy = client.post(
f"/api/marketplace/listings/{listing_id}/purchase",
headers=seller_headers,
)
assert self_buy.status_code == 400
assert "own listing" in self_buy.text
# 3. Buyer attempts purchase without enough credits -> 400
poor_buy = client.post(
f"/api/marketplace/listings/{listing_id}/purchase",
headers=buyer_headers,
)
assert poor_buy.status_code == 400
assert "Insufficient funds" in poor_buy.text
# 4. Buyer purchases 500 Credits via payments API
sess_resp = client.post(
"/api/payments/purchase",
headers=buyer_headers,
json={"credits": 500},
)
assert sess_resp.status_code == 201, sess_resp.text
sess_id = sess_resp.json()["session_id"]
conf_resp = client.post(
"/api/payments/confirm",
headers=buyer_headers,
json={"session_id": sess_id},
)
assert conf_resp.status_code == 200, conf_resp.text
assert conf_resp.json()["new_balance"] == 500
# 5. Buyer purchases listing for 200 Credits
buy_headers = dict(buyer_headers)
buy_headers["Idempotency-Key"] = "buy-test-key-1"
buy_resp = client.post(
f"/api/marketplace/listings/{listing_id}/purchase",
headers=buy_headers,
)
assert buy_resp.status_code == 201, buy_resp.text
purchase_data = buy_resp.json()
assert purchase_data["price"] == 200
assert purchase_data["platform_fee"] == 60
assert purchase_data["seller_net"] == 140
assert purchase_data["status"] == "pending_escrow"
assert purchase_data["buyer_id"] == buyer_id
assert purchase_data["seller_id"] == seller_id
purchase_id = purchase_data["id"]
# 6. Check buyer wallet balance: 500 - 200 = 300
w_resp = client.get("/api/wallet", headers=buyer_headers)
assert w_resp.status_code == 200
assert w_resp.json()["balance"] == 300
# 7. Check buyer has granted item in inventory
inv_resp = client.get("/api/marketplace/items", headers=buyer_headers, params={"mine": "true"})
assert inv_resp.status_code == 200
my_items = inv_resp.json()
assert any(i["name"] == "Dragon Armor" and i["owner_id"] == buyer_id for i in my_items)
# 8. Listing is now sold and cannot be purchased again
re_buy = client.post(
f"/api/marketplace/listings/{listing_id}/purchase",
headers=buyer_headers,
)
assert re_buy.status_code == 400
assert "not active" in re_buy.text
# 9. Seller's creator balance is pending escrow
cb_resp = client.get("/api/marketplace/creator/balance", headers=seller_headers)
assert cb_resp.status_code == 200, cb_resp.text
cb = cb_resp.json()
assert cb["pending"] == 140
assert cb["available"] == 0
# 10. List purchases
p_resp = client.get("/api/marketplace/purchases", headers=buyer_headers)
assert p_resp.status_code == 200
assert any(p["id"] == purchase_id for p in p_resp.json())
# 11. Escrow release
rel_resp = client.post(
f"/api/marketplace/purchases/{purchase_id}/release",
headers=seller_headers,
)
assert rel_resp.status_code == 200, rel_resp.text
assert rel_resp.json()["status"] == "completed"
# Check seller's creator balance now shows available = 140
cb_resp2 = client.get("/api/marketplace/creator/balance", headers=seller_headers)
assert cb_resp2.status_code == 200
cb2 = cb_resp2.json()
assert cb2["pending"] == 0
assert cb2["available"] == 140
assert cb2["lifetime_earned"] == 140
def test_marketplace_refund():
client = TestClient(app)
seller_id, seller_headers = _register(client, "refund_seller_1")
buyer_id, buyer_headers = _register(client, "refund_buyer_1")
# Item & Listing
item_resp = client.post(
"/api/marketplace/items",
headers=seller_headers,
json={"name": "Magic Ring", "item_type": "avatar_item"},
)
item_id = item_resp.json()["id"]
list_resp = client.post(
"/api/marketplace/listings",
headers=seller_headers,
json={"catalog_item_id": item_id, "price": 100},
)
listing_id = list_resp.json()["id"]
# Fund buyer
s_resp = client.post("/api/payments/purchase", headers=buyer_headers, json={"credits": 500})
assert s_resp.status_code == 201, s_resp.text
client.post("/api/payments/confirm", headers=buyer_headers, json={"session_id": s_resp.json()["session_id"]})
# Purchase
buy_resp = client.post(f"/api/marketplace/listings/{listing_id}/purchase", headers=buyer_headers)
assert buy_resp.status_code == 201
purchase_id = buy_resp.json()["id"]
# Buyer balance is 400
w1 = client.get("/api/wallet", headers=buyer_headers).json()
assert w1["balance"] == 400
# Seller pending is 70
cb1 = client.get("/api/marketplace/creator/balance", headers=seller_headers).json()
assert cb1["pending"] == 70
# Refund
ref_resp = client.post(
f"/api/marketplace/purchases/{purchase_id}/refund",
headers=seller_headers,
json={"reason": "Buyer dissatisfaction"},
)
assert ref_resp.status_code == 200, ref_resp.text
assert ref_resp.json()["status"] == "refunded"
# Buyer balance refunded back to 500
w2 = client.get("/api/wallet", headers=buyer_headers).json()
assert w2["balance"] == 500
# Seller pending reset to 0
cb2 = client.get("/api/marketplace/creator/balance", headers=seller_headers).json()
assert cb2["pending"] == 0