diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml new file mode 100644 index 0000000..114ac9b --- /dev/null +++ b/.forgejo/workflows/test.yaml @@ -0,0 +1,63 @@ +name: backend-integration-tests + +on: + push: + branches: + - main + - high-performance + +concurrency: + group: backend-tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + ai-credits: + runs-on: docker-ready + env: + TEST_DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check for relevant changes + id: check + run: | + set -euo pipefail + if git rev-parse --verify HEAD^ >/dev/null 2>&1; then + BASE="${{ github.event.before }}" + if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ] \ + || ! git rev-parse --verify "${BASE}^{commit}" >/dev/null 2>&1; then + BASE="HEAD^" + fi + CHANGED_FILES="$(git diff --name-only "$BASE" HEAD)" + else + CHANGED_FILES="$(git ls-files)" + fi + + run=false + if echo "$CHANGED_FILES" | grep -Eq '^(crates/db/|Cargo\.toml|Cargo\.lock|\.forgejo/workflows/test\.yaml)'; then + run=true + fi + echo "run=$run" >> "$GITHUB_OUTPUT" + if [ "$run" = "false" ]; then + echo "No changes relevant to crates/db - skipping integration tests." + fi + + - name: Verify TEST_DATABASE_URL is configured + if: steps.check.outputs.run == 'true' + run: | + test -n "${TEST_DATABASE_URL:-}" || { echo "TEST_DATABASE_URL secret is not set - see docs/LIVE_SERVER_RUNBOOK.md step 6"; exit 1; } + + - name: Install Rust + if: steps.check.outputs.run == 'true' + run: | + set -euo pipefail + if ! command -v cargo >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable + fi + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: Run ai_credits integration tests + if: steps.check.outputs.run == 'true' + run: | + cargo test -p db --test ai_credits --test ai_credits_reaper -- --test-threads=1 diff --git a/.gitignore b/.gitignore index 14c2a88..d237a66 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ Thumbs.db # Logs *.log *.pid + +# schemathesis fuzzing run cache (local only) +.schemathesis/ diff --git a/crates/db/migrations/20260703210000_ai_credits_wallet.down.sql b/crates/db/migrations/20260703210000_ai_credits_wallet.down.sql index df8e43e..75cc516 100644 --- a/crates/db/migrations/20260703210000_ai_credits_wallet.down.sql +++ b/crates/db/migrations/20260703210000_ai_credits_wallet.down.sql @@ -4,17 +4,45 @@ DROP INDEX IF EXISTS idx_ai_reservation_holds_status_expires; DROP INDEX IF EXISTS idx_ai_reservation_holds_wallet_id; DROP INDEX IF EXISTS idx_ai_credit_ledger_reference; DROP INDEX IF EXISTS idx_ai_credit_ledger_wallet_id; -DROP INDEX IF EXISTS idx_ai_usage_logs_created_at; -DROP INDEX IF EXISTS idx_ai_usage_logs_feature_code; -DROP INDEX IF EXISTS idx_ai_usage_logs_user_id; -DROP INDEX IF EXISTS idx_user_ai_subscriptions_plan_id; -DROP INDEX IF EXISTS idx_user_ai_subscriptions_user_id; DROP TABLE IF EXISTS ai_reservation_holds; DROP TABLE IF EXISTS ai_credit_ledger; -DROP TABLE IF EXISTS ai_usage_logs; -DROP TABLE IF EXISTS ai_feature_costs; -DROP TABLE IF EXISTS user_ai_subscriptions; -DROP TABLE IF EXISTS ai_plans; + +-- NOTE: ai_plans, user_ai_subscriptions, ai_feature_costs, and ai_usage_logs +-- are NOT dropped here (unlike the original version of this file) -- they +-- predate this migration (created by an earlier, separate schema pass) and +-- hold real production subscription data. This down-migration only reverses +-- what the corresponding up.sql actually added: the 2 new tables/indexes and +-- the columns/constraint below. + +ALTER TABLE user_ai_subscriptions DROP CONSTRAINT IF EXISTS chk_ai_wallet_nonnegative_v2; + +ALTER TABLE ai_plans DROP COLUMN IF EXISTS daily_credit_limit; +ALTER TABLE ai_plans DROP COLUMN IF EXISTS is_trial; +ALTER TABLE ai_plans DROP COLUMN IF EXISTS trial_days; + +ALTER TABLE user_ai_subscriptions DROP COLUMN IF EXISTS bonus_credits_total; +ALTER TABLE user_ai_subscriptions DROP COLUMN IF EXISTS bonus_credits_used; +ALTER TABLE user_ai_subscriptions DROP COLUMN IF EXISTS reserved_credits; +ALTER TABLE user_ai_subscriptions DROP COLUMN IF EXISTS locked_credits; +ALTER TABLE user_ai_subscriptions DROP COLUMN IF EXISTS lifetime_purchased_credits; +ALTER TABLE user_ai_subscriptions DROP COLUMN IF EXISTS lifetime_used_credits; +ALTER TABLE user_ai_subscriptions DROP COLUMN IF EXISTS daily_credits_used; +ALTER TABLE user_ai_subscriptions DROP COLUMN IF EXISTS daily_usage_date; +ALTER TABLE user_ai_subscriptions DROP COLUMN IF EXISTS purchased_credits_expire_at; +ALTER TABLE user_ai_subscriptions DROP COLUMN IF EXISTS billing_cycle; +ALTER TABLE user_ai_subscriptions DROP COLUMN IF EXISTS auto_renew; + +ALTER TABLE ai_feature_costs DROP COLUMN IF EXISTS min_plan_code; +ALTER TABLE ai_feature_costs DROP COLUMN IF EXISTS priority; +ALTER TABLE ai_feature_costs DROP COLUMN IF EXISTS timeout_ms; +ALTER TABLE ai_feature_costs DROP COLUMN IF EXISTS rate_limit_per_minute; +ALTER TABLE ai_feature_costs DROP COLUMN IF EXISTS retry_policy; +ALTER TABLE ai_feature_costs DROP COLUMN IF EXISTS fallback_model; + +ALTER TABLE ai_usage_logs DROP COLUMN IF EXISTS cached_tokens; +ALTER TABLE ai_usage_logs DROP COLUMN IF EXISTS provider_reported_cost; +ALTER TABLE ai_usage_logs DROP COLUMN IF EXISTS internal_cost; +ALTER TABLE ai_usage_logs DROP COLUMN IF EXISTS credit_unit_price_at_time; COMMIT; diff --git a/crates/db/migrations/20260703210000_ai_credits_wallet.up.sql b/crates/db/migrations/20260703210000_ai_credits_wallet.up.sql new file mode 100644 index 0000000..6a27733 --- /dev/null +++ b/crates/db/migrations/20260703210000_ai_credits_wallet.up.sql @@ -0,0 +1,136 @@ +-- Ask Ash AI Credits: wallet, ledger, plans, feature pricing, usage logs. +-- Phase 1 of docs/ASK_ASH_BILLING_ARCHITECTURE.md (nxtgauge-ai-assistant repo). +-- +-- This is a net-new schema. It does not extend or replace the existing +-- company_ai_usage / job_seeker_ai_usage daily-count quota tables +-- (20260425000000_ai_usage.up.sql) -- those stay in place until a later +-- migration phase consolidates users onto this system (see Section 19 of +-- the architecture doc). It also does not touch tracecoin_wallets / +-- tracecoin_ledger, which remain a completely separate currency. +-- +-- REWRITTEN 2026-08-13: this migration was originally CREATE TABLE for all +-- six tables below, but got disabled (renamed .up.sql.skip) in commit +-- 0163349 because ai_plans/user_ai_subscriptions/ai_feature_costs/ +-- ai_usage_logs already existed in prod from an earlier, narrower schema +-- pass -- the batch "relation already exists" error aborted the whole +-- script on table 1, silently leaving ai_credit_ledger and +-- ai_reservation_holds (which crates/db/src/models/ai_credits.rs's +-- try_reserve_credits/settle/release actually depend on) never created at +-- all, despite the wallet system being actively wired into +-- apps/users/src/handlers/ai_credits.rs, apps/payments, apps/cron and +-- already holding real subscription rows. Rewritten to be idempotent: +-- ADD COLUMN IF NOT EXISTS to bring the 4 pre-existing tables up to the +-- full column set the application code expects (safe defaults, no data +-- loss), and CREATE TABLE IF NOT EXISTS for the 2 genuinely-missing ones. + +BEGIN; + +-- ── ai_plans: add columns introduced after the table's original creation ── +ALTER TABLE ai_plans ADD COLUMN IF NOT EXISTS daily_credit_limit INT; +ALTER TABLE ai_plans ADD COLUMN IF NOT EXISTS is_trial BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE ai_plans ADD COLUMN IF NOT EXISTS trial_days INT; + +-- ── user_ai_subscriptions: bring up to the wallet shape the code expects ── +-- (crates/db/src/models/ai_credits.rs's AiSubscriptionWallet struct). +-- Existing rows backfill lifetime_*/bonus_*/reserved/locked to 0 -- these +-- are new cumulative counters with no prior history to reconstruct, not a +-- correction of existing monthly_credits_used/purchased_credits_used +-- (which are untouched and remain authoritative for what they track). +ALTER TABLE user_ai_subscriptions ADD COLUMN IF NOT EXISTS bonus_credits_total INT NOT NULL DEFAULT 0; +ALTER TABLE user_ai_subscriptions ADD COLUMN IF NOT EXISTS bonus_credits_used INT NOT NULL DEFAULT 0; +ALTER TABLE user_ai_subscriptions ADD COLUMN IF NOT EXISTS reserved_credits INT NOT NULL DEFAULT 0; +ALTER TABLE user_ai_subscriptions ADD COLUMN IF NOT EXISTS locked_credits INT NOT NULL DEFAULT 0; +ALTER TABLE user_ai_subscriptions ADD COLUMN IF NOT EXISTS lifetime_purchased_credits INT NOT NULL DEFAULT 0; +ALTER TABLE user_ai_subscriptions ADD COLUMN IF NOT EXISTS lifetime_used_credits INT NOT NULL DEFAULT 0; +ALTER TABLE user_ai_subscriptions ADD COLUMN IF NOT EXISTS daily_credits_used INT NOT NULL DEFAULT 0; +ALTER TABLE user_ai_subscriptions ADD COLUMN IF NOT EXISTS daily_usage_date DATE NOT NULL DEFAULT CURRENT_DATE; +ALTER TABLE user_ai_subscriptions ADD COLUMN IF NOT EXISTS purchased_credits_expire_at TIMESTAMPTZ; +ALTER TABLE user_ai_subscriptions ADD COLUMN IF NOT EXISTS billing_cycle VARCHAR(20) NOT NULL DEFAULT 'monthly'; +ALTER TABLE user_ai_subscriptions ADD COLUMN IF NOT EXISTS auto_renew BOOLEAN NOT NULL DEFAULT TRUE; + +-- Matches the original migration's chk_ai_wallet_nonnegative, guarded since +-- Postgres has no ADD CONSTRAINT IF NOT EXISTS. Safe against the 3 existing +-- rows: all newly-added columns default to 0, which trivially satisfies >= 0. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_ai_wallet_nonnegative_v2' + ) THEN + ALTER TABLE user_ai_subscriptions ADD CONSTRAINT chk_ai_wallet_nonnegative_v2 CHECK ( + monthly_credits_used >= 0 AND monthly_credits_used <= monthly_credits_total + AND purchased_credits_used >= 0 AND purchased_credits_used <= purchased_credits_total + AND bonus_credits_used >= 0 AND bonus_credits_used <= bonus_credits_total + AND reserved_credits >= 0 + AND locked_credits >= 0 + ); + END IF; +END $$; + +-- ── ai_feature_costs: columns the pricing/dispatch code reads ───────────── +-- (crates/db/src/models/ai_credits.rs's feature-cost SELECT lists +-- min_plan_code, timeout_ms, fallback_model explicitly -- these were +-- erroring with "column does not exist" against the live table before +-- this migration). +ALTER TABLE ai_feature_costs ADD COLUMN IF NOT EXISTS min_plan_code VARCHAR(50); +ALTER TABLE ai_feature_costs ADD COLUMN IF NOT EXISTS priority VARCHAR(20) NOT NULL DEFAULT 'normal'; +ALTER TABLE ai_feature_costs ADD COLUMN IF NOT EXISTS timeout_ms INT NOT NULL DEFAULT 15000; +ALTER TABLE ai_feature_costs ADD COLUMN IF NOT EXISTS rate_limit_per_minute INT; +ALTER TABLE ai_feature_costs ADD COLUMN IF NOT EXISTS retry_policy JSONB NOT NULL DEFAULT '{"max_retries": 1, "backoff_ms": 500}'; +ALTER TABLE ai_feature_costs ADD COLUMN IF NOT EXISTS fallback_model VARCHAR(100); + +-- ── ai_usage_logs: cost-tracking columns from the original design ───────── +-- (prompt_preview/response_preview already exist in prod from a later, +-- separate migration not part of this file -- left untouched). +ALTER TABLE ai_usage_logs ADD COLUMN IF NOT EXISTS cached_tokens INT; +ALTER TABLE ai_usage_logs ADD COLUMN IF NOT EXISTS provider_reported_cost NUMERIC(12, 6); +ALTER TABLE ai_usage_logs ADD COLUMN IF NOT EXISTS internal_cost NUMERIC(12, 6); +ALTER TABLE ai_usage_logs ADD COLUMN IF NOT EXISTS credit_unit_price_at_time NUMERIC(12, 6); + +-- ── Genuinely missing tables ─────────────────────────────────────────────── + +-- Immutable append-only ledger -- the source of truth for wallet balances +-- (Section 5 of the architecture doc). Application code must never UPDATE +-- or DELETE rows here; corrections are new rows referencing the row they +-- correct via reference_type='ledger_entry'/reference_id. +CREATE TABLE IF NOT EXISTS ai_credit_ledger ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + wallet_id UUID NOT NULL REFERENCES user_ai_subscriptions(id), + entry_type VARCHAR(40) NOT NULL, + credits INT NOT NULL, + balance_after INT NOT NULL, + idempotency_key VARCHAR(150) UNIQUE, + reference_type VARCHAR(50), + reference_id UUID, + actor_type VARCHAR(20) NOT NULL DEFAULT 'user', + actor_id UUID, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Backs the reserve -> capture/release flow (Section 4.2). A background +-- reaper (crates/db/tests/ai_credits_reaper.rs / apps/cron) releases `held` +-- rows past expires_at back to the wallet's available balance. +CREATE TABLE IF NOT EXISTS ai_reservation_holds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + wallet_id UUID NOT NULL REFERENCES user_ai_subscriptions(id), + credits_held INT NOT NULL, + feature_code VARCHAR(100) NOT NULL, + request_id VARCHAR(150), + idempotency_key VARCHAR(150) UNIQUE, + status VARCHAR(20) NOT NULL DEFAULT 'held', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '5 minutes'), + resolved_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_ai_credit_ledger_wallet_id ON ai_credit_ledger(wallet_id, created_at); +CREATE INDEX IF NOT EXISTS idx_ai_credit_ledger_reference ON ai_credit_ledger(reference_type, reference_id); +CREATE INDEX IF NOT EXISTS idx_ai_reservation_holds_wallet_id ON ai_reservation_holds(wallet_id); +CREATE INDEX IF NOT EXISTS idx_ai_reservation_holds_status_expires ON ai_reservation_holds(status, expires_at) WHERE status = 'held'; + +-- Free plan already exists in prod (seeded some other way) -- don't fail on it. +INSERT INTO ai_plans (code, name, monthly_credits, daily_action_limit, daily_credit_limit, allowed_models, allowed_features) +VALUES ('free', 'Free', 10, 3, 10, '["askash-fast"]', '[]') +ON CONFLICT (code) DO NOTHING; + +COMMIT; diff --git a/crates/db/migrations/20260703210000_ai_credits_wallet.up.sql.skip b/crates/db/migrations/20260703210000_ai_credits_wallet.up.sql.skip deleted file mode 100644 index 5231e9a..0000000 --- a/crates/db/migrations/20260703210000_ai_credits_wallet.up.sql.skip +++ /dev/null @@ -1,167 +0,0 @@ --- Ask Ash AI Credits: wallet, ledger, plans, feature pricing, usage logs. --- Phase 1 of docs/ASK_ASH_BILLING_ARCHITECTURE.md (nxtgauge-ai-assistant repo). --- --- This is a net-new schema. It does not extend or replace the existing --- company_ai_usage / job_seeker_ai_usage daily-count quota tables --- (20260425000000_ai_usage.up.sql) -- those stay in place until a later --- migration phase consolidates users onto this system (see Section 19 of --- the architecture doc). It also does not touch tracecoin_wallets / --- tracecoin_ledger, which remain a completely separate currency. - -BEGIN; - -CREATE TABLE ai_plans ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code VARCHAR(50) UNIQUE NOT NULL, - name VARCHAR(100) NOT NULL, - monthly_credits INT NOT NULL, - daily_action_limit INT NOT NULL, - daily_credit_limit INT, - allowed_models JSONB NOT NULL DEFAULT '[]', - allowed_features JSONB NOT NULL DEFAULT '[]', - is_trial BOOLEAN NOT NULL DEFAULT FALSE, - trial_days INT, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- The AI credits wallet. One row per user, mirroring tracecoin_wallets' --- balance/reserved shape (crates/db/src/models/tracecoin_wallet.rs) but --- split into pools (monthly/purchased/bonus) so expiry and consumption --- order can differ per pool -- see Section 4 of the architecture doc. -CREATE TABLE user_ai_subscriptions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE, - plan_id UUID NOT NULL REFERENCES ai_plans(id), - role_code VARCHAR(50), - - monthly_credits_total INT NOT NULL DEFAULT 0, - monthly_credits_used INT NOT NULL DEFAULT 0, - purchased_credits_total INT NOT NULL DEFAULT 0, - purchased_credits_used INT NOT NULL DEFAULT 0, - bonus_credits_total INT NOT NULL DEFAULT 0, - bonus_credits_used INT NOT NULL DEFAULT 0, - - reserved_credits INT NOT NULL DEFAULT 0, - locked_credits INT NOT NULL DEFAULT 0, - - lifetime_purchased_credits INT NOT NULL DEFAULT 0, - lifetime_used_credits INT NOT NULL DEFAULT 0, - - daily_actions_used INT NOT NULL DEFAULT 0, - daily_credits_used INT NOT NULL DEFAULT 0, - daily_usage_date DATE NOT NULL DEFAULT CURRENT_DATE, - - purchased_credits_expire_at TIMESTAMPTZ, - - billing_cycle VARCHAR(20) NOT NULL DEFAULT 'monthly', - auto_renew BOOLEAN NOT NULL DEFAULT TRUE, - current_period_start TIMESTAMPTZ NOT NULL DEFAULT NOW(), - current_period_end TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '30 days'), - status VARCHAR(30) NOT NULL DEFAULT 'active', - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - CONSTRAINT chk_ai_wallet_nonnegative CHECK ( - monthly_credits_used >= 0 AND monthly_credits_used <= monthly_credits_total - AND purchased_credits_used >= 0 AND purchased_credits_used <= purchased_credits_total - AND bonus_credits_used >= 0 AND bonus_credits_used <= bonus_credits_total - AND reserved_credits >= 0 - AND locked_credits >= 0 - ) -); - -CREATE TABLE ai_feature_costs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - feature_code VARCHAR(100) UNIQUE NOT NULL, - display_name VARCHAR(150) NOT NULL, - default_model VARCHAR(100) NOT NULL, - credit_cost INT NOT NULL, - max_input_tokens INT, - max_output_tokens INT, - min_plan_code VARCHAR(50), - priority VARCHAR(20) NOT NULL DEFAULT 'normal', - timeout_ms INT NOT NULL DEFAULT 15000, - rate_limit_per_minute INT, - retry_policy JSONB NOT NULL DEFAULT '{"max_retries": 1, "backoff_ms": 500}', - fallback_model VARCHAR(100), - is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE ai_usage_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - role_code VARCHAR(50), - feature_code VARCHAR(100) NOT NULL, - model_alias VARCHAR(100) NOT NULL, - credits_charged INT NOT NULL, - input_tokens INT, - output_tokens INT, - total_tokens INT, - cached_tokens INT, - provider_reported_cost NUMERIC(12, 6), - internal_cost NUMERIC(12, 6), - credit_unit_price_at_time NUMERIC(12, 6), - status VARCHAR(30) NOT NULL, - request_id VARCHAR(100), - error_message TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Immutable append-only ledger -- the source of truth for wallet balances --- (Section 5 of the architecture doc). Application code must never UPDATE --- or DELETE rows here; corrections are new rows referencing the row they --- correct via reference_type='ledger_entry'/reference_id. -CREATE TABLE ai_credit_ledger ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - wallet_id UUID NOT NULL REFERENCES user_ai_subscriptions(id), - entry_type VARCHAR(40) NOT NULL, - credits INT NOT NULL, - balance_after INT NOT NULL, - idempotency_key VARCHAR(150) UNIQUE, - reference_type VARCHAR(50), - reference_id UUID, - actor_type VARCHAR(20) NOT NULL DEFAULT 'user', - actor_id UUID, - metadata JSONB, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Backs the reserve -> capture/release flow (Section 4.2). A background --- reaper (Phase 1 follow-up, not yet implemented) releases `held` rows --- past expires_at back to the wallet's available balance. -CREATE TABLE ai_reservation_holds ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - wallet_id UUID NOT NULL REFERENCES user_ai_subscriptions(id), - credits_held INT NOT NULL, - feature_code VARCHAR(100) NOT NULL, - request_id VARCHAR(150), - idempotency_key VARCHAR(150) UNIQUE, - status VARCHAR(20) NOT NULL DEFAULT 'held', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '5 minutes'), - resolved_at TIMESTAMPTZ -); - -CREATE INDEX idx_user_ai_subscriptions_user_id ON user_ai_subscriptions(user_id); -CREATE INDEX idx_user_ai_subscriptions_plan_id ON user_ai_subscriptions(plan_id); -CREATE INDEX idx_ai_usage_logs_user_id ON ai_usage_logs(user_id); -CREATE INDEX idx_ai_usage_logs_feature_code ON ai_usage_logs(feature_code); -CREATE INDEX idx_ai_usage_logs_created_at ON ai_usage_logs(created_at); -CREATE INDEX idx_ai_credit_ledger_wallet_id ON ai_credit_ledger(wallet_id, created_at); -CREATE INDEX idx_ai_credit_ledger_reference ON ai_credit_ledger(reference_type, reference_id); -CREATE INDEX idx_ai_reservation_holds_wallet_id ON ai_reservation_holds(wallet_id); -CREATE INDEX idx_ai_reservation_holds_status_expires ON ai_reservation_holds(status, expires_at) WHERE status = 'held'; - --- Seed a minimal Free plan so ensure_wallet() always has a plan to assign --- new users to. Feature costs / higher tiers are a Phase 3 (packages/ --- plans admin) concern, not seeded here to avoid inventing real prices in --- a migration -- see Appendix Q4 of the architecture doc. -INSERT INTO ai_plans (code, name, monthly_credits, daily_action_limit, daily_credit_limit, allowed_models, allowed_features) -VALUES ('free', 'Free', 10, 3, 10, '["askash-fast"]', '[]'); - -COMMIT; diff --git a/docs/openapi.wallet-holds.json b/docs/openapi.wallet-holds.json new file mode 100644 index 0000000..cc1de70 --- /dev/null +++ b/docs/openapi.wallet-holds.json @@ -0,0 +1,102 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Nxtgauge — Wallet Holds (minimal, hand-maintained)", + "version": "1.0.0", + "description": "Covers only the routes deployed by the wallet-holds/printpdf release (see docs/LIVE_SERVER_RUNBOOK.md). This is deliberately narrow — the full backend has 341 routes across 19 services and none have utoipa annotations yet (tracked as a separate follow-up). Generated by hand from crates/contracts/src/profession_shared.rs (wallet_holds, wallet_release_hold) to give schemathesis something real to fuzz against today's change without taking on full OpenAPI generation." + }, + "servers": [ + { "url": "https://api.nxtgauge.com" } + ], + "security": [{ "bearerAuth": [] }], + "paths": { + "/api/photographers/wallet/me/holds": { + "get": { + "operationId": "listWalletHolds", + "summary": "List the authenticated professional's tracecoin holds", + "parameters": [ + { "name": "page", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1 } }, + { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 200 } } + ], + "responses": { + "200": { + "description": "Paginated list of holds", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "pagination"], + "properties": { + "data": { "type": "array", "items": { "$ref": "#/components/schemas/Hold" } }, + "pagination": { "$ref": "#/components/schemas/Pagination" } + } + } + } + } + }, + "401": { "description": "Missing/invalid JWT" }, + "500": { "description": "Server error" } + } + } + }, + "/api/photographers/wallet/me/holds/{id}/release": { + "post": { + "operationId": "releaseWalletHold", + "summary": "Release an ACTIVE hold owned by the authenticated professional", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } + ], + "responses": { + "200": { + "description": "Hold released, tracecoins returned to available balance", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "message": { "type": "string" } } + } + } + } + }, + "401": { "description": "Missing/invalid JWT" }, + "404": { "description": "Hold not found or not owned by caller" }, + "409": { "description": "Hold exists but is not ACTIVE (already SETTLED/RELEASED/EXPIRED)" }, + "500": { "description": "Server error" } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" } + }, + "schemas": { + "Hold": { + "type": "object", + "required": ["id", "wallet_id", "user_id", "amount", "reason", "status", "created_at"], + "properties": { + "id": { "type": "string", "format": "uuid" }, + "wallet_id": { "type": "string", "format": "uuid" }, + "user_id": { "type": "string", "format": "uuid" }, + "amount": { "type": "integer" }, + "reason": { "type": "string" }, + "reference_id": { "type": "string", "format": "uuid", "nullable": true }, + "status": { "type": "string", "enum": ["ACTIVE", "SETTLED", "RELEASED", "EXPIRED"] }, + "expires_at": { "type": "string", "format": "date-time", "nullable": true }, + "settled_at": { "type": "string", "format": "date-time", "nullable": true }, + "settled_ledger_id": { "type": "string", "format": "uuid", "nullable": true }, + "released_at": { "type": "string", "format": "date-time", "nullable": true }, + "created_at": { "type": "string", "format": "date-time" } + } + }, + "Pagination": { + "type": "object", + "required": ["page", "limit"], + "properties": { + "page": { "type": "integer" }, + "limit": { "type": "integer" } + } + } + } + } +}