Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 747eebaa83 | |||
| 1aa74c9bfe | |||
| 1aae4c00f0 | |||
| aa2f79b125 | |||
| 8551d14591 | |||
| ff610c0466 | |||
| acbc518b4a | |||
| 25a8900357 | |||
| 5a1f99eb28 | |||
| cb95895612 | |||
| 62889f39f1 | |||
| ef646a8e54 | |||
| e4d3fe566b | |||
| 1d0222d980 | |||
| 433d5d8f9f | |||
| 30b8dc6de2 |
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
from app.database import Base
|
||||
from app.models import user, session, world, asset, wallet # noqa: F401
|
||||
from app.models import user, session, world, asset # noqa: F401
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
|
||||
@@ -69,7 +69,8 @@ def upgrade() -> None:
|
||||
sa.Column(
|
||||
"visibility",
|
||||
postgresql.ENUM(
|
||||
"public", "private", "friends_only", name="world_visibility"
|
||||
"public", "private", "friends_only", name="world_visibility",
|
||||
create_type=False,
|
||||
),
|
||||
nullable=True,
|
||||
),
|
||||
@@ -89,7 +90,7 @@ def upgrade() -> None:
|
||||
"asset_type",
|
||||
postgresql.ENUM(
|
||||
"image", "audio", "video", "document", "model_3d", "other",
|
||||
name="asset_type",
|
||||
name="asset_type", create_type=False,
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add date_of_birth column to users table
|
||||
|
||||
Revision ID: 0002_add_date_of_birth_to_users
|
||||
Revises: 0001_initial
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0002_add_date_of_birth_to_users"
|
||||
down_revision: Union[str, None] = "0001_initial"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add date_of_birth column to users table."""
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("date_of_birth", sa.Date(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove date_of_birth column from users table."""
|
||||
op.drop_column("users", "date_of_birth")
|
||||
@@ -2,14 +2,5 @@ from app.models.user import User
|
||||
from app.models.session import Session
|
||||
from app.models.world import World
|
||||
from app.models.asset import Asset
|
||||
from app.models.wallet import Transaction, TransactionType, Wallet
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Session",
|
||||
"World",
|
||||
"Asset",
|
||||
"Wallet",
|
||||
"Transaction",
|
||||
"TransactionType",
|
||||
]
|
||||
__all__ = ["User", "Session", "World", "Asset"]
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
@@ -12,6 +15,7 @@ class User(Base):
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
email = Column(String(120), unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
date_of_birth = Column(Date, nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
@@ -19,3 +23,15 @@ class User(Base):
|
||||
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
|
||||
worlds = relationship("World", back_populates="owner")
|
||||
assets = relationship("Asset", back_populates="owner")
|
||||
|
||||
@property
|
||||
def is_adult(self) -> bool:
|
||||
"""Return True if the user is 18 or older based on date_of_birth."""
|
||||
if self.date_of_birth is None:
|
||||
return False
|
||||
today = date.today()
|
||||
age = today.year - self.date_of_birth.year
|
||||
# Adjust if birthday hasn't occurred yet this year
|
||||
if (today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day):
|
||||
age -= 1
|
||||
return age >= 18
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Wallet and transaction ledger models."""
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
CheckConstraint,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import ENUM, JSONB
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class TransactionType(str, enum.Enum):
|
||||
CREDIT_PURCHASE = "credit_purchase"
|
||||
MARKETPLACE_PURCHASE = "marketplace_purchase"
|
||||
MARKETPLACE_SALE = "marketplace_sale"
|
||||
ESCROW_RELEASE = "escrow_release"
|
||||
PAYOUT = "payout"
|
||||
REFUND = "refund"
|
||||
ADMIN_ADJUSTMENT = "admin_adjustment"
|
||||
|
||||
|
||||
class Wallet(Base):
|
||||
__tablename__ = "wallets"
|
||||
__table_args__ = (
|
||||
CheckConstraint("balance >= 0", name="ck_wallets_balance_nonnegative"),
|
||||
)
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
user_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
balance = Column(BigInteger, nullable=False, default=0, server_default="0")
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class Transaction(Base):
|
||||
__tablename__ = "transactions"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
wallet_id = Column(
|
||||
BigInteger, ForeignKey("wallets.id"), nullable=False
|
||||
)
|
||||
type = Column(
|
||||
ENUM(
|
||||
TransactionType,
|
||||
name="transaction_type",
|
||||
create_type=False,
|
||||
values_callable=lambda enum_cls: [member.value for member in enum_cls],
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
amount = Column(BigInteger, nullable=False)
|
||||
balance_after = Column(BigInteger, nullable=False)
|
||||
reference_type = Column(Text, nullable=True)
|
||||
reference_id = Column(Text, nullable=True)
|
||||
idempotency_key = Column(Text, unique=True, index=True, nullable=True)
|
||||
metadata_json = Column(JSONB, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Authentication router - login, register, token refresh."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
@@ -32,12 +32,24 @@ class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
date_of_birth: str # ISO format date string (YYYY-MM-DD)
|
||||
|
||||
@field_validator("date_of_birth")
|
||||
@classmethod
|
||||
def validate_date_of_birth(cls, v: str) -> str:
|
||||
"""Validate that the date string is a valid date in YYYY-MM-DD format."""
|
||||
try:
|
||||
datetime.strptime(v, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise ValueError("date_of_birth must be in YYYY-MM-DD format")
|
||||
return v
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
date_of_birth: str | None = None
|
||||
is_active: bool
|
||||
created_at: datetime | None = None
|
||||
|
||||
@@ -65,11 +77,44 @@ def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def calculate_age(birth_date: date, today: date | None = None) -> int:
|
||||
"""Calculate age in years from a birth date.
|
||||
|
||||
Args:
|
||||
birth_date: The person's date of birth.
|
||||
today: The reference date. Defaults to today's date (UTC).
|
||||
|
||||
Returns:
|
||||
Age in whole years as of the reference date.
|
||||
"""
|
||||
if today is None:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
|
||||
age = today.year - birth_date.year
|
||||
# Adjust if birthday hasn't occurred yet this year
|
||||
if (today.month, today.day) < (birth_date.month, birth_date.day):
|
||||
age -= 1
|
||||
return age
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Register a new user and return an access token."""
|
||||
"""Register a new user and return an access token.
|
||||
|
||||
Requires date_of_birth and rejects users under 18.
|
||||
"""
|
||||
# Parse and validate date_of_birth
|
||||
dob = datetime.strptime(req.date_of_birth, "%Y-%m-%d").date()
|
||||
age = calculate_age(dob)
|
||||
|
||||
if age < 18:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Must be 18 or older to register (provided date of birth: {req.date_of_birth})",
|
||||
)
|
||||
|
||||
# Check if username already exists
|
||||
result = await db.execute(select(User).where(User.username == req.username))
|
||||
if result.scalar_one_or_none():
|
||||
@@ -90,6 +135,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
username=req.username,
|
||||
email=req.email,
|
||||
hashed_password=hash_password(req.password),
|
||||
date_of_birth=dob,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
POSTGRES_PASSWORD: agp_password
|
||||
POSTGRES_DB: agp_db
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "${POSTGRES_HOST_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -20,7 +20,7 @@ services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
- "${REDIS_HOST_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tests for the 18+ age gate on user registration."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.routers.auth import calculate_age
|
||||
|
||||
|
||||
class TestCalculateAge:
|
||||
"""Unit tests for the calculate_age helper."""
|
||||
|
||||
def test_exactly_18(self):
|
||||
"""A person born exactly 18 years ago today is 18."""
|
||||
today = date(2025, 6, 15)
|
||||
dob = today.replace(year=today.year - 18)
|
||||
assert calculate_age(dob, today=today) == 18
|
||||
|
||||
def test_just_turned_18(self):
|
||||
"""A person who turned 18 yesterday is 18."""
|
||||
today = date(2025, 6, 15)
|
||||
dob = today.replace(year=today.year - 18) - timedelta(days=1)
|
||||
assert calculate_age(dob, today=today) == 18
|
||||
|
||||
def test_one_day_short_of_18(self):
|
||||
"""A person born one day after their 18th birthday is 17."""
|
||||
today = date(2025, 6, 15)
|
||||
dob = today.replace(year=today.year - 18) + timedelta(days=1)
|
||||
assert calculate_age(dob, today=today) == 17
|
||||
|
||||
def test_17_years_old(self):
|
||||
"""A person who is 17 years old today is 17."""
|
||||
today = date(2025, 6, 15)
|
||||
dob = date(2008, 6, 15)
|
||||
assert calculate_age(dob, today=today) == 17
|
||||
|
||||
def test_65_years_old(self):
|
||||
"""A person who is 65 is correctly calculated."""
|
||||
today = date(2025, 6, 15)
|
||||
dob = today.replace(year=today.year - 65)
|
||||
assert calculate_age(dob, today=today) == 65
|
||||
|
||||
def test_newborn(self):
|
||||
"""A newborn is 0."""
|
||||
today = date(2025, 6, 15)
|
||||
dob = today
|
||||
assert calculate_age(dob, today=today) == 0
|
||||
|
||||
def test_birthday_not_yet_this_year(self):
|
||||
"""Person born Dec 31, 2000 — on Jan 1, 2025 they are still 24."""
|
||||
dob = date(2000, 12, 31)
|
||||
assert calculate_age(dob, today=date(2025, 1, 1)) == 24
|
||||
|
||||
def test_birthday_today(self):
|
||||
"""Person born Dec 31, 2000 — on Dec 31, 2024 they turn 24."""
|
||||
dob = date(2000, 12, 31)
|
||||
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
|
||||
|
||||
|
||||
class TestRegisterRequestDateValidation:
|
||||
"""Tests for the RegisterRequest date_of_birth field validator."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dob",
|
||||
[
|
||||
"2000-01-01",
|
||||
"1990-12-31",
|
||||
"2004-02-29", # leap day
|
||||
],
|
||||
)
|
||||
def test_valid_dates(self, dob):
|
||||
"""Valid YYYY-MM-DD dates pass validation."""
|
||||
from app.routers.auth import RegisterRequest
|
||||
|
||||
req = RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
|
||||
assert req.date_of_birth == dob
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dob",
|
||||
[
|
||||
"01-01-2000",
|
||||
"2000/01/01",
|
||||
"2000-13-01",
|
||||
"2000-00-10",
|
||||
"not-a-date",
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_invalid_dates_rejected(self, dob):
|
||||
"""Invalid dates are rejected by the validator."""
|
||||
from app.routers.auth import RegisterRequest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
|
||||
@@ -0,0 +1,37 @@
|
||||
# MVP Vertical Slice: Client-Server Wiring — Plan
|
||||
|
||||
## Goal
|
||||
Prove the real Godot client and the real backend can talk: **a user can register/log in
|
||||
and see a list of worlds from the game client**. Nothing more. Gameplay, join-a-world,
|
||||
chat, avatar, Discovery search/tags/trending, Social/Economy/Trust & Safety are all
|
||||
explicitly out of scope.
|
||||
|
||||
## Verified backend contract (as of this writing)
|
||||
- `POST /api/auth/register` — JSON body `{username, email, password, date_of_birth}`.
|
||||
`date_of_birth` is a `YYYY-MM-DD` string. Returns `201 {access_token, token_type}`.
|
||||
Rejects under-18 with `400`.
|
||||
- `POST /api/auth/login` — OAuth2 **form-encoded** (`application/x-www-form-urlencoded`)
|
||||
`username` + `password` (this is NOT a JSON endpoint). Returns `{access_token, token_type}`.
|
||||
- `GET /api/worlds` — **requires auth** (`CurrentUser`), returns a JSON array of
|
||||
`{id, name, description, visibility, owner_id, created_at}`. There is no unauthenticated
|
||||
public-worlds path, so the worlds list screen can only be shown after login/register.
|
||||
- `GET /api/health` — unauthenticated, `{status, app}`.
|
||||
- Base URL: uvicorn default `http://127.0.0.1:8000` (must be configurable in the client).
|
||||
- CORS is irrelevant to the native Godot `HTTPRequest` client (no browser origin).
|
||||
|
||||
## Task breakdown
|
||||
| # | Type | Task | Depends on |
|
||||
|---|------|------|-----------|
|
||||
| 1 | architecture | `ApiClient` autoload: HTTP + JWT storage + error contract | — |
|
||||
| 2 | implementation | Main-menu **login** wiring (form-encoded) | 1 |
|
||||
| 3 | implementation | Main-menu **register** wiring (JSON + age gate) | 2 |
|
||||
| 4 | implementation | **World list** screen consuming `GET /api/worlds` | 2 |
|
||||
| 5 | testing | End-to-end headless smoke test + manual verify | 1,3,4 |
|
||||
|
||||
## Design decisions captured (resolved in task 1)
|
||||
- Token storage: in-memory singleton property for the MVP; a `save_to_disk` seam is left
|
||||
open but not required yet (no re-login on restart in this slice).
|
||||
- Error contract: every call returns a discriminated result (`ok`/`error`) so callers
|
||||
never crash on network failure; non-2xx surfaces `{status, detail}` from the backend.
|
||||
- Base URL: read from a ProjectSetting, defaulting to `http://127.0.0.1:8000`.
|
||||
- Login is form-encoded; register is JSON — the client helper must support both content types.
|
||||
@@ -0,0 +1,182 @@
|
||||
# Creator Studio — Extended Authoring Tools
|
||||
|
||||
> **Feature:** Creator Studio — Extended Authoring Tools
|
||||
> **Status:** Planning
|
||||
> **Owner:** Platform team
|
||||
|
||||
## Feature Overview
|
||||
|
||||
Extends the in-game world editor (M6) toward a real authoring tool with:
|
||||
1. **Asset import pipeline** (glTF models/textures)
|
||||
2. **Script editor** with sandboxed API surface exposed clearly
|
||||
3. **Experience publishing flow** (draft → review → live)
|
||||
4. **Basic analytics** for creators (plays, session length)
|
||||
|
||||
## User Stories
|
||||
|
||||
### US-1: Asset Import Pipeline
|
||||
_As a creator, I can import glTF models and textures into my experience so that I can use custom 3D assets._
|
||||
|
||||
### US-2: Script Editor with Sandboxed API
|
||||
_As a creator, I can write and test scripts in the editor with autocomplete/docs for the available API surface._
|
||||
|
||||
### US-3: Experience Publishing Flow
|
||||
_As a creator, I can publish my experience through a draft → review → live pipeline so that players can discover and play it._
|
||||
|
||||
### US-4: Creator Analytics
|
||||
_As a creator, I can see how many plays and average session length my experiences receive so that I can understand player engagement._
|
||||
|
||||
---
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### US-1: Asset Import Pipeline
|
||||
|
||||
#### T1.1: Backend — glTF Asset Upload with Metadata Extraction
|
||||
- **Type:** implementation
|
||||
- **Files:** `backend/app/routers/assets.py` (extend upload endpoint), `backend/app/models/asset.py` (add glTF-specific metadata fields)
|
||||
- **Scope:** Extend the existing `upload_asset_file` endpoint in `assets.py` to detect `.glb`/`.gltf` files and extract glTF metadata (mesh_count, vertex_count, has_animation, texture_refs, bounding_box) from the embedded JSON. Store extracted metadata in `metadata_json`. Add a `has_gltf_metadata` computed property. The Asset model already has `asset_type = MODEL_3D` and `metadata_json` — this task adds the extraction logic.
|
||||
- **Acceptance:** POST `/api/assets/{id}/upload` with a `.glb` file returns asset with `metadata_json` containing `mesh_count`, `vertex_count`, `has_animation`, `texture_refs`. GET lists include these fields. File stored on disk. Non-glTF files are unaffected.
|
||||
- **Dependencies:** None (builds on existing asset infrastructure)
|
||||
|
||||
#### T1.2: Backend — Public Asset Catalog Endpoint
|
||||
- **Type:** implementation
|
||||
- **Files:** `backend/app/routers/assets.py` (new endpoint)
|
||||
- **Scope:** Add `GET /api/assets/catalog` endpoint that returns all public glTF assets with their metadata (name, thumbnail_url, mesh_count, vertex_count, tags). Add filtering by tags and pagination. This is what the editor's asset palette queries. Uses the existing `Asset.is_public` field.
|
||||
- **Acceptance:** Catalog endpoint returns paginated list of public assets; filtering by tag works; pagination params respected; returns 401 without auth.
|
||||
- **Dependencies:** T1.1
|
||||
|
||||
#### T1.3: Godot — glTF Import Utility (Editor-Side)
|
||||
- **Type:** implementation
|
||||
- **Files:** `scripts/gltf_importer.gd` (new), `scripts/asset_catalog.gd` (new)
|
||||
- **Scope:** Godot-side utility that connects to the catalog API, downloads glTF files, and converts them to Godot-compatible resources (`.res` files with embedded mesh data). Provides `import_gltf(url: String) -> Resource` function. Handles texture extraction and mesh merging. Uses the ApiClient autoload (from client-server-wiring plan) for HTTP.
|
||||
- **Acceptance:** `import_gltf()` downloads a glTF from the catalog, converts mesh to `Mesh` resource, returns a ready-to-use Godot resource. Texture files stored in `user://assets/`.
|
||||
- **Dependencies:** T1.2, client-server-wiring ApiClient (task 1 from that plan)
|
||||
|
||||
### US-2: Script Editor with Sandboxed API
|
||||
|
||||
> **Design note from `scripting_sandbox.md`:** Godot 4 has no built-in GDScript sandbox. The recommended approach is a **data-driven DSL** (JSON/Resource) interpreted by trusted GDScript, not arbitrary code execution. This task follows that recommendation.
|
||||
|
||||
#### T2.1: Backend — Script Model & CRUD Endpoints
|
||||
- **Type:** architecture
|
||||
- **Files:** `backend/app/models/script.py` (new), `backend/app/routers/scripts.py` (new), `backend/app/main.py` (register router), `backend/app/models/__init__.py` (export)
|
||||
- **Scope:** Create `Script` model (id, name, world_id, owner_id, source_code TEXT, version INT, script_type ENUM[gdscript, dsl], dsl_payload JSON, created_at, updated_at). Add full CRUD endpoints: `POST /api/scripts/` (create), `GET /api/scripts/` (list), `GET /api/scripts/{id}` (get), `PUT /api/scripts/{id}` (update), `DELETE /api/scripts/{id}` (delete). Scripts are owned by users and associated with a world. `dsl_payload` stores the JSON/Resource data for the DSL approach; `source_code` stores raw GDScript if used.
|
||||
- **Acceptance:** All CRUD endpoints work; scripts are owned by creator; list endpoint filters by owner/world; update increments version; DSL and gdscript types supported.
|
||||
- **Dependencies:** None (new model + router)
|
||||
|
||||
#### T2.2: Godot — Script API Surface Definition
|
||||
- **Type:** architecture
|
||||
- **Files:** `scripts/editor_script_api.gd` (new)
|
||||
- **Scope:** Define the public API surface available to creator scripts. This is a `RefCounted` class that wraps the Godot engine's capabilities into a safe, documented interface. Includes: `WorldAPI` (get_tile, get_object, set_property), `PlayerAPI` (get_position, get_health), `AudioAPI` (play_sound, play_music), `NetworkAPI` (send_event, broadcast). Each method has documented parameters and return types. Self-documenting via `##` doc comments for autocomplete.
|
||||
- **Acceptance:** `EditorScriptAPI` class exposes all documented methods; methods are callable from user scripts; API surface is self-documenting.
|
||||
- **Dependencies:** None (standalone definition)
|
||||
|
||||
#### T2.3: Godot — In-Game Script Editor UI
|
||||
- **Type:** implementation
|
||||
- **Files:** `scenes/editor_script_editor.tscn` (new), `scripts/editor_script_editor.gd` (new)
|
||||
- **Scope:** A `Control`-based UI panel that provides a script editor within the game. Features: code editor with syntax highlighting (basic), autocomplete dropdown, run button (calls backend `/run` endpoint), output console (shows stdout/stderr), API reference panel (shows available methods). Integrates with the existing editor toolbar.
|
||||
- **Acceptance:** Editor panel opens from toolbar; code editor accepts input; autocomplete shows API methods; run button executes script and shows output; API reference panel is browsable.
|
||||
- **Dependencies:** T2.1, T2.2
|
||||
|
||||
### US-3: Experience Publishing Flow
|
||||
|
||||
#### T3.1: Backend — Experience Model & Status Transitions
|
||||
- **Type:** architecture
|
||||
- **Files:** `backend/app/models/experience.py` (new), `backend/app/routers/experiences.py` (new), `backend/app/main.py` (register router), `backend/app/models/__init__.py` (export)
|
||||
- **Scope:** Create `Experience` model (id, world_id FK→worlds, title, description, status ENUM[DRAFT,SUBMITTED,IN_REVIEW,APPROVED,REJECTED,LIVE], published_at, review_notes, reviewer_id FK→users). Status transitions enforced: DRAFT→SUBMITTED→IN_REVIEW→APPROVED→LIVE; REJECTED→DRAFT (resubmit). Add endpoints: `POST /api/experiences/` (create draft), `POST /api/experiences/{id}/submit` (submit for review), `POST /api/experiences/{id}/approve` (approve), `POST /api/experiences/{id}/reject` (reject), `GET /api/experiences/` (list with status filter). An experience is a **publishing wrapper** around a World — the world's visibility is set to PUBLIC when experience goes LIVE.
|
||||
- **Acceptance:** All CRUD + transition endpoints work; status transitions enforced; rejected can be resubmitted; list filters by status; approve sets world visibility to PUBLIC; only world owner can submit; only moderators can approve/reject.
|
||||
- **Dependencies:** None (new model + router)
|
||||
|
||||
#### T3.2: Backend — Review Queue & Moderation Integration
|
||||
- **Type:** implementation
|
||||
- **Files:** `backend/app/routers/experiences.py` (add endpoints)
|
||||
- **Scope:** Add review queue endpoint `GET /api/experiences/review-queue` (returns experiences in IN_REVIEW status). Add `POST /api/experiences/{id}/review` endpoint for moderators to add review notes and change status. Integrates with content moderation: when an experience is submitted, its world's content flags are computed and attached to the review.
|
||||
- **Acceptance:** Review queue returns IN_REVIEW experiences; review endpoint accepts notes and status change; content flags attached on submit; only moderators can review.
|
||||
- **Dependencies:** T3.1
|
||||
|
||||
#### T3.3: Godot — Publishing UI Panel
|
||||
- **Type:** implementation
|
||||
- **Files:** `scenes/editor_publish_panel.tscn` (new), `scripts/editor_publish_panel.gd` (new)
|
||||
- **Scope:** A `Control`-based panel in the editor that lets creators manage their experience's publishing state. Features: title/description editor, status badge (DRAFT/SUBMITTED/LIVE), submit button, review notes display, publish history timeline. Integrates with the existing editor toolbar.
|
||||
- **Acceptance:** Panel shows current status; submit button calls backend; review notes displayed; publish history shown; status badge updates on refresh.
|
||||
- **Dependencies:** T3.1, T3.2
|
||||
|
||||
#### T3.4: Godot — Experience List & Discovery
|
||||
- **Type:** implementation
|
||||
- **Files:** `scenes/experience_list.tscn` (new), `scripts/experience_list.gd` (new)
|
||||
- **Scope:** A UI panel that lists all experiences available to the player (LIVE status). Features: search/filter, experience cards (title, description, creator, play count), join button. Queries `GET /api/experiences/?status=LIVE` and displays results.
|
||||
- **Acceptance:** Lists LIVE experiences; search filters by title/description; cards show metadata; join button loads the experience's world.
|
||||
- **Dependencies:** T3.1
|
||||
|
||||
### US-4: Creator Analytics
|
||||
|
||||
#### T4.1: Backend — Session & Play Tracking Models
|
||||
- **Type:** architecture
|
||||
- **Files:** `backend/app/models/analytics.py` (new), `backend/app/routers/analytics.py` (new), `backend/app/main.py` (register router), `backend/app/models/__init__.py` (export)
|
||||
- **Scope:** Create `PlaySession` model (id, experience_id FK→experiences, player_id FK→users, started_at, ended_at, duration_seconds, chunk_loads INT, interactions_count INT). Create `PlayEvent` model (id, experience_id FK→experiences, player_id FK→users, event_type, payload JSON, created_at). Add endpoints: `POST /api/analytics/session/start` (start session), `POST /api/analytics/session/end` (end session), `POST /api/analytics/event` (log event). Note: `sessions` table is taken by auth tokens; this uses `play_sessions` table name.
|
||||
- **Acceptance:** Session start/end endpoints work; events logged; duration calculated correctly; endpoints accept valid experience/player IDs.
|
||||
- **Dependencies:** None (new models + routers)
|
||||
|
||||
#### T4.2: Backend — Creator Dashboard Analytics Endpoint
|
||||
- **Type:** implementation
|
||||
- **Files:** `backend/app/routers/analytics.py` (add endpoint)
|
||||
- **Scope:** Add `GET /api/analytics/experience/{id}` endpoint that returns aggregated analytics for an experience: total_plays, unique_players, avg_session_length, total_play_time, plays_over_time (daily breakdown), top_chunks_loaded, top_interactions. Data is aggregated from PlaySession and PlayEvent tables.
|
||||
- **Acceptance:** Returns all aggregated metrics; daily breakdown covers last 30 days; only owner or moderator can view; empty experience returns zeros.
|
||||
- **Dependencies:** T4.1
|
||||
|
||||
#### T4.3: Godot — Analytics Tracking in Game
|
||||
- **Type:** implementation
|
||||
- **Files:** `scripts/analytics_tracker.gd` (new)
|
||||
- **Scope:** A singleton-like service that tracks player sessions and events during gameplay. Automatically starts session on world load, tracks chunk loads, interactions, and time spent. Sends periodic heartbeat events to backend. Handles session end on world unload or disconnect.
|
||||
- **Acceptance:** Session starts on world load; events logged during play; heartbeat sends every 60s; session ends on world unload; handles disconnect gracefully.
|
||||
- **Dependencies:** T4.1
|
||||
|
||||
#### T4.4: Godot — Creator Analytics Dashboard UI
|
||||
- **Type:** implementation
|
||||
- **Files:** `scenes/creator_analytics_panel.tscn` (new), `scripts/creator_analytics_panel.gd` (new)
|
||||
- **Scope:** A `Control`-based panel in the editor that displays analytics for the current experience. Features: total plays counter, avg session length, plays over time chart (simple bar chart), top chunks, top interactions. Queries `GET /api/analytics/experience/{id}` and renders data.
|
||||
- **Acceptance:** Panel shows all metrics; chart renders bar chart of daily plays; data refreshes on button press; handles empty data gracefully.
|
||||
- **Dependencies:** T4.2, T4.3
|
||||
|
||||
---
|
||||
|
||||
## Database Migration Plan
|
||||
|
||||
New tables needed (in order):
|
||||
1. `scripts` — script storage
|
||||
2. `experiences` — publishing state (wraps worlds)
|
||||
3. `play_sessions` — session tracking (note: `sessions` table already exists for auth tokens)
|
||||
4. `play_events` — granular events
|
||||
|
||||
Migration file: `backend/alembic/versions/0003_creator_studio.py`
|
||||
|
||||
---
|
||||
|
||||
## Task Dependencies Summary
|
||||
|
||||
```
|
||||
T1.1 (glTF upload) ──→ T1.2 (catalog API) ──→ T1.3 (Godot importer)
|
||||
T2.1 (script model) ──→ T2.3 (editor UI)
|
||||
T2.2 (API surface) ──→ T2.3 (editor UI)
|
||||
T3.1 (experience model) ──→ T3.2 (review queue) ──→ T3.3 (publish UI)
|
||||
T3.1 ──→ T3.4 (discovery list)
|
||||
T4.1 (tracking model) ──→ T4.2 (dashboard endpoint) ──→ T4.4 (analytics UI)
|
||||
T4.1 ──→ T4.3 (game tracker) ──→ T4.4 (analytics UI)
|
||||
```
|
||||
|
||||
## Implementation Order Recommendation
|
||||
|
||||
1. **T1.1** — Foundation: asset upload with glTF metadata extraction
|
||||
2. **T2.1** — Foundation: script model + CRUD
|
||||
3. **T3.1** — Foundation: experience model + status transitions
|
||||
4. **T4.1** — Foundation: analytics tracking model
|
||||
5. **T1.2** — Asset catalog for editor
|
||||
6. **T2.2** — Script API surface definition
|
||||
7. **T3.2** — Review queue integration
|
||||
8. **T4.2** — Analytics dashboard endpoint
|
||||
9. **T1.3** — Godot glTF importer (depends on ApiClient from client-server-wiring)
|
||||
10. **T2.3** — Script editor UI
|
||||
11. **T3.3** — Publishing UI panel
|
||||
12. **T3.4** — Experience discovery list
|
||||
13. **T4.3** — Analytics tracker in game
|
||||
14. **T4.4** — Analytics dashboard UI
|
||||
@@ -0,0 +1,295 @@
|
||||
# Economy — Currency, Marketplace & Creator Payouts — Plan
|
||||
|
||||
> **Status:** Planned
|
||||
> **Owner:** Platform team
|
||||
> **Feature ID:** `economy`
|
||||
> **Godot version:** 4.6.2 stable
|
||||
> **Backend:** FastAPI + SQLAlchemy (async) + PostgreSQL + Redis
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document breaks the **Economy** feature into user stories and right-sized
|
||||
implementation tasks. The feature delivers:
|
||||
|
||||
- **Virtual currency ("Credits")** — a single, server-authoritative currency
|
||||
with an immutable, append-only transaction ledger.
|
||||
- **Premium purchase** — buying Credits with real money through a pluggable
|
||||
payment-provider hook.
|
||||
- **Marketplace** — user-generated content (UGC) listings where creators sell
|
||||
items (assets, avatar items, experience passes) to players for Credits.
|
||||
- **Creator payouts** — accumulating creator earnings and paying them out to
|
||||
external accounts through a pluggable payout-provider hook.
|
||||
- **Godot client** — wallet/transaction history, marketplace browse/buy/sell,
|
||||
and creator payout screens.
|
||||
|
||||
The economy is the monetization backbone of the platform. Because it moves
|
||||
virtual value between real people, correctness (no double-spend, no lost funds,
|
||||
no balance drift) and safety (fraud prevention, moderation, audit) are treated
|
||||
as **blocking requirements**, not add-ons.
|
||||
|
||||
## 2. Why correctness is the differentiator
|
||||
|
||||
The platform is a creator economy (players buy UGC from other players). The
|
||||
economy is the highest-risk surface after chat: a single arithmetic or
|
||||
concurrency bug can mint or destroy value and irreversibly erode trust.
|
||||
Correctness is therefore designed in from the start:
|
||||
|
||||
- **One currency, integer units.** Credits are whole-number integers. There are
|
||||
no floats, no rounding, and no client-computed totals.
|
||||
- **Append-only ledger.** Every balance change is a row in a `transactions`
|
||||
table with a signed amount, a `balance_after` snapshot, a reference to the
|
||||
event that caused it, and an **idempotency key** so retries cannot double
|
||||
credit or double debit.
|
||||
- **Atomic, locked mutations.** Balance changes run in a single database
|
||||
transaction with row locking (`SELECT ... FOR UPDATE`) so concurrent
|
||||
purchases/payouts cannot race.
|
||||
- **Escrow hold.** Marketplace proceeds are held in `pending_escrow` for a
|
||||
configurable window before becoming available to the seller, so fraudulent
|
||||
or moderated sales can be reversed without clawing back paid-out funds.
|
||||
- **Server-authoritative fees.** The platform commission is computed
|
||||
server-side from a configured rate; the client never supplies the fee or the
|
||||
seller's net.
|
||||
|
||||
## 3. Existing constraints (do not re-build)
|
||||
|
||||
- **Auth:** JWT bearer tokens; `get_current_user` / `CurrentUser` dependency in
|
||||
`app/dependencies.py`. All economy endpoints reuse this.
|
||||
- **DB:** async SQLAlchemy + Alembic; migration `0001_initial` is the head.
|
||||
Existing tables: `users`, `sessions`, `worlds`, `assets`.
|
||||
- **Redis:** configured (`REDIS_URL`) but unused so far; adopted here for
|
||||
idempotency short-caching and rate limiting.
|
||||
- **Models:** `User` (owner_id), `Asset` (owner_id, is_public, asset_type).
|
||||
Marketplace items reference existing `assets` rows and/or avatar items.
|
||||
- **Content moderation:** `docs/architecture/content_moderation.md` defines
|
||||
flag vocabulary, age gate, and moderation actions. Marketplace items and
|
||||
listings must inherit and enforce these rules (see §7).
|
||||
- **Client:** Godot 4.6.2, minimal `main_menu.gd`; no economy UI yet.
|
||||
|
||||
## 4. Key design decisions
|
||||
|
||||
| Concern | Decision |
|
||||
|---|---|
|
||||
| Currency model | Single virtual currency **"Credits" (CR)**, integer units. One wallet row per user; no fractional amounts. |
|
||||
| Ledger | Append-only `transactions` table: signed `amount`, `balance_after`, `reference_type`/`reference_id`, unique `idempotency_key`. |
|
||||
| Concurrency | `SELECT ... FOR UPDATE` on the wallet row inside the mutation transaction; never read-modify-write from stale data. |
|
||||
| Payments | Pluggable `PaymentProvider` interface with a **no-op default** that simulates a confirmed payment for dev/testing (clearly flagged). Stripe/PayPal later. |
|
||||
| Marketplace items | `catalog_items` (what is sold) + `marketplace_listings` (a seller's offer with a price/status). One item can have multiple listings over time. |
|
||||
| Purchase flow | Atomic transaction: debit buyer → hold funds in escrow (`pending_escrow`) → grant item. Platform fee computed server-side. |
|
||||
| Escrow | Configurable hold window (default 72h) before `pending_escrow` becomes available seller earnings. Refunds during the window reverse the sale. |
|
||||
| Fees | Single configurable `MARKETPLACE_FEE_RATE` (default `0.30`). `seller_net = floor(price * (1 - fee_rate))`. |
|
||||
| Creator balance | `creator_balances` table: `available` (earned, payable), `pending` (inside an open payout request), `lifetime_earned`. Updated in the same transaction as the sale completion. |
|
||||
| Payouts | Pluggable `PayoutProvider.pay()` with a no-op default (simulated external ref). Minimum payout threshold, admin review for large payouts. |
|
||||
| Idempotency | Every money-moving request carries a client-generated `Idempotency-Key`; duplicate keys return the original result. |
|
||||
| Rate limiting | Redis token-bucket dependency reused from the social feature for purchase/listing/payout endpoints. |
|
||||
| Moderation | Listings enforce content flags + age gate; moderators can `delist`/`remove`; fraud/chargeback reversal is an audited admin action. |
|
||||
|
||||
## 5. User stories
|
||||
|
||||
### US-1 Currency & wallet
|
||||
As a player, I can see my Credit balance and transaction history, and the system
|
||||
credits/debits my wallet only through server-authoritative events.
|
||||
|
||||
**Acceptance criteria**
|
||||
- Exactly one wallet per user, created lazily on first use.
|
||||
- Balance is always a non-negative integer; no operation can make it negative.
|
||||
- Every mutation appends an immutable ledger row with amount, resulting balance,
|
||||
reference, and idempotency key.
|
||||
- Retrying a request with the same idempotency key does not double-apply.
|
||||
- `GET` endpoints expose current balance and paged transaction history.
|
||||
|
||||
### US-2 Premium purchase (buy Credits)
|
||||
As a player, I can purchase Credits with real money and have them credited only
|
||||
after the payment is confirmed.
|
||||
|
||||
**Acceptance criteria**
|
||||
- A purchase session is created through the payment provider (or the no-op
|
||||
default) and is idempotent.
|
||||
- Credits are granted exactly once, only after a verified provider confirmation
|
||||
(webhook/confirm endpoint), never on client claim.
|
||||
- Purchase amounts are bounded and server-defined; clients cannot pick arbitrary
|
||||
prices.
|
||||
- Confirmation is idempotent and audited (provider reference recorded).
|
||||
|
||||
### US-3 Marketplace listing (sell)
|
||||
As a creator, I can list a catalog item for sale at a Credit price and
|
||||
manage/remove my listings.
|
||||
|
||||
**Acceptance criteria**
|
||||
- A listing references a valid `catalog_item` owned by the seller (or an
|
||||
authorized seller).
|
||||
- Price is bounded (min/max) and set server-side; status transitions are
|
||||
explicit (`active`, `sold`, `delisted`, `removed_by_moderation`).
|
||||
- Only the seller (or a moderator) can update/remove a listing.
|
||||
- Listings are searchable/filterable (item type, price range, owner).
|
||||
|
||||
### US-4 Marketplace purchase (buy)
|
||||
As a player, I can buy a listed item. The purchase is atomic, holds funds in
|
||||
escrow, grants the item, and splits the payment between platform and seller.
|
||||
|
||||
**Acceptance criteria**
|
||||
- A single transaction debits the buyer, holds funds in escrow, and grants the
|
||||
item — no partial states on failure.
|
||||
- Platform fee and seller net are computed server-side from the configured rate.
|
||||
- After the escrow hold window, funds move from escrow to the seller's
|
||||
available balance.
|
||||
- Refunds during the hold window reverse the sale and return funds to the buyer.
|
||||
- Concurrent purchases of a one-of-a-kind listing cannot both succeed.
|
||||
|
||||
### US-5 Creator payouts
|
||||
As a creator, I can view my earned balance, request a payout, and track its
|
||||
status; admins can review and approve/reject payouts.
|
||||
|
||||
**Acceptance criteria**
|
||||
- Available balance is derived only from completed (escrow-released) sales and
|
||||
completed payouts; it never goes negative.
|
||||
- Payout requests are bounded (min threshold, max per period) and rate-limited.
|
||||
- Large payouts require admin review; all payout requests carry an audit trail
|
||||
(requested by, reviewed by, provider reference, status).
|
||||
- The payout provider is called exactly once; idempotent on retry.
|
||||
|
||||
### US-6 Godot client
|
||||
As a player, I can manage Credits, browse/buy/sell marketplace items, and manage
|
||||
payouts from the game UI.
|
||||
|
||||
**Acceptance criteria**
|
||||
- An API client handles JWT auth, HTTP, and idempotency keys for all economy
|
||||
calls.
|
||||
- Dedicated screens: wallet + transaction history, marketplace browse/buy,
|
||||
sell-listing, and creator payout.
|
||||
- The client never computes balances, fees, or seller nets — it only displays
|
||||
server responses.
|
||||
|
||||
## 6. Task breakdown
|
||||
|
||||
Right-sized to 1–3 files / one coherent piece of functionality each. Task IDs
|
||||
are sequential for dependency chaining.
|
||||
|
||||
| # | Task | Story | Depends on |
|
||||
|---|------|-------|-----------|
|
||||
| 1 | Wallet & ledger models + Alembic migration (`wallets`, `transactions`) | US-1 | — |
|
||||
| 2 | Wallet service (atomic credit/debit, idempotency, locking) + wallet/transactions API | US-1 | 1 |
|
||||
| 3 | Premium purchase: `PaymentProvider` interface + purchase/confirm endpoints | US-2 | 1 |
|
||||
| 4 | Catalog item & listing models + migration (`catalog_items`, `marketplace_listings`, `creator_balances`) | US-3 | 1 |
|
||||
| 5 | Marketplace listing API (create/list/search/update/remove) | US-3 | 4 |
|
||||
| 6 | Marketplace purchase service + API (atomic escrow, fee split, item grant) | US-4 | 2, 4, 5 |
|
||||
| 7 | Creator payout request models + API (`payout_requests`) | US-5 | 2, 6 |
|
||||
| 8 | Payout admin review + `PayoutProvider` + audit | US-5 | 7 |
|
||||
| 9 | Marketplace moderation + content-flag/age-gate enforcement | cross-cutting | 5, 6 |
|
||||
| 10 | Godot economy API client (auth + HTTP + idempotency) | US-6 | 2 |
|
||||
| 11 | Godot wallet & transaction history UI | US-6 | 10 |
|
||||
| 12 | Godot marketplace browse/buy UI | US-6 | 6, 10 |
|
||||
| 13 | Godot selling & payout UI | US-6 | 5, 7, 10 |
|
||||
|
||||
## 7. Phasing
|
||||
|
||||
- **Phase 1 (currency + payments):** tasks 1–3. The foundation — ledger and
|
||||
money movement must be rock-solid before anything spends Credits.
|
||||
- **Phase 2 (marketplace):** tasks 4–6. Listing and atomic purchase with escrow.
|
||||
- **Phase 3 (payouts):** tasks 7–8. Turn released escrow into creator payouts.
|
||||
- **Phase 4 (moderation):** task 9. Lock down the marketplace against mature
|
||||
content and abuse.
|
||||
- **Phase 5 (client):** tasks 10–13, each landing after its backend dependency.
|
||||
|
||||
## 8. Data model sketch
|
||||
|
||||
```text
|
||||
wallets
|
||||
id bigint PK
|
||||
user_id int UNIQUE FK -> users.id
|
||||
balance bigint NOT NULL DEFAULT 0 CHECK (balance >= 0)
|
||||
created_at timestamptz
|
||||
updated_at timestamptz
|
||||
|
||||
transactions (append-only ledger)
|
||||
id bigint PK
|
||||
wallet_id bigint FK -> wallets.id
|
||||
type enum(credit_purchase, marketplace_purchase,
|
||||
marketplace_sale, escrow_release, payout,
|
||||
refund, admin_adjustment)
|
||||
amount bigint NOT NULL (signed)
|
||||
balance_after bigint NOT NULL
|
||||
reference_type text (e.g. "purchase", "sale", "payout_request")
|
||||
reference_id text
|
||||
idempotency_key text UNIQUE
|
||||
metadata_json jsonb
|
||||
created_at timestamptz
|
||||
|
||||
catalog_items
|
||||
id bigint PK
|
||||
owner_id int FK -> users.id
|
||||
asset_id int FK -> assets.id (nullable; null for non-asset virtual items)
|
||||
item_type enum(asset, avatar_item, experience_pass)
|
||||
name text
|
||||
description text
|
||||
metadata_json jsonb
|
||||
created_at timestamptz
|
||||
|
||||
marketplace_listings
|
||||
id bigint PK
|
||||
catalog_item_id bigint FK -> catalog_items.id
|
||||
seller_id int FK -> users.id
|
||||
price bigint NOT NULL CHECK (price > 0)
|
||||
status enum(active, sold, delisted, removed_by_moderation)
|
||||
created_at timestamptz
|
||||
updated_at timestamptz
|
||||
|
||||
purchases
|
||||
id bigint PK
|
||||
listing_id bigint FK -> marketplace_listings.id
|
||||
buyer_id int FK -> users.id
|
||||
seller_id int FK -> users.id
|
||||
price bigint NOT NULL
|
||||
platform_fee bigint NOT NULL
|
||||
seller_net bigint NOT NULL
|
||||
status enum(pending_escrow, completed, refunded, reversed)
|
||||
escrow_hold_until timestamptz
|
||||
created_at timestamptz
|
||||
|
||||
creator_balances
|
||||
id bigint PK
|
||||
user_id int UNIQUE FK -> users.id
|
||||
available bigint NOT NULL DEFAULT 0
|
||||
pending bigint NOT NULL DEFAULT 0
|
||||
lifetime_earned bigint NOT NULL DEFAULT 0
|
||||
updated_at timestamptz
|
||||
|
||||
payout_requests
|
||||
id bigint PK
|
||||
creator_id int FK -> users.id
|
||||
amount bigint NOT NULL CHECK (amount > 0)
|
||||
status enum(pending, approved, paid, rejected)
|
||||
external_ref text
|
||||
provider_ref text
|
||||
requested_by int FK -> users.id
|
||||
reviewed_by int FK -> users.id (nullable)
|
||||
reviewed_at timestamptz
|
||||
created_at timestamptz
|
||||
```
|
||||
|
||||
## 9. Security, anti-fraud & audit
|
||||
|
||||
- **Server-authoritative balances/fees:** clients never compute totals; amounts
|
||||
are validated server-side against bounds and the current listing.
|
||||
- **Row locking:** wallet and creator-balance mutations use `SELECT ... FOR
|
||||
UPDATE` within one transaction.
|
||||
- **Idempotency:** every money-moving endpoint requires an `Idempotency-Key`;
|
||||
duplicate keys return the stored original outcome.
|
||||
- **Rate limiting:** purchase, listing, and payout endpoints are token-bucket
|
||||
rate-limited per user.
|
||||
- **Escrow & reversibility:** funds are not payable until the hold window
|
||||
elapses; chargebacks/refunds reverse before release.
|
||||
- **Moderation:** listings carry the item's effective content flags; mature
|
||||
items require age verification (per `content_moderation.md`); moderators can
|
||||
`delist`/`remove` and trigger audited reversals.
|
||||
- **Audit:** the ledger plus `payout_requests` review fields form an append-only
|
||||
audit trail for every movement of value.
|
||||
|
||||
## 10. Out of scope for this feature
|
||||
|
||||
- Real Stripe/PayPal/KYC integrations (provider interfaces leave the hooks; the
|
||||
defaults are deterministic no-ops).
|
||||
- Subscriptions, recurring billing, or premium memberships.
|
||||
- Gift cards, promotional codes, or user-to-user Credit transfers.
|
||||
- Partial refunds, chargeback disputes, and seller tax/VAT handling.
|
||||
- In-world vending machines / scripted economy triggers (future, built on this
|
||||
ledger via the scripting sandbox).
|
||||
Reference in New Issue
Block a user