chore: sync current workspace changes
Desktop CI Build / Build macOS ARM64 DMG (push) Has been cancelled
Desktop CI Build / Build Windows x64 (push) Has been cancelled

This commit is contained in:
boos
2026-07-14 03:21:10 +08:00
parent 1512fa23ce
commit 557914fea8
745 changed files with 124917 additions and 11222 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ CREATE TABLE IF NOT EXISTS usage_daily_snapshots (
total_cost DOUBLE PRECISION NOT NULL DEFAULT 0.0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
UNIQUE(),
UNIQUE(local_day, downstream_key_id),
FOREIGN KEY (downstream_key_id) REFERENCES downstream_api_keys(id) ON DELETE SET NULL
);
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS user_recharge_records (
id BIGSERIAL PRIMARY KEY, public_id TEXT NOT NULL UNIQUE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
option_id TEXT, label TEXT, title TEXT, amount_usd DOUBLE PRECISION, provider TEXT NOT NULL DEFAULT 'comfy_checkout',
checkout_url TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', note TEXT, metadata TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT), updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT), completed_at TEXT
);
CREATE INDEX IF NOT EXISTS user_recharge_records_user_created_idx ON user_recharge_records (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS user_recharge_records_user_status_idx ON user_recharge_records (user_id, status);
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS payment_orders (
id BIGSERIAL PRIMARY KEY, public_id TEXT NOT NULL UNIQUE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
recharge_record_id BIGINT REFERENCES user_recharge_records(id) ON DELETE SET NULL, provider TEXT NOT NULL,
payment_method TEXT NOT NULL DEFAULT 'stripe', provider_session_id TEXT, provider_payment_id TEXT,
amount_usd DOUBLE PRECISION NOT NULL, amount_minor BIGINT NOT NULL, currency TEXT NOT NULL DEFAULT 'usd',
status TEXT NOT NULL DEFAULT 'pending', checkout_url TEXT, success_url TEXT, cancel_url TEXT, note TEXT, metadata TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT), updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT), paid_at TEXT, completed_at TEXT, expires_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_orders_provider_session_unique ON payment_orders (provider, provider_session_id) WHERE provider_session_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS payment_orders_user_created_idx ON payment_orders (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS payment_orders_status_idx ON payment_orders (status);
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS agent_sessions (
id TEXT PRIMARY KEY, user_id BIGINT, title TEXT, model TEXT NOT NULL DEFAULT '', permission_mode TEXT NOT NULL DEFAULT 'default',
input_tokens BIGINT NOT NULL DEFAULT 0, output_tokens BIGINT NOT NULL DEFAULT 0, total_tokens BIGINT NOT NULL DEFAULT 0,
cost_usd DOUBLE PRECISION NOT NULL DEFAULT 0, num_turns BIGINT NOT NULL DEFAULT 0, message_count BIGINT NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT), updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS agent_sessions_user_id_idx ON agent_sessions (user_id);
CREATE INDEX IF NOT EXISTS agent_sessions_updated_at_idx ON agent_sessions (updated_at);
@@ -0,0 +1 @@
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS transcript TEXT;
@@ -0,0 +1,3 @@
UPDATE downstream_api_keys SET max_cost = 1.0, updated_at = CURRENT_TIMESTAMP::TEXT
WHERE user_id IS NOT NULL AND (max_cost IS NULL OR max_cost <= 0.0)
AND (name LIKE '% 的默认密钥' OR name LIKE '% default key' OR key LIKE 'sk-user-%');
@@ -0,0 +1,6 @@
CREATE INDEX IF NOT EXISTS proxy_logs_created_at_desc_idx ON proxy_logs (created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS route_channels_route_enabled_cooldown_idx ON route_channels (route_id, enabled, cooldown_until, account_id, token_id);
CREATE INDEX IF NOT EXISTS token_routes_enabled_id_idx ON token_routes (enabled, id);
CREATE INDEX IF NOT EXISTS accounts_id_status_site_idx ON accounts (id, status, site_id);
CREATE INDEX IF NOT EXISTS sites_id_status_url_idx ON sites (id, status, url);
CREATE INDEX IF NOT EXISTS account_tokens_id_enabled_idx ON account_tokens (id, enabled);
@@ -0,0 +1,3 @@
CREATE INDEX IF NOT EXISTS token_routes_enabled_model_pattern_nocase_idx
ON token_routes (COALESCE(enabled, TRUE), lower(model_pattern))
WHERE model_pattern IS NOT NULL AND model_pattern <> '';
@@ -0,0 +1,3 @@
CREATE INDEX IF NOT EXISTS background_tasks_task_type_created_at_desc_idx ON background_tasks (task_type, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS background_tasks_created_at_desc_idx ON background_tasks (created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS background_tasks_task_type_status_created_at_idx ON background_tasks (task_type, status, created_at);
@@ -0,0 +1,6 @@
CREATE INDEX IF NOT EXISTS workspace_assets_completed_user_created_idx ON workspace_assets (user_id, created_at DESC, id DESC) WHERE deleted_at IS NULL AND status = 'completed';
CREATE INDEX IF NOT EXISTS workspace_assets_completed_created_idx ON workspace_assets (created_at DESC, id DESC) WHERE deleted_at IS NULL AND status = 'completed';
CREATE INDEX IF NOT EXISTS workspace_assets_completed_source_created_idx ON workspace_assets (source, created_at DESC, id DESC) WHERE deleted_at IS NULL AND status = 'completed';
CREATE INDEX IF NOT EXISTS workspace_assets_completed_kind_created_idx ON workspace_assets (kind, created_at DESC, id DESC) WHERE deleted_at IS NULL AND status = 'completed';
CREATE INDEX IF NOT EXISTS workspace_assets_completed_user_source_created_idx ON workspace_assets (user_id, source, created_at DESC, id DESC) WHERE deleted_at IS NULL AND status = 'completed';
CREATE INDEX IF NOT EXISTS workspace_assets_completed_user_kind_created_idx ON workspace_assets (user_id, kind, created_at DESC, id DESC) WHERE deleted_at IS NULL AND status = 'completed';
@@ -0,0 +1,25 @@
ALTER TABLE payment_orders ADD COLUMN IF NOT EXISTS credit_amount DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE payment_orders ADD COLUMN IF NOT EXISTS credit_unit TEXT NOT NULL DEFAULT 'USD API quota';
ALTER TABLE user_recharge_records ADD COLUMN IF NOT EXISTS currency TEXT;
ALTER TABLE user_recharge_records ADD COLUMN IF NOT EXISTS credit_amount DOUBLE PRECISION;
ALTER TABLE user_recharge_records ADD COLUMN IF NOT EXISTS credit_unit TEXT;
CREATE TABLE IF NOT EXISTS payment_order_fulfillments (
payment_order_id BIGINT PRIMARY KEY REFERENCES payment_orders(id) ON DELETE CASCADE,
credited_key_id BIGINT REFERENCES downstream_api_keys(id) ON DELETE SET NULL,
credit_amount DOUBLE PRECISION NOT NULL, credit_unit TEXT NOT NULL,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS payment_order_fulfillments_key_idx ON payment_order_fulfillments (credited_key_id);
CREATE TABLE IF NOT EXISTS credit_ledger (
id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, track TEXT NOT NULL, entry_type TEXT NOT NULL,
amount DOUBLE PRECISION NOT NULL, balance_after DOUBLE PRECISION NOT NULL, ref_type TEXT, ref_id TEXT,
model TEXT, operator TEXT, note TEXT, created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS credit_ledger_user_created_idx ON credit_ledger (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS credit_ledger_track_type_idx ON credit_ledger (track, entry_type);
CREATE TABLE IF NOT EXISTS user_credits (
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
points_balance DOUBLE PRECISION NOT NULL DEFAULT 0,
points_total_recharged DOUBLE PRECISION NOT NULL DEFAULT 0,
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
@@ -0,0 +1,30 @@
CREATE INDEX IF NOT EXISTS workspace_assets_gallery_created_idx ON workspace_assets (created_at DESC, id DESC)
WHERE deleted_at IS NULL AND status = 'completed' AND (
metadata::jsonb #>> '{workspaceAsset,role}' = 'output' OR metadata::jsonb #>> '{workflowUi,type}' = 'output'
OR (source IN ('picture-agent','workflow-studio') AND metadata::jsonb ? 'responseItem')
OR metadata::jsonb ? 'moneyPrinter' OR metadata::jsonb ? 'digitalHuman');
CREATE INDEX IF NOT EXISTS workspace_assets_gallery_user_created_idx ON workspace_assets (user_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL AND status = 'completed' AND (
metadata::jsonb #>> '{workspaceAsset,role}' = 'output' OR metadata::jsonb #>> '{workflowUi,type}' = 'output'
OR (source IN ('picture-agent','workflow-studio') AND metadata::jsonb ? 'responseItem')
OR metadata::jsonb ? 'moneyPrinter' OR metadata::jsonb ? 'digitalHuman');
CREATE INDEX IF NOT EXISTS workspace_assets_gallery_source_created_idx ON workspace_assets (source, created_at DESC, id DESC)
WHERE deleted_at IS NULL AND status = 'completed' AND (
metadata::jsonb #>> '{workspaceAsset,role}' = 'output' OR metadata::jsonb #>> '{workflowUi,type}' = 'output'
OR (source IN ('picture-agent','workflow-studio') AND metadata::jsonb ? 'responseItem')
OR metadata::jsonb ? 'moneyPrinter' OR metadata::jsonb ? 'digitalHuman');
CREATE INDEX IF NOT EXISTS workspace_assets_gallery_kind_created_idx ON workspace_assets (kind, created_at DESC, id DESC)
WHERE deleted_at IS NULL AND status = 'completed' AND (
metadata::jsonb #>> '{workspaceAsset,role}' = 'output' OR metadata::jsonb #>> '{workflowUi,type}' = 'output'
OR (source IN ('picture-agent','workflow-studio') AND metadata::jsonb ? 'responseItem')
OR metadata::jsonb ? 'moneyPrinter' OR metadata::jsonb ? 'digitalHuman');
CREATE INDEX IF NOT EXISTS workspace_assets_gallery_user_source_created_idx ON workspace_assets (user_id, source, created_at DESC, id DESC)
WHERE deleted_at IS NULL AND status = 'completed' AND (
metadata::jsonb #>> '{workspaceAsset,role}' = 'output' OR metadata::jsonb #>> '{workflowUi,type}' = 'output'
OR (source IN ('picture-agent','workflow-studio') AND metadata::jsonb ? 'responseItem')
OR metadata::jsonb ? 'moneyPrinter' OR metadata::jsonb ? 'digitalHuman');
CREATE INDEX IF NOT EXISTS workspace_assets_gallery_user_kind_created_idx ON workspace_assets (user_id, kind, created_at DESC, id DESC)
WHERE deleted_at IS NULL AND status = 'completed' AND (
metadata::jsonb #>> '{workspaceAsset,role}' = 'output' OR metadata::jsonb #>> '{workflowUi,type}' = 'output'
OR (source IN ('picture-agent','workflow-studio') AND metadata::jsonb ? 'responseItem')
OR metadata::jsonb ? 'moneyPrinter' OR metadata::jsonb ? 'digitalHuman');
@@ -0,0 +1,119 @@
-- Account-level API billing. Workflow points remain in user_credits and are
-- intentionally not migrated into this account.
CREATE TABLE IF NOT EXISTS api_billing_accounts (
user_id BIGINT PRIMARY KEY,
available_micros BIGINT NOT NULL DEFAULT 0,
reserved_micros BIGINT NOT NULL DEFAULT 0,
debt_micros BIGINT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
version BIGINT NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CHECK (available_micros >= 0),
CHECK (reserved_micros >= 0),
CHECK (debt_micros >= 0),
CHECK (status IN ('active', 'frozen', 'closed'))
);
CREATE TABLE IF NOT EXISTS api_credit_grants (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
user_id BIGINT NOT NULL,
source_type TEXT NOT NULL,
source_id TEXT,
payment_order_id BIGINT,
total_micros BIGINT NOT NULL,
remaining_micros BIGINT NOT NULL,
reserved_micros BIGINT NOT NULL DEFAULT 0,
starts_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
expires_at TEXT,
status TEXT NOT NULL DEFAULT 'active',
scope_json TEXT,
idempotency_key TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (payment_order_id) REFERENCES payment_orders(id) ON DELETE SET NULL,
CHECK (total_micros > 0),
CHECK (remaining_micros >= 0),
CHECK (reserved_micros >= 0),
CHECK (remaining_micros + reserved_micros <= total_micros),
CHECK (status IN ('scheduled', 'active', 'refund_pending', 'exhausted', 'expired', 'revoked'))
);
CREATE UNIQUE INDEX IF NOT EXISTS api_credit_grants_public_id_unique ON api_credit_grants (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS api_credit_grants_idempotency_unique ON api_credit_grants (idempotency_key);
CREATE INDEX IF NOT EXISTS api_credit_grants_spend_idx ON api_credit_grants (user_id, status, starts_at, expires_at, id);
CREATE INDEX IF NOT EXISTS api_credit_grants_order_idx ON api_credit_grants (payment_order_id);
CREATE TABLE IF NOT EXISTS api_entitlement_ledger (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
entry_type TEXT NOT NULL,
amount_micros BIGINT NOT NULL,
balance_after_micros BIGINT NOT NULL,
grant_id BIGINT,
ref_type TEXT,
ref_id TEXT,
model TEXT,
operator TEXT,
note TEXT,
idempotency_key TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (grant_id) REFERENCES api_credit_grants(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS api_entitlement_ledger_idempotency_unique ON api_entitlement_ledger (idempotency_key);
CREATE INDEX IF NOT EXISTS api_entitlement_ledger_user_created_idx ON api_entitlement_ledger (user_id, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS api_entitlement_ledger_ref_idx ON api_entitlement_ledger (ref_type, ref_id);
-- Preserve the finite remaining API credit users had before account billing.
INSERT INTO api_billing_accounts (user_id, available_micros)
SELECT
u.id,
ROUND(COALESCE(SUM(
CASE
WHEN k.max_cost IS NOT NULL
THEN GREATEST(k.max_cost - COALESCE(k.used_cost, 0), 0) * 1000000.0
ELSE 0
END
), 0))::BIGINT
FROM users u
LEFT JOIN downstream_api_keys k ON k.user_id = u.id
GROUP BY u.id
ON CONFLICT (user_id) DO NOTHING;
INSERT INTO api_credit_grants (
public_id, user_id, source_type, source_id,
total_micros, remaining_micros, idempotency_key
)
SELECT
'grant_legacy_' || user_id,
user_id,
'legacy_opening_balance',
user_id::TEXT,
available_micros,
available_micros,
'migration:20260710000003:user:' || user_id
FROM api_billing_accounts
WHERE available_micros > 0
ON CONFLICT (idempotency_key) DO NOTHING;
INSERT INTO api_entitlement_ledger (
user_id, entry_type, amount_micros, balance_after_micros,
ref_type, ref_id, operator, idempotency_key
)
SELECT
user_id,
'opening_balance',
available_micros,
available_micros,
'migration',
'20260710000003',
'system',
'migration:20260710000003:ledger:user:' || user_id
FROM api_billing_accounts
WHERE available_micros > 0
ON CONFLICT (idempotency_key) DO NOTHING;
@@ -0,0 +1,296 @@
-- Commerce catalog and immutable purchase snapshots. Money is stored in
-- integer minor units and API credit in integer micros.
CREATE TABLE IF NOT EXISTS commerce_products (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
code TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
product_type TEXT NOT NULL,
api_credit_micros BIGINT NOT NULL,
subscription_days BIGINT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
sort_order BIGINT NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
CHECK (product_type IN ('credit_pack', 'subscription')),
CHECK (api_credit_micros > 0),
CHECK ((product_type = 'credit_pack' AND subscription_days IS NULL) OR
(product_type = 'subscription' AND subscription_days > 0))
);
CREATE UNIQUE INDEX IF NOT EXISTS commerce_products_public_id_unique ON commerce_products (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS commerce_products_code_unique ON commerce_products (code);
CREATE INDEX IF NOT EXISTS commerce_products_enabled_sort_idx ON commerce_products (enabled, sort_order, id);
CREATE TABLE IF NOT EXISTS commerce_product_prices (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES commerce_products(id) ON DELETE CASCADE,
currency TEXT NOT NULL,
amount_minor BIGINT NOT NULL,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
UNIQUE (product_id, currency),
CHECK (currency IN ('CNY', 'USD')),
CHECK (amount_minor > 0)
);
CREATE TABLE IF NOT EXISTS commerce_product_credit_scopes (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES commerce_products(id) ON DELETE CASCADE,
scope_type TEXT NOT NULL,
scope_value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
UNIQUE (product_id, scope_type, scope_value),
CHECK (scope_type IN ('model', 'model_prefix')),
CHECK (length(btrim(scope_value)) > 0)
);
CREATE TABLE IF NOT EXISTS commerce_product_unlock_scopes (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES commerce_products(id) ON DELETE CASCADE,
scope_type TEXT NOT NULL,
scope_value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
UNIQUE (product_id, scope_type, scope_value),
CHECK (scope_type IN ('model', 'model_prefix')),
CHECK (length(btrim(scope_value)) > 0)
);
CREATE TABLE IF NOT EXISTS commerce_model_gate_registry (
id BIGSERIAL PRIMARY KEY,
scope_type TEXT NOT NULL,
scope_value TEXT NOT NULL,
first_seen_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
UNIQUE (scope_type, scope_value),
CHECK (scope_type IN ('model', 'model_prefix')),
CHECK (length(btrim(scope_value)) > 0)
);
INSERT INTO commerce_model_gate_registry (scope_type, scope_value)
SELECT scope_type, scope_value FROM commerce_product_unlock_scopes
ON CONFLICT (scope_type, scope_value) DO NOTHING;
CREATE OR REPLACE FUNCTION commerce_register_model_gate() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO commerce_model_gate_registry (scope_type, scope_value)
VALUES (NEW.scope_type, NEW.scope_value)
ON CONFLICT (scope_type, scope_value) DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER commerce_product_unlock_scope_register_gate
AFTER INSERT OR UPDATE OF scope_type, scope_value ON commerce_product_unlock_scopes
FOR EACH ROW EXECUTE FUNCTION commerce_register_model_gate();
CREATE OR REPLACE FUNCTION commerce_gate_registry_append_only() RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'commerce model gate registry is append-only';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER commerce_model_gate_registry_no_update
BEFORE UPDATE ON commerce_model_gate_registry
FOR EACH ROW EXECUTE FUNCTION commerce_gate_registry_append_only();
CREATE TRIGGER commerce_model_gate_registry_no_delete
BEFORE DELETE ON commerce_model_gate_registry
FOR EACH ROW EXECUTE FUNCTION commerce_gate_registry_append_only();
CREATE OR REPLACE FUNCTION commerce_unlock_scope_subscription_only() RETURNS TRIGGER AS $$
BEGIN
IF (SELECT product_type FROM commerce_products WHERE id = NEW.product_id) IS DISTINCT FROM 'subscription' THEN
RAISE EXCEPTION 'unlock scopes require a subscription product';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER commerce_unlock_scope_subscription_only_insert
BEFORE INSERT ON commerce_product_unlock_scopes
FOR EACH ROW EXECUTE FUNCTION commerce_unlock_scope_subscription_only();
CREATE TRIGGER commerce_unlock_scope_subscription_only_update
BEFORE UPDATE ON commerce_product_unlock_scopes
FOR EACH ROW EXECUTE FUNCTION commerce_unlock_scope_subscription_only();
CREATE TABLE IF NOT EXISTS commerce_orders (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
payment_order_id BIGINT NOT NULL REFERENCES payment_orders(id) ON DELETE RESTRICT,
product_id BIGINT NOT NULL REFERENCES commerce_products(id) ON DELETE RESTRICT,
product_public_id_snapshot TEXT NOT NULL,
product_code_snapshot TEXT NOT NULL,
product_name_snapshot TEXT NOT NULL,
product_type_snapshot TEXT NOT NULL,
order_kind TEXT NOT NULL,
currency TEXT NOT NULL,
amount_minor BIGINT NOT NULL,
api_credit_micros_snapshot BIGINT NOT NULL,
subscription_days_snapshot BIGINT,
status TEXT NOT NULL DEFAULT 'pending',
idempotency_key TEXT NOT NULL,
snapshot_sealed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
expires_at TEXT NOT NULL,
paid_at TEXT,
fulfilled_at TEXT,
UNIQUE (payment_order_id),
UNIQUE (user_id, idempotency_key),
CHECK (product_type_snapshot IN ('credit_pack', 'subscription')),
CHECK (order_kind IN ('credit_pack', 'subscription')),
CHECK (order_kind = product_type_snapshot),
CHECK (currency IN ('CNY', 'USD')),
CHECK (amount_minor > 0),
CHECK (api_credit_micros_snapshot > 0),
CHECK (expires_at::timestamptz > created_at::timestamptz),
CHECK ((product_type_snapshot = 'credit_pack' AND subscription_days_snapshot IS NULL) OR
(product_type_snapshot = 'subscription' AND subscription_days_snapshot > 0)),
CHECK (status IN ('pending', 'paid', 'fulfilled', 'partially_refunded', 'cancelled', 'expired', 'refunded'))
);
CREATE UNIQUE INDEX IF NOT EXISTS commerce_orders_public_id_unique ON commerce_orders (public_id);
CREATE INDEX IF NOT EXISTS commerce_orders_user_created_idx ON commerce_orders (user_id, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS commerce_orders_status_created_idx ON commerce_orders (status, created_at, id);
CREATE INDEX IF NOT EXISTS commerce_orders_pending_expiry_idx ON commerce_orders (expires_at, id) WHERE status = 'pending';
CREATE TABLE IF NOT EXISTS commerce_order_credit_scopes (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES commerce_orders(id) ON DELETE CASCADE,
scope_type TEXT NOT NULL,
scope_value TEXT NOT NULL,
UNIQUE (order_id, scope_type, scope_value),
CHECK (scope_type IN ('model', 'model_prefix')),
CHECK (length(btrim(scope_value)) > 0)
);
CREATE TABLE IF NOT EXISTS commerce_order_unlock_scopes (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES commerce_orders(id) ON DELETE CASCADE,
scope_type TEXT NOT NULL,
scope_value TEXT NOT NULL,
UNIQUE (order_id, scope_type, scope_value),
CHECK (scope_type IN ('model', 'model_prefix')),
CHECK (length(btrim(scope_value)) > 0)
);
INSERT INTO commerce_model_gate_registry (scope_type, scope_value)
SELECT scope_type, scope_value FROM commerce_order_unlock_scopes
ON CONFLICT (scope_type, scope_value) DO NOTHING;
CREATE TRIGGER commerce_order_unlock_scope_register_gate
AFTER INSERT OR UPDATE OF scope_type, scope_value ON commerce_order_unlock_scopes
FOR EACH ROW EXECUTE FUNCTION commerce_register_model_gate();
CREATE OR REPLACE FUNCTION commerce_order_snapshot_immutable() RETURNS TRIGGER AS $$
BEGIN
IF ROW(NEW.user_id, NEW.payment_order_id, NEW.product_id, NEW.product_public_id_snapshot,
NEW.product_code_snapshot, NEW.product_name_snapshot, NEW.product_type_snapshot,
NEW.order_kind, NEW.currency, NEW.amount_minor, NEW.api_credit_micros_snapshot,
NEW.subscription_days_snapshot, NEW.idempotency_key, NEW.expires_at)
IS DISTINCT FROM
ROW(OLD.user_id, OLD.payment_order_id, OLD.product_id, OLD.product_public_id_snapshot,
OLD.product_code_snapshot, OLD.product_name_snapshot, OLD.product_type_snapshot,
OLD.order_kind, OLD.currency, OLD.amount_minor, OLD.api_credit_micros_snapshot,
OLD.subscription_days_snapshot, OLD.idempotency_key, OLD.expires_at) THEN
RAISE EXCEPTION 'commerce order snapshot is immutable';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER commerce_order_snapshot_fields_immutable
BEFORE UPDATE ON commerce_orders FOR EACH ROW EXECUTE FUNCTION commerce_order_snapshot_immutable();
CREATE OR REPLACE FUNCTION commerce_product_unlock_scope_type_guard() RETURNS TRIGGER AS $$
BEGIN
IF NEW.product_type <> 'subscription' AND EXISTS (
SELECT 1 FROM commerce_product_unlock_scopes WHERE product_id = OLD.id
) THEN
RAISE EXCEPTION 'unlock scopes require a subscription product';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER commerce_product_unlock_scope_type_guard
BEFORE UPDATE OF product_type ON commerce_products
FOR EACH ROW EXECUTE FUNCTION commerce_product_unlock_scope_type_guard();
CREATE OR REPLACE FUNCTION commerce_order_cannot_unseal() RETURNS TRIGGER AS $$
BEGIN
IF OLD.snapshot_sealed AND NOT NEW.snapshot_sealed THEN
RAISE EXCEPTION 'commerce order snapshot cannot be unsealed';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER commerce_order_snapshot_cannot_unseal
BEFORE UPDATE OF snapshot_sealed ON commerce_orders
FOR EACH ROW EXECUTE FUNCTION commerce_order_cannot_unseal();
CREATE OR REPLACE FUNCTION commerce_order_scope_sealed_guard() RETURNS TRIGGER AS $$
DECLARE target_order_id BIGINT;
BEGIN
target_order_id := CASE WHEN TG_OP = 'DELETE' THEN OLD.order_id ELSE NEW.order_id END;
IF (SELECT snapshot_sealed FROM commerce_orders WHERE id = target_order_id) THEN
RAISE EXCEPTION 'commerce order scope snapshot is sealed';
END IF;
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER commerce_order_credit_scope_sealed
BEFORE INSERT OR UPDATE OR DELETE ON commerce_order_credit_scopes
FOR EACH ROW EXECUTE FUNCTION commerce_order_scope_sealed_guard();
CREATE TRIGGER commerce_order_unlock_scope_sealed
BEFORE INSERT OR UPDATE OR DELETE ON commerce_order_unlock_scopes
FOR EACH ROW EXECUTE FUNCTION commerce_order_scope_sealed_guard();
CREATE TABLE IF NOT EXISTS commerce_subscription_periods (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
product_id BIGINT NOT NULL REFERENCES commerce_products(id) ON DELETE RESTRICT,
source_order_id BIGINT NOT NULL REFERENCES commerce_orders(id) ON DELETE RESTRICT,
starts_at TEXT NOT NULL,
ends_at TEXT NOT NULL,
access_ends_at TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
UNIQUE (source_order_id),
CHECK (ends_at::timestamptz > starts_at::timestamptz),
CHECK (access_ends_at IS NULL OR
(access_ends_at::timestamptz >= starts_at::timestamptz AND
access_ends_at::timestamptz <= ends_at::timestamptz)),
CHECK (status IN ('active', 'revoked'))
);
CREATE UNIQUE INDEX IF NOT EXISTS commerce_subscription_periods_public_id_unique ON commerce_subscription_periods (public_id);
CREATE INDEX IF NOT EXISTS commerce_subscription_periods_user_product_end_idx ON commerce_subscription_periods (user_id, product_id, ends_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS commerce_subscription_periods_active_window_idx ON commerce_subscription_periods (user_id, status, starts_at, ends_at);
CREATE OR REPLACE FUNCTION commerce_subscription_period_window_immutable() RETURNS TRIGGER AS $$
BEGIN
IF ROW(NEW.user_id, NEW.product_id, NEW.source_order_id, NEW.starts_at, NEW.ends_at)
IS DISTINCT FROM ROW(OLD.user_id, OLD.product_id, OLD.source_order_id, OLD.starts_at, OLD.ends_at) THEN
RAISE EXCEPTION 'subscription period window is immutable';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER commerce_subscription_period_window_immutable
BEFORE UPDATE ON commerce_subscription_periods
FOR EACH ROW EXECUTE FUNCTION commerce_subscription_period_window_immutable();
CREATE TABLE IF NOT EXISTS commerce_order_fulfillments (
order_id BIGINT PRIMARY KEY REFERENCES commerce_orders(id) ON DELETE RESTRICT,
idempotency_key TEXT NOT NULL UNIQUE,
api_credit_grant_id BIGINT REFERENCES api_credit_grants(id) ON DELETE RESTRICT,
subscription_period_id BIGINT REFERENCES commerce_subscription_periods(id) ON DELETE RESTRICT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
@@ -0,0 +1,341 @@
-- Multi-provider payment routing. Credentials are encrypted by the application.
CREATE TABLE IF NOT EXISTS payment_provider_instances (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
provider TEXT NOT NULL,
name TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
credentials_ciphertext BYTEA NOT NULL,
credentials_version BIGINT NOT NULL DEFAULT 1,
callback_token_hash TEXT NOT NULL,
callback_token_ciphertext BYTEA NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
CHECK (provider IN ('easypay', 'alipay', 'wechatpay', 'stripe', 'comfy_cloud')),
CHECK (credentials_version > 0),
CHECK (octet_length(credentials_ciphertext) > 29),
CHECK (length(callback_token_hash) = 64),
CHECK (octet_length(callback_token_ciphertext) > 29),
CHECK (jsonb_typeof(config_json::jsonb) IS NOT NULL)
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_provider_instances_public_id_unique ON payment_provider_instances (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS payment_provider_instances_callback_token_unique ON payment_provider_instances (callback_token_hash);
CREATE INDEX IF NOT EXISTS payment_provider_instances_provider_enabled_idx ON payment_provider_instances (provider, enabled, id);
CREATE OR REPLACE FUNCTION payment_provider_instance_identity_immutable() RETURNS TRIGGER AS $$
BEGIN
IF ROW(NEW.public_id, NEW.provider) IS DISTINCT FROM ROW(OLD.public_id, OLD.provider) THEN
RAISE EXCEPTION 'payment provider instance identity is immutable';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER payment_provider_instance_identity_immutable
BEFORE UPDATE ON payment_provider_instances
FOR EACH ROW EXECUTE FUNCTION payment_provider_instance_identity_immutable();
CREATE TABLE IF NOT EXISTS payment_method_routes (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
name TEXT NOT NULL,
payment_method TEXT NOT NULL,
currency TEXT NOT NULL,
selection_strategy TEXT NOT NULL DEFAULT 'round_robin',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
CHECK (payment_method IN ('alipay', 'wechatpay', 'card', 'comfy_cloud')),
CHECK (currency IN ('CNY', 'USD')),
CHECK (selection_strategy IN ('round_robin', 'least_amount'))
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_method_routes_public_id_unique ON payment_method_routes (public_id);
CREATE INDEX IF NOT EXISTS payment_method_routes_method_enabled_idx ON payment_method_routes (payment_method, currency, enabled, id);
CREATE OR REPLACE FUNCTION payment_method_route_identity_immutable() RETURNS TRIGGER AS $$
BEGIN
IF ROW(NEW.public_id, NEW.payment_method, NEW.currency)
IS DISTINCT FROM ROW(OLD.public_id, OLD.payment_method, OLD.currency) THEN
RAISE EXCEPTION 'payment method route identity is immutable';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER payment_method_route_identity_immutable
BEFORE UPDATE ON payment_method_routes
FOR EACH ROW EXECUTE FUNCTION payment_method_route_identity_immutable();
CREATE TABLE IF NOT EXISTS payment_method_route_members (
id BIGSERIAL PRIMARY KEY,
route_id BIGINT NOT NULL REFERENCES payment_method_routes(id) ON DELETE CASCADE,
provider_instance_id BIGINT NOT NULL REFERENCES payment_provider_instances(id) ON DELETE RESTRICT,
position BIGINT NOT NULL DEFAULT 0,
daily_capacity_minor BIGINT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
UNIQUE (route_id, provider_instance_id),
CHECK (daily_capacity_minor IS NULL OR daily_capacity_minor > 0)
);
CREATE INDEX IF NOT EXISTS payment_method_route_members_selection_idx ON payment_method_route_members (route_id, enabled, position, id);
CREATE OR REPLACE FUNCTION payment_route_member_provider_guard() RETURNS TRIGGER AS $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM payment_method_routes r
JOIN payment_provider_instances i ON i.id = NEW.provider_instance_id
WHERE r.id = NEW.route_id
AND ((r.payment_method = 'alipay' AND i.provider IN ('easypay', 'alipay')) OR
(r.payment_method = 'wechatpay' AND i.provider IN ('easypay', 'wechatpay')) OR
(r.payment_method = 'card' AND i.provider = 'stripe') OR
(r.payment_method = 'comfy_cloud' AND i.provider = 'comfy_cloud'))
) THEN
RAISE EXCEPTION 'provider instance does not support route payment method';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER payment_route_member_provider_guard
BEFORE INSERT OR UPDATE OF route_id, provider_instance_id ON payment_method_route_members
FOR EACH ROW EXECUTE FUNCTION payment_route_member_provider_guard();
CREATE TABLE IF NOT EXISTS payment_method_route_cursors (
route_id BIGINT PRIMARY KEY REFERENCES payment_method_routes(id) ON DELETE CASCADE,
last_member_id BIGINT REFERENCES payment_method_route_members(id) ON DELETE SET NULL,
version BIGINT NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE TABLE IF NOT EXISTS payment_route_member_daily_usage (
member_id BIGINT NOT NULL REFERENCES payment_method_route_members(id) ON DELETE CASCADE,
usage_date TEXT NOT NULL,
currency TEXT NOT NULL,
reserved_minor BIGINT NOT NULL DEFAULT 0,
captured_minor BIGINT NOT NULL DEFAULT 0,
refunded_minor BIGINT NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
PRIMARY KEY (member_id, usage_date, currency),
CHECK (currency IN ('CNY', 'USD')),
CHECK (reserved_minor >= 0),
CHECK (captured_minor >= 0),
CHECK (refunded_minor >= 0),
CHECK (refunded_minor <= captured_minor)
);
CREATE TABLE IF NOT EXISTS payment_order_provider_bindings (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
payment_order_id BIGINT NOT NULL REFERENCES payment_orders(id) ON DELETE RESTRICT,
route_id BIGINT NOT NULL REFERENCES payment_method_routes(id) ON DELETE RESTRICT,
member_id BIGINT NOT NULL REFERENCES payment_method_route_members(id) ON DELETE RESTRICT,
provider_instance_id BIGINT NOT NULL REFERENCES payment_provider_instances(id) ON DELETE RESTRICT,
provider TEXT NOT NULL,
payment_method TEXT NOT NULL,
amount_minor BIGINT NOT NULL,
currency TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'bound',
capacity_state TEXT NOT NULL DEFAULT 'reserved',
provider_session_id TEXT,
provider_payment_id TEXT,
bound_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
UNIQUE (payment_order_id),
CHECK (provider IN ('easypay', 'alipay', 'wechatpay', 'stripe', 'comfy_cloud')),
CHECK (payment_method IN ('alipay', 'wechatpay', 'card', 'comfy_cloud')),
CHECK (amount_minor > 0),
CHECK (currency IN ('CNY', 'USD')),
CHECK (status IN ('bound', 'processing', 'paid', 'failed', 'cancelled', 'refunded')),
CHECK (capacity_state IN ('reserved', 'released', 'captured', 'refunded'))
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_order_provider_bindings_public_id_unique ON payment_order_provider_bindings (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS payment_order_provider_session_unique ON payment_order_provider_bindings (provider_instance_id, provider_session_id) WHERE provider_session_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS payment_order_provider_payment_unique ON payment_order_provider_bindings (provider_instance_id, provider_payment_id) WHERE provider_payment_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS payment_order_provider_bindings_instance_status_idx ON payment_order_provider_bindings (provider_instance_id, status, id);
CREATE INDEX IF NOT EXISTS payment_order_provider_bindings_capacity_status_idx ON payment_order_provider_bindings (capacity_state, status, payment_order_id, id);
CREATE OR REPLACE FUNCTION payment_order_provider_binding_immutable() RETURNS TRIGGER AS $$
BEGIN
IF ROW(NEW.payment_order_id, NEW.route_id, NEW.member_id, NEW.provider_instance_id,
NEW.provider, NEW.payment_method, NEW.amount_minor, NEW.currency)
IS DISTINCT FROM
ROW(OLD.payment_order_id, OLD.route_id, OLD.member_id, OLD.provider_instance_id,
OLD.provider, OLD.payment_method, OLD.amount_minor, OLD.currency) THEN
RAISE EXCEPTION 'payment order provider binding is immutable';
END IF;
IF OLD.provider_payment_id IS NOT NULL
AND NEW.provider_payment_id IS DISTINCT FROM OLD.provider_payment_id THEN
RAISE EXCEPTION 'provider payment ID is immutable after first assignment';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER payment_order_provider_binding_identity_immutable
BEFORE UPDATE ON payment_order_provider_bindings
FOR EACH ROW EXECUTE FUNCTION payment_order_provider_binding_immutable();
CREATE TABLE IF NOT EXISTS payment_provider_event_inbox (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
provider_instance_id BIGINT NOT NULL REFERENCES payment_provider_instances(id) ON DELETE RESTRICT,
provider_event_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload_json TEXT NOT NULL,
payload_sha256 TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'received',
attempt_count BIGINT NOT NULL DEFAULT 0,
last_error TEXT,
received_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
processed_at TEXT,
UNIQUE (provider_instance_id, provider_event_id),
CHECK (length(btrim(provider_event_id)) > 0),
CHECK (length(btrim(event_type)) > 0),
CHECK (jsonb_typeof(payload_json::jsonb) IS NOT NULL),
CHECK (length(payload_sha256) = 64),
CHECK (status IN ('received', 'processing', 'processed', 'failed')),
CHECK (attempt_count >= 0)
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_provider_event_inbox_public_id_unique ON payment_provider_event_inbox (public_id);
CREATE INDEX IF NOT EXISTS payment_provider_event_inbox_work_idx ON payment_provider_event_inbox (status, received_at, id);
CREATE OR REPLACE FUNCTION payment_provider_event_content_immutable() RETURNS TRIGGER AS $$
BEGIN
IF ROW(NEW.public_id, NEW.provider_instance_id, NEW.provider_event_id, NEW.event_type,
NEW.payload_json, NEW.payload_sha256, NEW.received_at)
IS DISTINCT FROM
ROW(OLD.public_id, OLD.provider_instance_id, OLD.provider_event_id, OLD.event_type,
OLD.payload_json, OLD.payload_sha256, OLD.received_at) THEN
RAISE EXCEPTION 'payment provider event content is immutable';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER payment_provider_event_content_immutable
BEFORE UPDATE ON payment_provider_event_inbox
FOR EACH ROW EXECUTE FUNCTION payment_provider_event_content_immutable();
CREATE TABLE IF NOT EXISTS payment_jobs (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
job_type TEXT NOT NULL,
payment_order_id BIGINT REFERENCES payment_orders(id) ON DELETE RESTRICT,
binding_id BIGINT REFERENCES payment_order_provider_bindings(id) ON DELETE RESTRICT,
provider_instance_id BIGINT NOT NULL REFERENCES payment_provider_instances(id) ON DELETE RESTRICT,
event_id BIGINT REFERENCES payment_provider_event_inbox(id) ON DELETE RESTRICT,
payload_json TEXT NOT NULL DEFAULT '{}',
result_json TEXT,
status TEXT NOT NULL DEFAULT 'pending',
attempt_count BIGINT NOT NULL DEFAULT 0,
max_attempts BIGINT NOT NULL DEFAULT 8,
available_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
lease_token TEXT,
leased_until TEXT,
last_error TEXT,
idempotency_key TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
CHECK (job_type IN ('create_checkout', 'query_payment', 'capture', 'process_event', 'refund')),
CHECK (jsonb_typeof(payload_json::jsonb) IS NOT NULL),
CHECK (result_json IS NULL OR jsonb_typeof(result_json::jsonb) IS NOT NULL),
CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'dead')),
CHECK (attempt_count >= 0),
CHECK (max_attempts > 0)
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_jobs_public_id_unique ON payment_jobs (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS payment_jobs_idempotency_unique ON payment_jobs (idempotency_key);
CREATE INDEX IF NOT EXISTS payment_jobs_work_idx ON payment_jobs (status, available_at, id);
CREATE INDEX IF NOT EXISTS payment_jobs_lease_idx ON payment_jobs (status, leased_until, id);
CREATE TABLE IF NOT EXISTS payment_refund_requests (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
user_id BIGINT REFERENCES users(id) ON DELETE RESTRICT,
payment_order_id BIGINT NOT NULL REFERENCES payment_orders(id) ON DELETE RESTRICT,
binding_id BIGINT NOT NULL REFERENCES payment_order_provider_bindings(id) ON DELETE RESTRICT,
amount_minor BIGINT NOT NULL,
currency TEXT NOT NULL,
reason TEXT,
requested_by TEXT NOT NULL,
force_reversal BOOLEAN NOT NULL DEFAULT FALSE,
entitlement_target_micros BIGINT NOT NULL DEFAULT 0,
entitlement_shortfall_micros BIGINT NOT NULL DEFAULT 0,
subscription_target_seconds BIGINT NOT NULL DEFAULT 0,
subscription_shortfall_seconds BIGINT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
idempotency_key TEXT NOT NULL,
reviewed_by TEXT,
review_note TEXT,
reviewed_at TEXT,
provider_attempt_token TEXT,
provider_attempt_started_at TEXT,
provider_attempt_expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
CHECK (amount_minor > 0),
CHECK (currency IN ('CNY', 'USD')),
CHECK (entitlement_target_micros >= 0),
CHECK (entitlement_shortfall_micros >= 0),
CHECK (subscription_target_seconds >= 0),
CHECK (subscription_shortfall_seconds >= 0),
CHECK (status IN ('pending', 'approved', 'rejected', 'processing', 'completed', 'failed'))
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_refund_requests_public_id_unique ON payment_refund_requests (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS payment_refund_requests_idempotency_unique ON payment_refund_requests (requested_by, idempotency_key);
CREATE INDEX IF NOT EXISTS payment_refund_requests_status_created_idx ON payment_refund_requests (status, created_at, id);
CREATE INDEX IF NOT EXISTS payment_refund_requests_user_created_idx ON payment_refund_requests (user_id, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS payment_refund_requests_attempt_idx ON payment_refund_requests (status, provider_attempt_expires_at) WHERE provider_attempt_token IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS payment_refund_requests_one_active_per_order ON payment_refund_requests (payment_order_id) WHERE status IN ('pending', 'approved', 'processing');
CREATE TABLE IF NOT EXISTS payment_refund_entitlement_reservations (
id BIGSERIAL PRIMARY KEY,
refund_request_id BIGINT NOT NULL REFERENCES payment_refund_requests(id) ON DELETE RESTRICT,
grant_id BIGINT NOT NULL REFERENCES api_credit_grants(id) ON DELETE RESTRICT,
reserved_micros BIGINT NOT NULL,
grant_status_before TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'reserved',
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
UNIQUE (refund_request_id, grant_id),
CHECK (reserved_micros > 0),
CHECK (status IN ('reserved', 'finalized', 'released'))
);
CREATE TABLE IF NOT EXISTS payment_refund_subscription_reservations (
refund_request_id BIGINT PRIMARY KEY REFERENCES payment_refund_requests(id) ON DELETE RESTRICT,
subscription_period_id BIGINT NOT NULL REFERENCES commerce_subscription_periods(id) ON DELETE RESTRICT,
previous_access_ends_at TEXT NOT NULL,
previous_status TEXT NOT NULL,
reserved_access_ends_at TEXT NOT NULL,
reversal_seconds BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'reserved',
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
CHECK (reversal_seconds >= 0),
CHECK (previous_status IN ('active', 'revoked')),
CHECK (status IN ('reserved', 'finalized', 'released'))
);
CREATE TABLE IF NOT EXISTS payment_refunds (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
refund_request_id BIGINT NOT NULL REFERENCES payment_refund_requests(id) ON DELETE RESTRICT,
provider_instance_id BIGINT NOT NULL REFERENCES payment_provider_instances(id) ON DELETE RESTRICT,
provider_refund_id TEXT,
amount_minor BIGINT NOT NULL,
currency TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'processing',
response_json TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
completed_at TEXT,
UNIQUE (refund_request_id),
CHECK (amount_minor > 0),
CHECK (currency IN ('CNY', 'USD')),
CHECK (status IN ('processing', 'succeeded', 'failed')),
CHECK (response_json IS NULL OR jsonb_typeof(response_json::jsonb) IS NOT NULL)
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_refunds_public_id_unique ON payment_refunds (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS payment_refunds_provider_reference_unique ON payment_refunds (provider_instance_id, provider_refund_id) WHERE provider_refund_id IS NOT NULL;
@@ -0,0 +1,23 @@
-- End-user social-login identities. Provider tokens are intentionally not
-- persisted; this table only links a stable provider subject to a local user.
CREATE TABLE IF NOT EXISTS user_oauth_identities (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
provider TEXT NOT NULL,
provider_subject TEXT NOT NULL,
email_snapshot TEXT,
display_name TEXT,
avatar_url TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
last_login_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CHECK (provider IN ('google', 'github', 'wechat')),
CHECK (length(provider_subject) > 0)
);
CREATE UNIQUE INDEX IF NOT EXISTS user_oauth_identities_provider_subject_unique
ON user_oauth_identities (provider, provider_subject);
CREATE INDEX IF NOT EXISTS user_oauth_identities_user_id_idx
ON user_oauth_identities (user_id);
@@ -0,0 +1,26 @@
-- Durable pre-authorization leases bind ordinary API spend to one in-flight
-- request per user. No balance is moved into reserved_micros for a lease.
CREATE TABLE IF NOT EXISTS api_request_leases (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
user_id BIGINT NOT NULL,
downstream_key_id BIGINT,
model TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
acquired_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
expires_at TEXT NOT NULL,
released_at TEXT,
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (downstream_key_id) REFERENCES downstream_api_keys(id) ON DELETE SET NULL,
CHECK (status IN ('active', 'released', 'expired'))
);
CREATE UNIQUE INDEX IF NOT EXISTS api_request_leases_public_id_unique
ON api_request_leases (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS api_request_leases_one_active_per_user
ON api_request_leases (user_id) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS api_request_leases_expiry_idx
ON api_request_leases (status, expires_at);
CREATE INDEX IF NOT EXISTS api_request_leases_user_created_idx
ON api_request_leases (user_id, acquired_at DESC, id DESC);
@@ -0,0 +1,74 @@
-- Durable Agent Workbench run lifecycle and per-session workspace identity.
-- Active statuses include `cancelling` until the worker has actually stopped.
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS workspace_id TEXT;
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 0;
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS runtime_status TEXT NOT NULL DEFAULT 'idle';
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS context_version BIGINT NOT NULL DEFAULT 1;
UPDATE agent_sessions
SET workspace_id = substr(workspace_hash, 1, 8) || '-' ||
substr(workspace_hash, 9, 4) || '-' ||
'4' || substr(workspace_hash, 14, 3) || '-' ||
'a' || substr(workspace_hash, 18, 3) || '-' ||
substr(workspace_hash, 21, 12)
FROM (
SELECT id,
md5(random()::TEXT || clock_timestamp()::TEXT || id::TEXT) AS workspace_hash
FROM agent_sessions
WHERE workspace_id IS NULL OR btrim(workspace_id) = ''
) AS generated
WHERE agent_sessions.id = generated.id;
CREATE UNIQUE INDEX IF NOT EXISTS agent_sessions_workspace_id_unique
ON agent_sessions(workspace_id);
CREATE TABLE IF NOT EXISTS agent_runs (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
user_id BIGINT,
actor_key TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'starting',
model TEXT NOT NULL,
revision BIGINT NOT NULL DEFAULT 0,
error_message TEXT,
cancel_requested_at TEXT,
started_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
finished_at TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY(session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL,
CHECK(status IN (
'starting', 'running', 'waiting_approval', 'waiting_input', 'cancelling',
'succeeded', 'failed', 'cancelled', 'interrupted'
))
);
CREATE UNIQUE INDEX IF NOT EXISTS agent_runs_one_active_per_session
ON agent_runs(session_id)
WHERE status IN ('starting', 'running', 'waiting_approval', 'waiting_input', 'cancelling');
CREATE INDEX IF NOT EXISTS agent_runs_actor_status_created
ON agent_runs(actor_key, status, created_at DESC);
CREATE INDEX IF NOT EXISTS agent_runs_session_created
ON agent_runs(session_id, created_at DESC);
CREATE TABLE IF NOT EXISTS agent_tool_audits (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
session_id TEXT NOT NULL,
actor_key TEXT NOT NULL,
tool_name TEXT NOT NULL,
risk TEXT NOT NULL,
decision TEXT NOT NULL,
input_summary TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY(run_id) REFERENCES agent_runs(id) ON DELETE CASCADE,
FOREIGN KEY(session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS agent_tool_audits_run_created
ON agent_tool_audits(run_id, created_at DESC);
@@ -0,0 +1,6 @@
-- Link proxy usage records to durable Agent Workbench runs.
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS agent_run_id TEXT;
CREATE INDEX IF NOT EXISTS proxy_logs_agent_run_id_idx
ON proxy_logs(agent_run_id)
WHERE agent_run_id IS NOT NULL;
@@ -0,0 +1,32 @@
CREATE TABLE IF NOT EXISTS comfy_cloud_jobs (
job_id TEXT PRIMARY KEY NOT NULL,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
last_synced_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS comfy_cloud_jobs_user_updated_idx
ON comfy_cloud_jobs(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS comfy_cloud_assets (
asset_id TEXT PRIMARY KEY NOT NULL,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
job_id TEXT,
asset_hash TEXT,
name TEXT,
tags_json TEXT NOT NULL DEFAULT '[]',
snapshot_json TEXT,
is_preview BIGINT NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS comfy_cloud_assets_user_created_idx
ON comfy_cloud_assets(user_id, created_at DESC, asset_id DESC);
CREATE INDEX IF NOT EXISTS comfy_cloud_assets_user_job_idx
ON comfy_cloud_assets(user_id, job_id);
CREATE INDEX IF NOT EXISTS comfy_cloud_assets_user_name_idx
ON comfy_cloud_assets(user_id, name);
CREATE INDEX IF NOT EXISTS comfy_cloud_assets_user_hash_idx
ON comfy_cloud_assets(user_id, asset_hash);
@@ -0,0 +1 @@
-- MySQL/MariaDB constraint parity is implemented in the matching MySQL migration.
@@ -0,0 +1,59 @@
ALTER TABLE api_credit_grants ADD COLUMN grant_kind TEXT NOT NULL DEFAULT 'balance';
UPDATE api_credit_grants SET grant_kind = CASE WHEN source_type = 'subscription' THEN 'package' WHEN source_type = 'package' AND payment_order_id IS NULL THEN 'package' ELSE 'balance' END;
CREATE INDEX api_credit_grants_kind_spend_idx ON api_credit_grants (user_id, grant_kind, status, expires_at, id);
CREATE TABLE comfy_billing_accounts (
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
available_micros BIGINT NOT NULL DEFAULT 0, reserved_micros BIGINT NOT NULL DEFAULT 0,
debt_micros BIGINT NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'active', version BIGINT NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT), updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
CHECK (available_micros >= 0), CHECK (reserved_micros >= 0), CHECK (debt_micros >= 0), CHECK (status IN ('active','frozen','closed'))
);
CREATE TABLE comfy_credit_grants (
id BIGSERIAL PRIMARY KEY, public_id TEXT NOT NULL UNIQUE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
grant_kind TEXT NOT NULL, source_type TEXT NOT NULL, source_id TEXT, payment_order_id BIGINT REFERENCES payment_orders(id) ON DELETE SET NULL,
total_micros BIGINT NOT NULL, remaining_micros BIGINT NOT NULL, reserved_micros BIGINT NOT NULL DEFAULT 0,
starts_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT), expires_at TEXT, status TEXT NOT NULL DEFAULT 'active',
idempotency_key TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT), updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
CHECK (grant_kind IN ('package','balance')), CHECK (total_micros > 0), CHECK (remaining_micros >= 0), CHECK (reserved_micros >= 0),
CHECK (remaining_micros + reserved_micros <= total_micros), CHECK (status IN ('scheduled','active','refund_pending','exhausted','expired','revoked'))
);
CREATE INDEX comfy_credit_grants_spend_idx ON comfy_credit_grants (user_id, grant_kind, status, starts_at, expires_at, id);
CREATE TABLE comfy_charge_holds (
id BIGSERIAL PRIMARY KEY, public_id TEXT NOT NULL UNIQUE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
task_type TEXT NOT NULL, ref_id TEXT NOT NULL, amount_micros BIGINT NOT NULL, package_micros BIGINT NOT NULL DEFAULT 0,
balance_micros BIGINT NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'active', idempotency_key TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT), updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT), settled_at TEXT, released_at TEXT,
CHECK (amount_micros > 0), CHECK (status IN ('active','settled','released'))
);
CREATE INDEX comfy_charge_holds_ref_idx ON comfy_charge_holds (task_type, ref_id, status);
CREATE TABLE comfy_charge_hold_allocations (
id BIGSERIAL PRIMARY KEY, hold_id BIGINT NOT NULL REFERENCES comfy_charge_holds(id) ON DELETE CASCADE,
grant_id BIGINT NOT NULL REFERENCES comfy_credit_grants(id) ON DELETE RESTRICT, amount_micros BIGINT NOT NULL,
grant_kind TEXT NOT NULL, UNIQUE (hold_id, grant_id), CHECK (amount_micros > 0), CHECK (grant_kind IN ('package','balance'))
);
CREATE TABLE comfy_entitlement_ledger (
id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, entry_type TEXT NOT NULL,
amount_micros BIGINT NOT NULL, balance_after_micros BIGINT NOT NULL, package_amount_micros BIGINT NOT NULL DEFAULT 0,
balance_amount_micros BIGINT NOT NULL DEFAULT 0, grant_id BIGINT REFERENCES comfy_credit_grants(id) ON DELETE SET NULL,
task_type TEXT, ref_type TEXT, ref_id TEXT, operator TEXT, note TEXT, idempotency_key TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX comfy_entitlement_ledger_user_idx ON comfy_entitlement_ledger (user_id, created_at DESC, id DESC);
CREATE TABLE comfy_task_prices (
task_type TEXT PRIMARY KEY, display_name TEXT NOT NULL, points_micros BIGINT NOT NULL DEFAULT 0,
enabled BOOLEAN NOT NULL DEFAULT FALSE, updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT), CHECK (points_micros >= 0)
);
INSERT INTO comfy_task_prices (task_type, display_name) VALUES
('comfy-native-workflow','Comfy 工作流'),('money-printer-video','Money Printer 视频'),
('digital-human-generation','数字人生成'),('jimeng-image-generation','即梦图片'),
('jimeng-video-generation','即梦视频'),('seedance-video-generation','Seedance 视频');
INSERT INTO comfy_billing_accounts (user_id, available_micros)
SELECT user_id, ROUND(GREATEST(points_balance,0) * 1000000.0)::BIGINT FROM user_credits WHERE points_balance > 0;
INSERT INTO comfy_credit_grants (public_id,user_id,grant_kind,source_type,source_id,total_micros,remaining_micros,idempotency_key)
SELECT 'cgrant_legacy_'||user_id,user_id,'balance','legacy_opening_balance','user_credits',
ROUND(GREATEST(points_balance,0)*1000000.0)::BIGINT,ROUND(GREATEST(points_balance,0)*1000000.0)::BIGINT,
'migration:20260711000004:comfy:user:'||user_id FROM user_credits WHERE points_balance > 0;
INSERT INTO comfy_entitlement_ledger (user_id,entry_type,amount_micros,balance_after_micros,ref_type,ref_id,operator,idempotency_key)
SELECT user_id,'opening_balance',available_micros,available_micros,'migration','20260711000004','system',
'migration:20260711000004:comfy:ledger:user:'||user_id FROM comfy_billing_accounts WHERE available_micros > 0;
@@ -0,0 +1,15 @@
ALTER TABLE commerce_products ADD COLUMN billing_track TEXT NOT NULL DEFAULT 'api';
ALTER TABLE commerce_products ADD COLUMN grant_kind TEXT NOT NULL DEFAULT 'package';
ALTER TABLE commerce_products ADD COLUMN benefit_type TEXT NOT NULL DEFAULT 'api_credit';
ALTER TABLE commerce_products ADD COLUMN validity_days BIGINT;
UPDATE commerce_products SET grant_kind = CASE WHEN product_type = 'subscription' THEN 'package' ELSE 'balance' END;
ALTER TABLE commerce_orders ADD COLUMN billing_track_snapshot TEXT NOT NULL DEFAULT 'api';
ALTER TABLE commerce_orders ADD COLUMN grant_kind_snapshot TEXT NOT NULL DEFAULT 'package';
ALTER TABLE commerce_orders ADD COLUMN benefit_type_snapshot TEXT NOT NULL DEFAULT 'api_credit';
ALTER TABLE commerce_orders ADD COLUMN validity_days_snapshot BIGINT;
UPDATE commerce_orders SET grant_kind_snapshot = CASE WHEN product_type_snapshot = 'subscription' THEN 'package' ELSE 'balance' END;
ALTER TABLE commerce_order_fulfillments ADD COLUMN comfy_credit_grant_id BIGINT REFERENCES comfy_credit_grants(id) ON DELETE RESTRICT;
ALTER TABLE payment_orders ADD COLUMN billing_target TEXT NOT NULL DEFAULT 'api';
ALTER TABLE user_recharge_records ADD COLUMN billing_target TEXT NOT NULL DEFAULT 'api';
@@ -0,0 +1,33 @@
ALTER TABLE api_entitlement_ledger ADD COLUMN package_amount_micros BIGINT NOT NULL DEFAULT 0;
ALTER TABLE api_entitlement_ledger ADD COLUMN balance_amount_micros BIGINT NOT NULL DEFAULT 0;
CREATE TABLE comfy_refund_entitlement_reservations (
id BIGSERIAL PRIMARY KEY,
refund_request_id BIGINT NOT NULL REFERENCES payment_refund_requests(id) ON DELETE RESTRICT,
grant_id BIGINT NOT NULL REFERENCES comfy_credit_grants(id) ON DELETE RESTRICT,
reserved_micros BIGINT NOT NULL,
grant_status_before TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'reserved',
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
UNIQUE (refund_request_id, grant_id),
CHECK (reserved_micros > 0),
CHECK (status IN ('reserved', 'finalized', 'released'))
);
CREATE OR REPLACE FUNCTION commerce_order_billing_snapshot_immutable() RETURNS TRIGGER AS $$
BEGIN
IF ROW(NEW.billing_track_snapshot, NEW.grant_kind_snapshot,
NEW.benefit_type_snapshot, NEW.validity_days_snapshot)
IS DISTINCT FROM
ROW(OLD.billing_track_snapshot, OLD.grant_kind_snapshot,
OLD.benefit_type_snapshot, OLD.validity_days_snapshot) THEN
RAISE EXCEPTION 'commerce order billing snapshot is immutable';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER commerce_order_billing_snapshot_fields_immutable
BEFORE UPDATE ON commerce_orders FOR EACH ROW
EXECUTE FUNCTION commerce_order_billing_snapshot_immutable();
@@ -0,0 +1,10 @@
CREATE TABLE user_profile_avatars (
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
public_id TEXT NOT NULL UNIQUE,
image_bytes BYTEA NOT NULL,
mime_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
CHECK (size_bytes > 0)
);