App Store Optimization SaaS — web-based alternative to aso.dev. Focuses on price management across locales and keyword analysis.
- Backend: FastAPI (Python 3.12+), SQLAlchemy 2.0 async, Alembic, pydantic-settings, uv
- Database: SQLite (dev via
sqlite+aiosqlite) / PostgreSQL (prod viaasyncpg) - Auth: JWT HS256 (python-jose), bcrypt direct (NOT passlib — version conflict), Fernet for .p8 key encryption
- ASC API auth: ES256 JWT via PyJWT — NOT python-jose (different signing interface needed)
- HTTP client: httpx (async)
- Exchange rates: rate-cache-api at
api.overx.ai(166 currencies, no auth, hourly cache) - Frontend: React 19 + TypeScript, Vite, Mantine v8, TanStack Query v5, react-router-dom v7, mantine-datatable, @mantine/charts
- Package managers:
uv(backend),npm(frontend)
- Python imports: always absolute (
from app.core.config import settings, neverfrom .config import settings) - Price math: use
Decimalthroughout — neverfloatfor monetary calculations - SQLAlchemy: use
select()+session.execute()style only — never legacy.query() - ASC ownership: always verify
app.credential_id → credential.user_id == current_user_idbefore any ASC operation - Error messages: never expose raw Python errors to API responses; use
HTTPException - Model style: SQLAlchemy 2.0 mapped_column with
Mapped[type]annotations - FastAPI:
redirect_slashes=False— route paths use""not"/"for root endpoints - httpx: use
aclose()notclose()onAsyncClient - ASC territory codes: ASC API returns alpha-3 (ARE, USA); our DB uses alpha-2 (AE, US) — use
ALPHA2_TO_ALPHA3fromapp/data/territories.py - Pricing endpoints: read from DB cache; ASC sync is explicit via
POST .../sync - ASC binary uploads: use
_put_binary()(no auth headers) for pre-signed S3 URLs — Apple rejects Bearer tokens on those
- Encrypt
.p8keys at rest with Fernet; decrypt in memory only for the duration of an API call - Use
PriceCalculatorABC when adding new index types — never add ad-hoc calculation logic in routes - Use
IndexFetcherABC when adding new economic data sources - Use
apply_currency_rounding()for local-currency rounding — never hardcode.99for non-USD currencies - Add new currency profiles in
currency_rounding.pywhen supporting new currencies with special rounding - Use
_get_all_pages()fromASCClientfor any paginated ASC endpoint - Frontend API calls always go through TanStack Query hooks in
src/lib/hooks.ts
- Start dev:
make dev(starts backend on :8002, frontend on :5173) - DB init: migration-first in all environments — app startup runs Alembic
upgrade headbefore seeding;make db-up && make migrateis the explicit preflight path - Run backend alone:
cd backend && uv run uvicorn app.main:app --reload --port 8002 - Run frontend alone:
cd frontend && npm run dev - Generate Fernet key:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
bcryptused directly (not passlib) — passlib has a version conflict with bcrypt 4.xPyJWTused for ASC tokens,python-joseused for app auth — do NOT mix themProportionalCalculatorbase class is shared by PPP/BigMac/Netflix/Spotify/ExchangeRate (all use same formula)- Exchange rate mode fetches live FX rates from
api.overx.ai— bypasses EconomicIndex table entirely - Smart currency rounding uses ±10% flex to find the "nicest" local price near the raw converted value
ASCClient.from_credential()is the only place private keys are decryptedseed_territories()is idempotent — safe to call on every startup- IAP endpoints require ASC API v2: localizations (
?include=inAppPurchaseLocalizations), price schedule (iapPriceSchedule), and price points all use/v2/inAppPurchases/{id}/...; create/update mutations still use v1 top-level resources - ASC rate limiter: 150ms min interval between requests, global backoff on 429, max 6 retries
- Price point cache: filesystem-based under
backend/.cache/price_points/, not DB — per-territory JSON files - Price safety limits: ±50% — skip territory if price change exceeds this threshold in either direction (constant
SAFETY_BAND_PCTinbackend/app/api/v1/pricing.py) - MCP server: mounted at
/mcpon the FastAPI app, exposes ~123 tools across all REST domains viafastmcp. Auth uses Personal Access Tokens (aso_pat_…, sha256-hashed at rest, model inbackend/app/models/personal_access_token.py, routes under/api/v1/auth/tokens). Tool modules live inbackend/app/mcp/tools/and call service classes directly (no HTTP hop). App-scope is enforced byapp.mcp.context.resolve_appmirroring_get_verified_app. - Product swap: the existing clone subsystem (
POST /apps/{id}/subscriptions/{sub_id}/clonewithauto_archive=True, swap_revenuecat=True) IS the swap. The MCPswap.subscription_product/swap.iaptools wrap it and additionally return anios_checklisttailored to whether RC is wired, whether RC swap succeeded, and which path the iOS app is on. iOS-side guidance lives indocs/006-product-swap-ios-integration.md.
- All 202 territories are seeded on startup via
app/data/seed.py(idempotent) - Economic indices start empty — call
POST /api/v1/indices/refreshto populate - Apple price points are discrete (not arbitrary) — always use
price_point_idfrom preview when applying prices - Cross-localization data is static Python (not DB) — edit
app/services/keywords/cross_localization.pyto update - Netflix/Spotify indices are seed data — update
app/services/indices/netflix.pyandspotify.pyquarterly RATE_CACHE_API_URLdefaults tohttps://api.overx.ai— the rate-cache-api from1B-bots/rate-cache-api- Currency rounding profiles are in
app/services/pricing/currency_rounding.py— update when App Store adds new territories - Subscription immutables:
productIdandsubscriptionPeriodare immutable in ASC after create — never expose them on update paths (SubscriptionUpdateschema enforces this) - Intro offers:
FREE_TRIALrejectsprice_point_id;PAY_AS_YOU_GO/PAY_UP_FRONTrequire it;FREE_TRIALandPAY_UP_FRONTforcenumber_of_periods = 1(validated inIntroOfferCreate);IntroOfferDurationextendsSubscriptionPeriodwithTHREE_DAYSandTWO_WEEKS - Submit-for-review is not automated — subscription state transitions are done manually in App Store Connect
- AI translation is suggestion-only:
AnthropicTranslator(and any futureAbstractTranslatorimpl) returns text for the user to review — the metadata router never auto-applies translations. Per-app monthly soft cap is 500 calls (rolling 30 days, cached); raise viaMetadataTranslationCachetable. - Metadata version state machine: when
editable_version_state == 'READY_FOR_DISTRIBUTION', onlypromotional_textis mutable onappStoreVersionLocalizations. Theeditable_fieldslist returned byGET /apps/{id}/metadatais the source of truth for the UI.
When I switch to plan mode (shift+tab), you MUST only outline steps and reasoning. Do NOT execute any tools in plan mode. Wait for me to switch back to act mode before executing.
- docs/000-architecture.md — Full system architecture
- docs/001-pricing-system.md — Pricing system deep dive
- docs/002-asc-integration.md — ASC API integration details
- docs/003-keyword-analysis.md — Keyword analysis system
- docs/004-localization-management.md — Localization management
- docs/005-subscription-management.md — Subscription / group / intro-offer write paths
- docs/006-product-swap-ios-integration.md — Product swap (clone+archive+RC) and what the iOS app must change
- docs/007-mcp-integration.md — MCP server, PAT lifecycle, tool reference, client config