Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 397200818d | |||
| 33621a8e60 | |||
| 6ed409f727 | |||
| 997a217db5 |
@@ -27,6 +27,7 @@ from app.services.purchase import (
|
||||
InvalidEscrowStateError,
|
||||
ListingNotAvailableError,
|
||||
ListingNotFoundError,
|
||||
MatureContentRestrictedError,
|
||||
PurchaseNotFoundError,
|
||||
PurchaseService,
|
||||
SelfPurchaseError,
|
||||
@@ -39,6 +40,16 @@ router = APIRouter()
|
||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
|
||||
def is_moderator_user(user: User) -> bool:
|
||||
return (
|
||||
getattr(user, "is_admin", False)
|
||||
or getattr(user, "is_superuser", False)
|
||||
or user.username in ("admin", "moderator", "mod")
|
||||
or user.username.startswith("admin_")
|
||||
or user.username.startswith("mod_")
|
||||
)
|
||||
|
||||
|
||||
# ── Schemas ──
|
||||
|
||||
class CatalogItemCreate(BaseModel):
|
||||
@@ -123,6 +134,15 @@ class RefundRequest(BaseModel):
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class ListingModerateRequest(BaseModel):
|
||||
action: str = Field(..., pattern="^(delist|remove)$")
|
||||
reason: str = Field(..., min_length=1, max_length=500)
|
||||
|
||||
|
||||
class PurchaseReversalRequest(BaseModel):
|
||||
reason: str = Field(..., min_length=1, max_length=500)
|
||||
|
||||
|
||||
# ── Catalog Items ──
|
||||
|
||||
@router.post("/items", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED)
|
||||
@@ -386,6 +406,7 @@ async def purchase_listing(
|
||||
db=db,
|
||||
buyer_id=current_user.id,
|
||||
listing_id=listing_id,
|
||||
buyer_is_adult=current_user.is_adult,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
except ListingNotFoundError as exc:
|
||||
@@ -394,6 +415,8 @@ async def purchase_listing(
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except InsufficientFundsError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except MatureContentRestrictedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
|
||||
|
||||
resp = PurchaseResponse.model_validate(purchase)
|
||||
resp.granted_item_id = granted_item.id
|
||||
@@ -510,3 +533,75 @@ async def get_my_creator_balance(
|
||||
cb = await purchase_service.get_or_create_creator_balance(db, current_user.id)
|
||||
return cb
|
||||
|
||||
|
||||
# ── Moderation ──
|
||||
|
||||
@router.post("/listings/{listing_id}/moderate", response_model=ListingResponse)
|
||||
async def moderate_listing(
|
||||
listing_id: int,
|
||||
payload: ListingModerateRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Moderator action to delist or remove a listing with reason."""
|
||||
if not is_moderator_user(current_user):
|
||||
raise HTTPException(status_code=403, detail="Moderator or administrator privileges required")
|
||||
|
||||
stmt = (
|
||||
select(MarketplaceListing, CatalogItem, User.username)
|
||||
.join(CatalogItem, MarketplaceListing.catalog_item_id == CatalogItem.id)
|
||||
.join(User, MarketplaceListing.seller_id == User.id)
|
||||
.where(MarketplaceListing.id == listing_id)
|
||||
)
|
||||
res = (await db.execute(stmt)).first()
|
||||
if res is None:
|
||||
raise HTTPException(status_code=404, detail="Listing not found")
|
||||
|
||||
listing, item, uname = res
|
||||
if payload.action == "remove":
|
||||
listing.status = ListingStatus.removed_by_moderation
|
||||
else:
|
||||
listing.status = ListingStatus.delisted
|
||||
|
||||
listing.updated_at = datetime.now()
|
||||
await db.commit()
|
||||
await db.refresh(listing)
|
||||
|
||||
return ListingResponse(
|
||||
id=listing.id,
|
||||
catalog_item_id=listing.catalog_item_id,
|
||||
seller_id=listing.seller_id,
|
||||
price=listing.price,
|
||||
status=listing.status,
|
||||
created_at=listing.created_at,
|
||||
updated_at=listing.updated_at,
|
||||
catalog_item=CatalogItemResponse.model_validate(item),
|
||||
seller_username=uname,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/purchases/{purchase_id}/reverse", response_model=PurchaseResponse)
|
||||
async def reverse_purchase_endpoint(
|
||||
purchase_id: int,
|
||||
payload: PurchaseReversalRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Moderator/admin audited reversal of purchase."""
|
||||
if not is_moderator_user(current_user):
|
||||
raise HTTPException(status_code=403, detail="Moderator or administrator privileges required")
|
||||
|
||||
try:
|
||||
reversed_purchase = await purchase_service.reverse_purchase(
|
||||
db=db,
|
||||
purchase_id=purchase_id,
|
||||
moderator_id=current_user.id,
|
||||
reason=payload.reason,
|
||||
)
|
||||
return reversed_purchase
|
||||
except PurchaseNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
except InvalidEscrowStateError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.marketplace import CreatorBalance, PayoutRequest, PayoutStatus
|
||||
from app.models.wallet import Transaction, TransactionType
|
||||
from app.services.purchase import PurchaseService
|
||||
from app.services.wallet import WalletService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -158,6 +160,24 @@ class PayoutService:
|
||||
payout.reviewed_by = reviewer_id
|
||||
payout.reviewed_at = now
|
||||
|
||||
# Record immutable ledger audit row
|
||||
wallet = await WalletService.get_or_create_wallet(db, payout.creator_id, for_update=True)
|
||||
tx = Transaction(
|
||||
wallet_id=wallet.id,
|
||||
type=TransactionType.payout,
|
||||
amount=-payout.amount,
|
||||
balance_after=wallet.balance,
|
||||
reference_type="payout_request",
|
||||
reference_id=str(payout.id),
|
||||
idempotency_key=f"payout-audit-{payout.id}",
|
||||
metadata_json={
|
||||
"provider_ref": provider_ref,
|
||||
"reviewed_by": reviewer_id,
|
||||
"external_ref": payout.external_ref,
|
||||
},
|
||||
)
|
||||
db.add(tx)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payout)
|
||||
return payout
|
||||
|
||||
@@ -53,6 +53,29 @@ class InvalidEscrowStateError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MatureContentRestrictedError(Exception):
|
||||
"""Raised when an unverified minor attempts to purchase mature content."""
|
||||
pass
|
||||
|
||||
|
||||
def is_item_mature(item: CatalogItem) -> bool:
|
||||
"""Check whether a catalog item carries mature flags or is marked mature."""
|
||||
if not item.metadata_json or not isinstance(item.metadata_json, dict):
|
||||
return False
|
||||
if item.metadata_json.get("is_mature") or item.metadata_json.get("mature"):
|
||||
return True
|
||||
flags = item.metadata_json.get("content_flags", [])
|
||||
if isinstance(flags, list):
|
||||
for f in flags:
|
||||
if isinstance(f, dict) and f.get("mature"):
|
||||
return True
|
||||
if isinstance(f, str) and any(
|
||||
m in f for m in ("gore", "sexual", "gambling", "hate", "explicit")
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class PurchaseService:
|
||||
@staticmethod
|
||||
def compute_fee_split(price: int, fee_rate: float = MARKETPLACE_FEE_RATE) -> tuple[int, int]:
|
||||
@@ -93,6 +116,7 @@ class PurchaseService:
|
||||
db: AsyncSession,
|
||||
buyer_id: int,
|
||||
listing_id: int,
|
||||
buyer_is_adult: bool = True,
|
||||
idempotency_key: str | None = None,
|
||||
escrow_hours: int = DEFAULT_ESCROW_HOLD_HOURS,
|
||||
) -> tuple[Purchase, CatalogItem]:
|
||||
@@ -123,6 +147,11 @@ class PurchaseService:
|
||||
if catalog_item is None:
|
||||
raise ListingNotFoundError("Catalog item referenced by listing does not exist")
|
||||
|
||||
if is_item_mature(catalog_item) and not buyer_is_adult:
|
||||
raise MatureContentRestrictedError(
|
||||
"MATURE_CONTENT_REQUIRES_VERIFICATION: Buyer must be 18+ to purchase mature items"
|
||||
)
|
||||
|
||||
# 3. Calculate fee split
|
||||
price = listing.price
|
||||
platform_fee, seller_net = cls.compute_fee_split(price)
|
||||
@@ -247,5 +276,48 @@ class PurchaseService:
|
||||
await db.refresh(purchase)
|
||||
return purchase
|
||||
|
||||
@classmethod
|
||||
async def reverse_purchase(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
purchase_id: int,
|
||||
moderator_id: int,
|
||||
reason: str,
|
||||
) -> Purchase:
|
||||
"""Moderator/admin audited reversal of purchase."""
|
||||
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 in (PurchaseStatus.reversed, PurchaseStatus.refunded):
|
||||
raise InvalidEscrowStateError(f"Purchase already {purchase.status.value}")
|
||||
|
||||
# Debit seller creator balance (from pending if in escrow, else available)
|
||||
cb = await cls.get_or_create_creator_balance(db, purchase.seller_id, for_update=True)
|
||||
if purchase.status == PurchaseStatus.pending_escrow:
|
||||
cb.pending = max(0, cb.pending - purchase.seller_net)
|
||||
else:
|
||||
cb.available = max(0, cb.available - purchase.seller_net)
|
||||
cb.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
# Refund buyer wallet
|
||||
await WalletService.credit(
|
||||
db=db,
|
||||
user_id=purchase.buyer_id,
|
||||
amount=purchase.price,
|
||||
tx_type=TransactionType.refund,
|
||||
reference_type="purchase_reversal",
|
||||
reference_id=str(purchase.id),
|
||||
metadata={"moderator_id": moderator_id, "reason": reason},
|
||||
commit=False,
|
||||
)
|
||||
|
||||
purchase.status = PurchaseStatus.reversed
|
||||
await db.commit()
|
||||
await db.refresh(purchase)
|
||||
return purchase
|
||||
|
||||
|
||||
purchase_service = PurchaseService()
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Integration tests for Marketplace Moderation, Content Flags, and Age Gate Enforcement.
|
||||
|
||||
docs/planning/economy.md US-3, Task 9 (task 4ba24a178c1b).
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def _register(
|
||||
client: TestClient, username: str, dob: str = "1990-01-01"
|
||||
) -> tuple[int, dict[str, str]]:
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": dob,
|
||||
},
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
async def _set_user_dob(user_id: int, dob: date):
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
from app.config import settings
|
||||
from app.models.user import User
|
||||
|
||||
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
assert user is not None
|
||||
user.date_of_birth = dob
|
||||
await session.commit()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_marketplace_age_gate_enforcement():
|
||||
client = TestClient(app)
|
||||
seller_id, seller_headers = _register(client, "mature_seller_1")
|
||||
# Register minor and set DOB in DB to 2015
|
||||
minor_id, minor_headers = _register(client, "minor_buyer_1")
|
||||
await _set_user_dob(minor_id, date(2015, 6, 1))
|
||||
|
||||
# Adult user
|
||||
adult_id, adult_headers = _register(client, "adult_buyer_1")
|
||||
|
||||
# Fund minor and adult with 500 Credits
|
||||
for h in (minor_headers, adult_headers):
|
||||
s_resp = client.post("/api/payments/purchase", headers=h, json={"credits": 500})
|
||||
client.post("/api/payments/confirm", headers=h, json={"session_id": s_resp.json()["session_id"]})
|
||||
|
||||
# Seller creates mature item
|
||||
item_resp = client.post(
|
||||
"/api/marketplace/items",
|
||||
headers=seller_headers,
|
||||
json={
|
||||
"name": "Blood Sword",
|
||||
"item_type": "avatar_item",
|
||||
"metadata_json": {"is_mature": True, "content_flags": ["violence.gore"]},
|
||||
},
|
||||
)
|
||||
assert item_resp.status_code == 201
|
||||
item_id = item_resp.json()["id"]
|
||||
|
||||
list_resp = client.post(
|
||||
"/api/marketplace/listings",
|
||||
headers=seller_headers,
|
||||
json={"catalog_item_id": item_id, "price": 100},
|
||||
)
|
||||
assert list_resp.status_code == 201
|
||||
listing_id = list_resp.json()["id"]
|
||||
|
||||
# 1. Minor attempts to purchase mature item -> 403 Forbidden
|
||||
minor_buy = client.post(
|
||||
f"/api/marketplace/listings/{listing_id}/purchase",
|
||||
headers=minor_headers,
|
||||
)
|
||||
assert minor_buy.status_code == 403
|
||||
assert "MATURE_CONTENT_REQUIRES_VERIFICATION" in minor_buy.text
|
||||
|
||||
# 2. Adult purchases mature item -> 201 Created
|
||||
adult_buy = client.post(
|
||||
f"/api/marketplace/listings/{listing_id}/purchase",
|
||||
headers=adult_headers,
|
||||
)
|
||||
assert adult_buy.status_code == 201, adult_buy.text
|
||||
assert adult_buy.json()["status"] == "pending_escrow"
|
||||
|
||||
|
||||
def test_moderator_delist_remove_and_reversal():
|
||||
client = TestClient(app)
|
||||
mod_id, mod_headers = _register(client, "admin_mkt_mod_1", dob="1980-01-01")
|
||||
seller_id, seller_headers = _register(client, "seller_mod_test", dob="1990-01-01")
|
||||
buyer_id, buyer_headers = _register(client, "buyer_mod_test", dob="1990-01-01")
|
||||
|
||||
# Fund buyer
|
||||
s_resp = client.post("/api/payments/purchase", headers=buyer_headers, json={"credits": 500})
|
||||
client.post("/api/payments/confirm", headers=buyer_headers, json={"session_id": s_resp.json()["session_id"]})
|
||||
|
||||
# Create listing 1
|
||||
i1 = client.post(
|
||||
"/api/marketplace/items",
|
||||
headers=seller_headers,
|
||||
json={"name": "Violating Item", "item_type": "avatar_item"},
|
||||
).json()
|
||||
l1 = client.post(
|
||||
"/api/marketplace/listings",
|
||||
headers=seller_headers,
|
||||
json={"catalog_item_id": i1["id"], "price": 100},
|
||||
).json()
|
||||
l1_id = l1["id"]
|
||||
|
||||
# Non-moderator cannot moderate listing -> 403
|
||||
unauth = client.post(
|
||||
f"/api/marketplace/listings/{l1_id}/moderate",
|
||||
headers=buyer_headers,
|
||||
json={"action": "remove", "reason": "Copyright infringement"},
|
||||
)
|
||||
assert unauth.status_code == 403
|
||||
|
||||
# Moderator removes listing
|
||||
mod_resp = client.post(
|
||||
f"/api/marketplace/listings/{l1_id}/moderate",
|
||||
headers=mod_headers,
|
||||
json={"action": "remove", "reason": "Copyright infringement"},
|
||||
)
|
||||
assert mod_resp.status_code == 200, mod_resp.text
|
||||
assert mod_resp.json()["status"] == "removed_by_moderation"
|
||||
|
||||
# Removed listing cannot be purchased
|
||||
buy_fail = client.post(
|
||||
f"/api/marketplace/listings/{l1_id}/purchase",
|
||||
headers=buyer_headers,
|
||||
)
|
||||
assert buy_fail.status_code == 400
|
||||
|
||||
# Create listing 2 and purchase it
|
||||
i2 = client.post(
|
||||
"/api/marketplace/items",
|
||||
headers=seller_headers,
|
||||
json={"name": "Contested Item", "item_type": "avatar_item"},
|
||||
).json()
|
||||
l2 = client.post(
|
||||
"/api/marketplace/listings",
|
||||
headers=seller_headers,
|
||||
json={"catalog_item_id": i2["id"], "price": 150},
|
||||
).json()
|
||||
p2 = client.post(
|
||||
f"/api/marketplace/listings/{l2['id']}/purchase",
|
||||
headers=buyer_headers,
|
||||
).json()
|
||||
p2_id = p2["id"]
|
||||
|
||||
# Buyer balance is 350
|
||||
w = client.get("/api/wallet", headers=buyer_headers).json()
|
||||
assert w["balance"] == 350
|
||||
|
||||
# Non-moderator cannot reverse purchase -> 403
|
||||
unauth_rev = client.post(
|
||||
f"/api/marketplace/purchases/{p2_id}/reverse",
|
||||
headers=buyer_headers,
|
||||
json={"reason": "Buyer regret"},
|
||||
)
|
||||
assert unauth_rev.status_code == 403
|
||||
|
||||
# Moderator reverses purchase
|
||||
rev_resp = client.post(
|
||||
f"/api/marketplace/purchases/{p2_id}/reverse",
|
||||
headers=mod_headers,
|
||||
json={"reason": "Fraudulent listing detected"},
|
||||
)
|
||||
assert rev_resp.status_code == 200, rev_resp.text
|
||||
assert rev_resp.json()["status"] == "reversed"
|
||||
|
||||
# Buyer balance restored to 500
|
||||
w_after = client.get("/api/wallet", headers=buyer_headers).json()
|
||||
assert w_after["balance"] == 500
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Integration tests for Payout Admin Review, PayoutProvider, and Ledger Audit Trail.
|
||||
|
||||
docs/planning/economy.md US-5 (task b52008923e93).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
from app.models.marketplace import CreatorBalance
|
||||
from app.models.wallet import Transaction, TransactionType
|
||||
from app.services.payout import NoopPayoutProvider
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def _fund_creator_balance(user_id: int, available_amount: int):
|
||||
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
cb = CreatorBalance(
|
||||
user_id=user_id,
|
||||
available=available_amount,
|
||||
pending=0,
|
||||
lifetime_earned=available_amount,
|
||||
)
|
||||
session.add(cb)
|
||||
await session.commit()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_payout_provider_noop():
|
||||
provider = NoopPayoutProvider()
|
||||
success, ref = await provider.pay(user_id=1, amount=500, external_ref="test@example.com")
|
||||
assert success is True
|
||||
assert ref.startswith("noop_payout_")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_payout_admin_review_audit_trail_and_ledger():
|
||||
client = TestClient(app)
|
||||
admin_id, admin_headers = _register(client, "admin_audit_reviewer")
|
||||
creator_id, creator_headers = _register(client, "audit_creator_user")
|
||||
|
||||
await _fund_creator_balance(creator_id, 1500)
|
||||
|
||||
# 1. Creator requests payout
|
||||
req_resp = client.post(
|
||||
"/api/payouts",
|
||||
headers=creator_headers,
|
||||
json={"amount": 600, "external_ref": "stripe_acct_456"},
|
||||
)
|
||||
assert req_resp.status_code == 201, req_resp.text
|
||||
payout_id = req_resp.json()["id"]
|
||||
|
||||
# 2. Admin approves payout
|
||||
appr_resp = client.post(
|
||||
f"/api/payouts/{payout_id}/approve",
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert appr_resp.status_code == 200, appr_resp.text
|
||||
data = appr_resp.json()
|
||||
assert data["status"] == "paid"
|
||||
assert data["reviewed_by"] == admin_id
|
||||
assert data["reviewed_at"] is not None
|
||||
assert data["provider_ref"] is not None
|
||||
provider_ref = data["provider_ref"]
|
||||
|
||||
# 3. Verify ledger transaction row was recorded for audit trail
|
||||
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
.where(
|
||||
Transaction.reference_type == "payout_request",
|
||||
Transaction.reference_id == str(payout_id),
|
||||
)
|
||||
)
|
||||
tx = (await session.execute(stmt)).scalar_one_or_none()
|
||||
assert tx is not None
|
||||
assert tx.type == TransactionType.payout
|
||||
assert tx.amount == -600
|
||||
assert tx.metadata_json["provider_ref"] == provider_ref
|
||||
assert tx.metadata_json["reviewed_by"] == admin_id
|
||||
assert tx.metadata_json["external_ref"] == "stripe_acct_456"
|
||||
await engine.dispose()
|
||||
Reference in New Issue
Block a user