Merge remote-tracking branch 'entropy-xu/payment-billing-plans'

# Conflicts:
#	crates/aether-data/src/lifecycle/bootstrap/postgres.rs
#	crates/aether-data/src/lifecycle/migrate/tests.rs
#	frontend/src/views/admin/Users.vue
This commit is contained in:
fawney19
2026-05-15 16:37:17 +08:00
97 changed files with 14795 additions and 402 deletions

View File

@@ -3,5 +3,7 @@ mod types;
pub use types::{
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingMutationOutcome,
AdminBillingPresetApplyResult, AdminBillingRuleRecord, AdminBillingRuleWriteInput,
BillingReadRepository, StoredBillingModelContext,
BillingPlanRecord, BillingPlanWriteInput, BillingReadRepository, PaymentGatewayConfigRecord,
PaymentGatewayConfigWriteInput, StoredBillingModelContext, UserDailyQuotaAvailabilityRecord,
UserPlanEntitlementRecord,
};

View File

@@ -150,6 +150,93 @@ pub enum AdminBillingMutationOutcome<T> {
Unavailable,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PaymentGatewayConfigRecord {
pub provider: String,
pub enabled: bool,
pub endpoint_url: String,
pub callback_base_url: Option<String>,
pub merchant_id: String,
pub merchant_key_encrypted: Option<String>,
pub pay_currency: String,
pub usd_exchange_rate: f64,
pub min_recharge_usd: f64,
pub channels_json: Value,
pub created_at_unix_secs: u64,
pub updated_at_unix_secs: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PaymentGatewayConfigWriteInput {
pub provider: String,
pub enabled: bool,
pub endpoint_url: String,
pub callback_base_url: Option<String>,
pub merchant_id: String,
pub merchant_key_encrypted: Option<String>,
pub preserve_existing_secret: bool,
pub pay_currency: String,
pub usd_exchange_rate: f64,
pub min_recharge_usd: f64,
pub channels_json: Value,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct BillingPlanRecord {
pub id: String,
pub title: String,
pub description: Option<String>,
pub price_amount: f64,
pub price_currency: String,
pub duration_unit: String,
pub duration_value: i64,
pub enabled: bool,
pub sort_order: i64,
pub max_active_per_user: i64,
pub purchase_limit_scope: String,
pub entitlements_json: Value,
pub created_at_unix_secs: u64,
pub updated_at_unix_secs: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BillingPlanWriteInput {
pub title: String,
pub description: Option<String>,
pub price_amount: f64,
pub price_currency: String,
pub duration_unit: String,
pub duration_value: i64,
pub enabled: bool,
pub sort_order: i64,
pub max_active_per_user: i64,
pub purchase_limit_scope: String,
pub entitlements_json: Value,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct UserPlanEntitlementRecord {
pub id: String,
pub user_id: String,
pub plan_id: String,
pub payment_order_id: String,
pub status: String,
pub starts_at_unix_secs: u64,
pub expires_at_unix_secs: u64,
pub entitlements_snapshot: Value,
pub created_at_unix_secs: u64,
pub updated_at_unix_secs: u64,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct UserDailyQuotaAvailabilityRecord {
pub has_active_daily_quota: bool,
pub total_quota_usd: f64,
pub used_usd: f64,
pub remaining_usd: f64,
pub allow_wallet_overage: bool,
}
#[async_trait]
pub trait BillingReadRepository: Send + Sync {
async fn find_model_context(
@@ -273,4 +360,87 @@ pub trait BillingReadRepository: Send + Sync {
let _ = (preset, mode, collectors);
Ok(AdminBillingMutationOutcome::Unavailable)
}
async fn find_payment_gateway_config(
&self,
provider: &str,
) -> Result<Option<PaymentGatewayConfigRecord>, crate::DataLayerError> {
let _ = provider;
Ok(None)
}
async fn upsert_payment_gateway_config(
&self,
input: &PaymentGatewayConfigWriteInput,
) -> Result<AdminBillingMutationOutcome<PaymentGatewayConfigRecord>, crate::DataLayerError>
{
let _ = input;
Ok(AdminBillingMutationOutcome::Unavailable)
}
async fn list_billing_plans(
&self,
include_disabled: bool,
) -> Result<Option<Vec<BillingPlanRecord>>, crate::DataLayerError> {
let _ = include_disabled;
Ok(None)
}
async fn find_billing_plan(
&self,
plan_id: &str,
) -> Result<Option<BillingPlanRecord>, crate::DataLayerError> {
let _ = plan_id;
Ok(None)
}
async fn create_billing_plan(
&self,
input: &BillingPlanWriteInput,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, crate::DataLayerError> {
let _ = input;
Ok(AdminBillingMutationOutcome::Unavailable)
}
async fn update_billing_plan(
&self,
plan_id: &str,
input: &BillingPlanWriteInput,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, crate::DataLayerError> {
let _ = (plan_id, input);
Ok(AdminBillingMutationOutcome::Unavailable)
}
async fn set_billing_plan_enabled(
&self,
plan_id: &str,
enabled: bool,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, crate::DataLayerError> {
let _ = (plan_id, enabled);
Ok(AdminBillingMutationOutcome::Unavailable)
}
async fn delete_billing_plan(
&self,
plan_id: &str,
) -> Result<AdminBillingMutationOutcome<()>, crate::DataLayerError> {
let _ = plan_id;
Ok(AdminBillingMutationOutcome::Unavailable)
}
async fn list_user_plan_entitlements(
&self,
user_id: &str,
) -> Result<Option<Vec<UserPlanEntitlementRecord>>, crate::DataLayerError> {
let _ = user_id;
Ok(None)
}
async fn find_user_daily_quota_availability(
&self,
user_id: &str,
) -> Result<Option<UserDailyQuotaAvailabilityRecord>, crate::DataLayerError> {
let _ = user_id;
Ok(None)
}
}

View File

@@ -0,0 +1,79 @@
ALTER TABLE payment_orders
ADD COLUMN payment_provider VARCHAR(64),
ADD COLUMN payment_channel VARCHAR(64),
ADD COLUMN order_kind VARCHAR(64) NOT NULL DEFAULT 'wallet_recharge',
ADD COLUMN product_id VARCHAR(64),
ADD COLUMN product_snapshot TEXT,
ADD COLUMN fulfillment_status VARCHAR(64) NOT NULL DEFAULT 'pending',
ADD COLUMN fulfillment_error TEXT;
CREATE INDEX idx_payment_orders_kind_status
ON payment_orders (order_kind, status);
CREATE INDEX idx_payment_orders_product
ON payment_orders (product_id);
CREATE TABLE IF NOT EXISTS payment_gateway_configs (
provider VARCHAR(64) PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
endpoint_url VARCHAR(512) NOT NULL,
callback_base_url VARCHAR(512),
merchant_id VARCHAR(128) NOT NULL,
merchant_key_encrypted TEXT,
pay_currency VARCHAR(16) NOT NULL DEFAULT 'CNY',
usd_exchange_rate DOUBLE NOT NULL DEFAULT 7.2,
min_recharge_usd DOUBLE NOT NULL DEFAULT 1,
channels_json TEXT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS billing_plans (
id VARCHAR(64) PRIMARY KEY,
title VARCHAR(128) NOT NULL,
description TEXT,
price_amount DOUBLE NOT NULL,
price_currency VARCHAR(16) NOT NULL DEFAULT 'CNY',
duration_unit VARCHAR(32) NOT NULL,
duration_value BIGINT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
sort_order BIGINT NOT NULL DEFAULT 0,
max_active_per_user BIGINT NOT NULL DEFAULT 1,
entitlements_json TEXT NOT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
KEY idx_billing_plans_enabled_sort (enabled, sort_order)
);
CREATE TABLE IF NOT EXISTS user_plan_entitlements (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
plan_id VARCHAR(64) NOT NULL,
payment_order_id VARCHAR(64) NOT NULL,
status VARCHAR(64) NOT NULL DEFAULT 'active',
starts_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
entitlements_snapshot TEXT NOT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
KEY idx_user_plan_entitlements_user_active (user_id, status, expires_at),
KEY idx_user_plan_entitlements_order (payment_order_id),
CONSTRAINT user_plan_entitlements_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT user_plan_entitlements_plan_id_fkey FOREIGN KEY (plan_id) REFERENCES billing_plans(id) ON DELETE RESTRICT,
CONSTRAINT user_plan_entitlements_payment_order_id_fkey FOREIGN KEY (payment_order_id) REFERENCES payment_orders(id) ON DELETE RESTRICT
);
CREATE TABLE IF NOT EXISTS entitlement_usage_ledgers (
id VARCHAR(64) PRIMARY KEY,
user_entitlement_id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
request_id VARCHAR(128) NOT NULL,
amount_usd DOUBLE NOT NULL,
balance_before DOUBLE NOT NULL,
balance_after DOUBLE NOT NULL,
usage_date VARCHAR(16) NOT NULL,
created_at BIGINT NOT NULL,
UNIQUE KEY uq_entitlement_usage_request (user_entitlement_id, request_id),
KEY idx_entitlement_usage_user_date (user_id, usage_date),
CONSTRAINT entitlement_usage_ledgers_entitlement_fkey FOREIGN KEY (user_entitlement_id) REFERENCES user_plan_entitlements(id) ON DELETE CASCADE,
CONSTRAINT entitlement_usage_ledgers_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE billing_plans
ADD COLUMN purchase_limit_scope VARCHAR(32) NOT NULL DEFAULT 'active_period';

View File

@@ -0,0 +1,81 @@
ALTER TABLE public.payment_orders
ADD COLUMN payment_provider character varying(64),
ADD COLUMN payment_channel character varying(64),
ADD COLUMN order_kind character varying(64) NOT NULL DEFAULT 'wallet_recharge',
ADD COLUMN product_id character varying(64),
ADD COLUMN product_snapshot jsonb,
ADD COLUMN fulfillment_status character varying(64) NOT NULL DEFAULT 'pending',
ADD COLUMN fulfillment_error text;
CREATE INDEX IF NOT EXISTS idx_payment_orders_kind_status
ON public.payment_orders USING btree (order_kind, status);
CREATE INDEX IF NOT EXISTS idx_payment_orders_product
ON public.payment_orders USING btree (product_id);
CREATE TABLE IF NOT EXISTS public.payment_gateway_configs (
provider character varying(64) PRIMARY KEY,
enabled boolean NOT NULL DEFAULT false,
endpoint_url character varying(512) NOT NULL,
callback_base_url character varying(512),
merchant_id character varying(128) NOT NULL,
merchant_key_encrypted text,
pay_currency character varying(16) NOT NULL DEFAULT 'CNY',
usd_exchange_rate numeric(18,8) NOT NULL DEFAULT 7.2,
min_recharge_usd numeric(20,8) NOT NULL DEFAULT 1,
channels_json jsonb,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL
);
CREATE TABLE IF NOT EXISTS public.billing_plans (
id character varying(64) PRIMARY KEY,
title character varying(128) NOT NULL,
description text,
price_amount numeric(20,8) NOT NULL,
price_currency character varying(16) NOT NULL DEFAULT 'CNY',
duration_unit character varying(32) NOT NULL,
duration_value bigint NOT NULL,
enabled boolean NOT NULL DEFAULT true,
sort_order bigint NOT NULL DEFAULT 0,
max_active_per_user bigint NOT NULL DEFAULT 1,
entitlements_json jsonb NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_billing_plans_enabled_sort
ON public.billing_plans USING btree (enabled, sort_order);
CREATE TABLE IF NOT EXISTS public.user_plan_entitlements (
id character varying(64) PRIMARY KEY,
user_id character varying(64) NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
plan_id character varying(64) NOT NULL REFERENCES public.billing_plans(id) ON DELETE RESTRICT,
payment_order_id character varying(64) NOT NULL REFERENCES public.payment_orders(id) ON DELETE RESTRICT,
status character varying(64) NOT NULL DEFAULT 'active',
starts_at timestamp with time zone NOT NULL,
expires_at timestamp with time zone NOT NULL,
entitlements_snapshot jsonb NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_plan_entitlements_user_active
ON public.user_plan_entitlements USING btree (user_id, status, expires_at);
CREATE INDEX IF NOT EXISTS idx_user_plan_entitlements_order
ON public.user_plan_entitlements USING btree (payment_order_id);
CREATE TABLE IF NOT EXISTS public.entitlement_usage_ledgers (
id character varying(64) PRIMARY KEY,
user_entitlement_id character varying(64) NOT NULL REFERENCES public.user_plan_entitlements(id) ON DELETE CASCADE,
user_id character varying(64) NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
request_id character varying(128) NOT NULL,
amount_usd numeric(20,8) NOT NULL,
balance_before numeric(20,8) NOT NULL,
balance_after numeric(20,8) NOT NULL,
usage_date character varying(16) NOT NULL,
created_at timestamp with time zone NOT NULL,
CONSTRAINT uq_entitlement_usage_request UNIQUE (user_entitlement_id, request_id)
);
CREATE INDEX IF NOT EXISTS idx_entitlement_usage_user_date
ON public.entitlement_usage_ledgers USING btree (user_id, usage_date);

View File

@@ -0,0 +1,2 @@
ALTER TABLE public.billing_plans
ADD COLUMN IF NOT EXISTS purchase_limit_scope character varying(32) NOT NULL DEFAULT 'active_period';

View File

@@ -0,0 +1,85 @@
ALTER TABLE payment_orders ADD COLUMN payment_provider TEXT;
ALTER TABLE payment_orders ADD COLUMN payment_channel TEXT;
ALTER TABLE payment_orders ADD COLUMN order_kind TEXT NOT NULL DEFAULT 'wallet_recharge';
ALTER TABLE payment_orders ADD COLUMN product_id TEXT;
ALTER TABLE payment_orders ADD COLUMN product_snapshot TEXT;
ALTER TABLE payment_orders ADD COLUMN fulfillment_status TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE payment_orders ADD COLUMN fulfillment_error TEXT;
CREATE INDEX IF NOT EXISTS idx_payment_orders_kind_status
ON payment_orders (order_kind, status);
CREATE INDEX IF NOT EXISTS idx_payment_orders_product
ON payment_orders (product_id);
CREATE TABLE IF NOT EXISTS payment_gateway_configs (
provider TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
endpoint_url TEXT NOT NULL,
callback_base_url TEXT,
merchant_id TEXT NOT NULL,
merchant_key_encrypted TEXT,
pay_currency TEXT NOT NULL DEFAULT 'CNY',
usd_exchange_rate REAL NOT NULL DEFAULT 7.2,
min_recharge_usd REAL NOT NULL DEFAULT 1,
channels_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS billing_plans (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
price_amount REAL NOT NULL,
price_currency TEXT NOT NULL DEFAULT 'CNY',
duration_unit TEXT NOT NULL,
duration_value INTEGER NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
max_active_per_user INTEGER NOT NULL DEFAULT 1,
entitlements_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_billing_plans_enabled_sort
ON billing_plans (enabled, sort_order);
CREATE TABLE IF NOT EXISTS user_plan_entitlements (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
plan_id TEXT NOT NULL,
payment_order_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
starts_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
entitlements_snapshot TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(plan_id) REFERENCES billing_plans(id) ON DELETE RESTRICT,
FOREIGN KEY(payment_order_id) REFERENCES payment_orders(id) ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS idx_user_plan_entitlements_user_active
ON user_plan_entitlements (user_id, status, expires_at);
CREATE INDEX IF NOT EXISTS idx_user_plan_entitlements_order
ON user_plan_entitlements (payment_order_id);
CREATE TABLE IF NOT EXISTS entitlement_usage_ledgers (
id TEXT PRIMARY KEY,
user_entitlement_id TEXT NOT NULL,
user_id TEXT NOT NULL,
request_id TEXT NOT NULL,
amount_usd REAL NOT NULL,
balance_before REAL NOT NULL,
balance_after REAL NOT NULL,
usage_date TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE (user_entitlement_id, request_id),
FOREIGN KEY(user_entitlement_id) REFERENCES user_plan_entitlements(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_entitlement_usage_user_date
ON entitlement_usage_ledgers (user_id, usage_date);

View File

@@ -0,0 +1,2 @@
ALTER TABLE billing_plans
ADD COLUMN purchase_limit_scope TEXT NOT NULL DEFAULT 'active_period';

View File

@@ -459,6 +459,13 @@ CREATE TABLE IF NOT EXISTS public.payment_orders (
refunded_amount_usd numeric(20,8) DEFAULT '0'::numeric NOT NULL,
refundable_amount_usd numeric(20,8) DEFAULT '0'::numeric NOT NULL,
payment_method character varying(30) NOT NULL,
payment_provider character varying(64),
payment_channel character varying(64),
order_kind character varying(64) DEFAULT 'wallet_recharge'::character varying NOT NULL,
product_id character varying(64),
product_snapshot jsonb,
fulfillment_status character varying(64) DEFAULT 'pending'::character varying NOT NULL,
fulfillment_error text,
gateway_order_id character varying(128),
gateway_response jsonb,
status character varying(20) DEFAULT 'pending'::character varying NOT NULL,
@@ -470,6 +477,87 @@ CREATE TABLE IF NOT EXISTS public.payment_orders (
--
-- Name: payment_gateway_configs; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE IF NOT EXISTS public.payment_gateway_configs (
provider character varying(64) NOT NULL,
enabled boolean DEFAULT false NOT NULL,
endpoint_url character varying(512) NOT NULL,
callback_base_url character varying(512),
merchant_id character varying(128) NOT NULL,
merchant_key_encrypted text,
pay_currency character varying(16) DEFAULT 'CNY'::character varying NOT NULL,
usd_exchange_rate numeric(18,8) DEFAULT 7.2 NOT NULL,
min_recharge_usd numeric(20,8) DEFAULT 1 NOT NULL,
channels_json jsonb,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL
);
--
-- Name: billing_plans; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE IF NOT EXISTS public.billing_plans (
id character varying(64) NOT NULL,
title character varying(128) NOT NULL,
description text,
price_amount numeric(20,8) NOT NULL,
price_currency character varying(16) DEFAULT 'CNY'::character varying NOT NULL,
duration_unit character varying(32) NOT NULL,
duration_value bigint NOT NULL,
enabled boolean DEFAULT true NOT NULL,
sort_order bigint DEFAULT 0 NOT NULL,
max_active_per_user bigint DEFAULT 1 NOT NULL,
purchase_limit_scope character varying(32) DEFAULT 'active_period'::character varying NOT NULL,
entitlements_json jsonb NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL
);
--
-- Name: user_plan_entitlements; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE IF NOT EXISTS public.user_plan_entitlements (
id character varying(64) NOT NULL,
user_id character varying(64) NOT NULL,
plan_id character varying(64) NOT NULL,
payment_order_id character varying(64) NOT NULL,
status character varying(64) DEFAULT 'active'::character varying NOT NULL,
starts_at timestamp with time zone NOT NULL,
expires_at timestamp with time zone NOT NULL,
entitlements_snapshot jsonb NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL
);
--
-- Name: entitlement_usage_ledgers; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE IF NOT EXISTS public.entitlement_usage_ledgers (
id character varying(64) NOT NULL,
user_entitlement_id character varying(64) NOT NULL,
user_id character varying(64) NOT NULL,
request_id character varying(128) NOT NULL,
amount_usd numeric(20,8) NOT NULL,
balance_before numeric(20,8) NOT NULL,
balance_after numeric(20,8) NOT NULL,
usage_date character varying(16) NOT NULL,
created_at timestamp with time zone NOT NULL
);
--
-- Name: provider_api_keys; Type: TABLE; Schema: public; Owner: -
--

View File

@@ -87,6 +87,21 @@ END $mig$;
--
-- Name: billing_plans billing_plans_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.billing_plans
ADD CONSTRAINT billing_plans_pkey PRIMARY KEY (id);
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: dimension_collectors dimension_collectors_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -222,6 +237,21 @@ END $mig$;
--
-- Name: payment_gateway_configs payment_gateway_configs_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.payment_gateway_configs
ADD CONSTRAINT payment_gateway_configs_pkey PRIMARY KEY (provider);
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: payment_orders payment_orders_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -237,6 +267,21 @@ END $mig$;
--
-- Name: entitlement_usage_ledgers entitlement_usage_ledgers_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.entitlement_usage_ledgers
ADD CONSTRAINT entitlement_usage_ledgers_pkey PRIMARY KEY (id);
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: provider_api_keys provider_api_keys_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -612,6 +657,21 @@ END $mig$;
--
-- Name: entitlement_usage_ledgers uq_entitlement_usage_request; Type: CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.entitlement_usage_ledgers
ADD CONSTRAINT uq_entitlement_usage_request UNIQUE (user_entitlement_id, request_id);
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: payment_callbacks uq_payment_callbacks_callback_key; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -1167,6 +1227,21 @@ END $mig$;
--
-- Name: user_plan_entitlements user_plan_entitlements_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_plan_entitlements
ADD CONSTRAINT user_plan_entitlements_pkey PRIMARY KEY (id);
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: video_tasks video_tasks_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--

View File

@@ -101,6 +101,22 @@ CREATE INDEX IF NOT EXISTS idx_payment_orders_gateway_order_id ON public.payment
--
-- Name: idx_payment_orders_kind_status; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_payment_orders_kind_status ON public.payment_orders USING btree (order_kind, status);
--
-- Name: idx_payment_orders_product; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_payment_orders_product ON public.payment_orders USING btree (product_id);
--
-- Name: idx_payment_orders_status; Type: INDEX; Schema: public; Owner: -
--
@@ -125,6 +141,38 @@ CREATE INDEX IF NOT EXISTS idx_payment_orders_wallet_created ON public.payment_o
--
-- Name: idx_billing_plans_enabled_sort; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_billing_plans_enabled_sort ON public.billing_plans USING btree (enabled, sort_order);
--
-- Name: idx_user_plan_entitlements_user_active; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_user_plan_entitlements_user_active ON public.user_plan_entitlements USING btree (user_id, status, expires_at);
--
-- Name: idx_user_plan_entitlements_order; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_user_plan_entitlements_order ON public.user_plan_entitlements USING btree (payment_order_id);
--
-- Name: idx_entitlement_usage_user_date; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_entitlement_usage_user_date ON public.entitlement_usage_ledgers USING btree (user_id, usage_date);
--
-- Name: idx_provider_api_keys_provider_active; Type: INDEX; Schema: public; Owner: -
--

View File

@@ -267,6 +267,81 @@ END $mig$;
--
-- Name: user_plan_entitlements user_plan_entitlements_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_plan_entitlements
ADD CONSTRAINT user_plan_entitlements_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: user_plan_entitlements user_plan_entitlements_plan_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_plan_entitlements
ADD CONSTRAINT user_plan_entitlements_plan_id_fkey FOREIGN KEY (plan_id) REFERENCES public.billing_plans(id) ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: user_plan_entitlements user_plan_entitlements_payment_order_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_plan_entitlements
ADD CONSTRAINT user_plan_entitlements_payment_order_id_fkey FOREIGN KEY (payment_order_id) REFERENCES public.payment_orders(id) ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: entitlement_usage_ledgers entitlement_usage_ledgers_user_entitlement_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.entitlement_usage_ledgers
ADD CONSTRAINT entitlement_usage_ledgers_user_entitlement_id_fkey FOREIGN KEY (user_entitlement_id) REFERENCES public.user_plan_entitlements(id) ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: entitlement_usage_ledgers entitlement_usage_ledgers_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.entitlement_usage_ledgers
ADD CONSTRAINT entitlement_usage_ledgers_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: provider_endpoints provider_endpoints_provider_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--

View File

@@ -80,6 +80,13 @@ CREATE TABLE IF NOT EXISTS payment_orders (
`refunded_amount_usd` DOUBLE NOT NULL DEFAULT 0,
`refundable_amount_usd` DOUBLE NOT NULL DEFAULT 0,
`payment_method` VARCHAR(64) NOT NULL,
`payment_provider` VARCHAR(64),
`payment_channel` VARCHAR(64),
`order_kind` VARCHAR(64) NOT NULL DEFAULT 'wallet_recharge',
`product_id` VARCHAR(64),
`product_snapshot` JSON,
`fulfillment_status` VARCHAR(64) NOT NULL DEFAULT 'pending',
`fulfillment_error` LONGTEXT,
`gateway_order_id` VARCHAR(128),
`gateway_response` JSON,
`status` VARCHAR(64) NOT NULL DEFAULT 'pending',
@@ -92,7 +99,25 @@ CREATE TABLE IF NOT EXISTS payment_orders (
KEY idx_payment_orders_wallet_created (`wallet_id`, `created_at`),
KEY idx_payment_orders_user_created (`user_id`, `created_at`),
KEY idx_payment_orders_status (`status`),
KEY idx_payment_orders_gateway_order_id (`gateway_order_id`)
KEY idx_payment_orders_gateway_order_id (`gateway_order_id`),
KEY idx_payment_orders_kind_status (`order_kind`, `status`),
KEY idx_payment_orders_product (`product_id`)
);
CREATE TABLE IF NOT EXISTS payment_gateway_configs (
`provider` VARCHAR(64) NOT NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 0,
`endpoint_url` VARCHAR(512) NOT NULL,
`callback_base_url` VARCHAR(512),
`merchant_id` VARCHAR(128) NOT NULL,
`merchant_key_encrypted` LONGTEXT,
`pay_currency` VARCHAR(16) NOT NULL DEFAULT 'CNY',
`usd_exchange_rate` DOUBLE NOT NULL DEFAULT 7.2,
`min_recharge_usd` DOUBLE NOT NULL DEFAULT 1,
`channels_json` JSON,
`created_at` BIGINT NOT NULL,
`updated_at` BIGINT NOT NULL,
PRIMARY KEY (`provider`)
);
CREATE TABLE IF NOT EXISTS payment_callbacks (
@@ -117,6 +142,56 @@ CREATE TABLE IF NOT EXISTS payment_callbacks (
KEY ix_payment_callbacks_payment_order_id (`payment_order_id`)
);
CREATE TABLE IF NOT EXISTS billing_plans (
`id` VARCHAR(64) NOT NULL,
`title` VARCHAR(128) NOT NULL,
`description` LONGTEXT,
`price_amount` DOUBLE NOT NULL,
`price_currency` VARCHAR(16) NOT NULL DEFAULT 'CNY',
`duration_unit` VARCHAR(32) NOT NULL,
`duration_value` BIGINT NOT NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`sort_order` BIGINT NOT NULL DEFAULT 0,
`max_active_per_user` BIGINT NOT NULL DEFAULT 1,
`purchase_limit_scope` VARCHAR(32) NOT NULL DEFAULT 'active_period',
`entitlements_json` JSON NOT NULL,
`created_at` BIGINT NOT NULL,
`updated_at` BIGINT NOT NULL,
PRIMARY KEY (`id`),
KEY idx_billing_plans_enabled_sort (`enabled`, `sort_order`)
);
CREATE TABLE IF NOT EXISTS user_plan_entitlements (
`id` VARCHAR(64) NOT NULL,
`user_id` VARCHAR(64) NOT NULL,
`plan_id` VARCHAR(64) NOT NULL,
`payment_order_id` VARCHAR(64) NOT NULL,
`status` VARCHAR(64) NOT NULL DEFAULT 'active',
`starts_at` BIGINT NOT NULL,
`expires_at` BIGINT NOT NULL,
`entitlements_snapshot` JSON NOT NULL,
`created_at` BIGINT NOT NULL,
`updated_at` BIGINT NOT NULL,
PRIMARY KEY (`id`),
KEY idx_user_plan_entitlements_user_active (`user_id`, `status`, `expires_at`),
KEY idx_user_plan_entitlements_order (`payment_order_id`)
);
CREATE TABLE IF NOT EXISTS entitlement_usage_ledgers (
`id` VARCHAR(64) NOT NULL,
`user_entitlement_id` VARCHAR(64) NOT NULL,
`user_id` VARCHAR(64) NOT NULL,
`request_id` VARCHAR(128) NOT NULL,
`amount_usd` DOUBLE NOT NULL,
`balance_before` DOUBLE NOT NULL,
`balance_after` DOUBLE NOT NULL,
`usage_date` VARCHAR(16) NOT NULL,
`created_at` BIGINT NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY uq_entitlement_usage_request (`user_entitlement_id`, `request_id`),
KEY idx_entitlement_usage_user_date (`user_id`, `usage_date`)
);
CREATE TABLE IF NOT EXISTS refund_requests (
`id` VARCHAR(64) NOT NULL,
`refund_no` VARCHAR(128) NOT NULL,

View File

@@ -83,6 +83,13 @@ CREATE TABLE IF NOT EXISTS public.payment_orders (
refunded_amount_usd double precision DEFAULT 0 NOT NULL,
refundable_amount_usd double precision DEFAULT 0 NOT NULL,
payment_method character varying(64) NOT NULL,
payment_provider character varying(64),
payment_channel character varying(64),
order_kind character varying(64) DEFAULT 'wallet_recharge' NOT NULL,
product_id character varying(64),
product_snapshot jsonb,
fulfillment_status character varying(64) DEFAULT 'pending' NOT NULL,
fulfillment_error text,
gateway_order_id character varying(128),
gateway_response jsonb,
status character varying(64) DEFAULT 'pending' NOT NULL,
@@ -98,6 +105,25 @@ CREATE INDEX IF NOT EXISTS idx_payment_orders_wallet_created ON public.payment_o
CREATE INDEX IF NOT EXISTS idx_payment_orders_user_created ON public.payment_orders USING btree (user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_payment_orders_status ON public.payment_orders USING btree (status);
CREATE INDEX IF NOT EXISTS idx_payment_orders_gateway_order_id ON public.payment_orders USING btree (gateway_order_id);
CREATE INDEX IF NOT EXISTS idx_payment_orders_kind_status ON public.payment_orders USING btree (order_kind, status);
CREATE INDEX IF NOT EXISTS idx_payment_orders_product ON public.payment_orders USING btree (product_id);
CREATE TABLE IF NOT EXISTS public.payment_gateway_configs (
provider character varying(64) NOT NULL,
enabled boolean DEFAULT false NOT NULL,
endpoint_url character varying(512) NOT NULL,
callback_base_url character varying(512),
merchant_id character varying(128) NOT NULL,
merchant_key_encrypted text,
pay_currency character varying(16) DEFAULT 'CNY' NOT NULL,
usd_exchange_rate double precision DEFAULT 7.2 NOT NULL,
min_recharge_usd double precision DEFAULT 1 NOT NULL,
channels_json jsonb,
created_at bigint NOT NULL,
updated_at bigint NOT NULL
);
ALTER TABLE ONLY public.payment_gateway_configs ADD CONSTRAINT payment_gateway_configs_pkey PRIMARY KEY (provider);
CREATE TABLE IF NOT EXISTS public.payment_callbacks (
id character varying(64) NOT NULL,
@@ -122,6 +148,59 @@ CREATE INDEX IF NOT EXISTS idx_payment_callbacks_gateway_order ON public.payment
CREATE INDEX IF NOT EXISTS idx_payment_callbacks_created ON public.payment_callbacks USING btree (created_at);
CREATE INDEX IF NOT EXISTS ix_payment_callbacks_payment_order_id ON public.payment_callbacks USING btree (payment_order_id);
CREATE TABLE IF NOT EXISTS public.billing_plans (
id character varying(64) NOT NULL,
title character varying(128) NOT NULL,
description text,
price_amount double precision NOT NULL,
price_currency character varying(16) DEFAULT 'CNY' NOT NULL,
duration_unit character varying(32) NOT NULL,
duration_value bigint NOT NULL,
enabled boolean DEFAULT true NOT NULL,
sort_order bigint DEFAULT 0 NOT NULL,
max_active_per_user bigint DEFAULT 1 NOT NULL,
purchase_limit_scope character varying(32) DEFAULT 'active_period' NOT NULL,
entitlements_json jsonb NOT NULL,
created_at bigint NOT NULL,
updated_at bigint NOT NULL
);
ALTER TABLE ONLY public.billing_plans ADD CONSTRAINT billing_plans_pkey PRIMARY KEY (id);
CREATE INDEX IF NOT EXISTS idx_billing_plans_enabled_sort ON public.billing_plans USING btree (enabled, sort_order);
CREATE TABLE IF NOT EXISTS public.user_plan_entitlements (
id character varying(64) NOT NULL,
user_id character varying(64) NOT NULL,
plan_id character varying(64) NOT NULL,
payment_order_id character varying(64) NOT NULL,
status character varying(64) DEFAULT 'active' NOT NULL,
starts_at bigint NOT NULL,
expires_at bigint NOT NULL,
entitlements_snapshot jsonb NOT NULL,
created_at bigint NOT NULL,
updated_at bigint NOT NULL
);
ALTER TABLE ONLY public.user_plan_entitlements ADD CONSTRAINT user_plan_entitlements_pkey PRIMARY KEY (id);
CREATE INDEX IF NOT EXISTS idx_user_plan_entitlements_user_active ON public.user_plan_entitlements USING btree (user_id, status, expires_at);
CREATE INDEX IF NOT EXISTS idx_user_plan_entitlements_order ON public.user_plan_entitlements USING btree (payment_order_id);
CREATE TABLE IF NOT EXISTS public.entitlement_usage_ledgers (
id character varying(64) NOT NULL,
user_entitlement_id character varying(64) NOT NULL,
user_id character varying(64) NOT NULL,
request_id character varying(128) NOT NULL,
amount_usd double precision NOT NULL,
balance_before double precision NOT NULL,
balance_after double precision NOT NULL,
usage_date character varying(16) NOT NULL,
created_at bigint NOT NULL
);
ALTER TABLE ONLY public.entitlement_usage_ledgers ADD CONSTRAINT entitlement_usage_ledgers_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.entitlement_usage_ledgers ADD CONSTRAINT uq_entitlement_usage_request UNIQUE (user_entitlement_id, request_id);
CREATE INDEX IF NOT EXISTS idx_entitlement_usage_user_date ON public.entitlement_usage_ledgers USING btree (user_id, usage_date);
CREATE TABLE IF NOT EXISTS public.refund_requests (
id character varying(64) NOT NULL,
refund_no character varying(128) NOT NULL,

View File

@@ -77,6 +77,13 @@ CREATE TABLE IF NOT EXISTS payment_orders (
refunded_amount_usd REAL NOT NULL DEFAULT 0,
refundable_amount_usd REAL NOT NULL DEFAULT 0,
payment_method TEXT NOT NULL,
payment_provider TEXT,
payment_channel TEXT,
order_kind TEXT NOT NULL DEFAULT 'wallet_recharge',
product_id TEXT,
product_snapshot TEXT,
fulfillment_status TEXT NOT NULL DEFAULT 'pending',
fulfillment_error TEXT,
gateway_order_id TEXT,
gateway_response TEXT,
status TEXT NOT NULL DEFAULT 'pending',
@@ -90,6 +97,23 @@ CREATE INDEX IF NOT EXISTS idx_payment_orders_wallet_created ON payment_orders (
CREATE INDEX IF NOT EXISTS idx_payment_orders_user_created ON payment_orders (user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_payment_orders_status ON payment_orders (status);
CREATE INDEX IF NOT EXISTS idx_payment_orders_gateway_order_id ON payment_orders (gateway_order_id);
CREATE INDEX IF NOT EXISTS idx_payment_orders_kind_status ON payment_orders (order_kind, status);
CREATE INDEX IF NOT EXISTS idx_payment_orders_product ON payment_orders (product_id);
CREATE TABLE IF NOT EXISTS payment_gateway_configs (
provider TEXT PRIMARY KEY NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
endpoint_url TEXT NOT NULL,
callback_base_url TEXT,
merchant_id TEXT NOT NULL,
merchant_key_encrypted TEXT,
pay_currency TEXT NOT NULL DEFAULT 'CNY',
usd_exchange_rate REAL NOT NULL DEFAULT 7.2,
min_recharge_usd REAL NOT NULL DEFAULT 1,
channels_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS payment_callbacks (
id TEXT PRIMARY KEY NOT NULL,
@@ -112,6 +136,53 @@ CREATE INDEX IF NOT EXISTS idx_payment_callbacks_gateway_order ON payment_callba
CREATE INDEX IF NOT EXISTS idx_payment_callbacks_created ON payment_callbacks (created_at);
CREATE INDEX IF NOT EXISTS ix_payment_callbacks_payment_order_id ON payment_callbacks (payment_order_id);
CREATE TABLE IF NOT EXISTS billing_plans (
id TEXT PRIMARY KEY NOT NULL,
title TEXT NOT NULL,
description TEXT,
price_amount REAL NOT NULL,
price_currency TEXT NOT NULL DEFAULT 'CNY',
duration_unit TEXT NOT NULL,
duration_value INTEGER NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
max_active_per_user INTEGER NOT NULL DEFAULT 1,
purchase_limit_scope TEXT NOT NULL DEFAULT 'active_period',
entitlements_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_billing_plans_enabled_sort ON billing_plans (enabled, sort_order);
CREATE TABLE IF NOT EXISTS user_plan_entitlements (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
plan_id TEXT NOT NULL,
payment_order_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
starts_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
entitlements_snapshot TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_plan_entitlements_user_active ON user_plan_entitlements (user_id, status, expires_at);
CREATE INDEX IF NOT EXISTS idx_user_plan_entitlements_order ON user_plan_entitlements (payment_order_id);
CREATE TABLE IF NOT EXISTS entitlement_usage_ledgers (
id TEXT PRIMARY KEY NOT NULL,
user_entitlement_id TEXT NOT NULL,
user_id TEXT NOT NULL,
request_id TEXT NOT NULL,
amount_usd REAL NOT NULL,
balance_before REAL NOT NULL,
balance_after REAL NOT NULL,
usage_date TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE (user_entitlement_id, request_id)
);
CREATE INDEX IF NOT EXISTS idx_entitlement_usage_user_date ON entitlement_usage_ledgers (user_id, usage_date);
CREATE TABLE IF NOT EXISTS refund_requests (
id TEXT PRIMARY KEY NOT NULL,
refund_no TEXT NOT NULL,

View File

@@ -334,6 +334,46 @@ name = "payment_method"
type = "text"
length = 64
[[table.payment_orders.columns]]
name = "payment_provider"
type = "text"
length = 64
nullable = true
[[table.payment_orders.columns]]
name = "payment_channel"
type = "text"
length = 64
nullable = true
[[table.payment_orders.columns]]
name = "order_kind"
type = "text"
length = 64
default = "wallet_recharge"
[[table.payment_orders.columns]]
name = "product_id"
type = "text_id"
length = 64
nullable = true
[[table.payment_orders.columns]]
name = "product_snapshot"
type = "json"
nullable = true
[[table.payment_orders.columns]]
name = "fulfillment_status"
type = "text"
length = 64
default = "pending"
[[table.payment_orders.columns]]
name = "fulfillment_error"
type = "long_text"
nullable = true
[[table.payment_orders.columns]]
name = "gateway_order_id"
type = "text"
@@ -390,6 +430,79 @@ columns = ["status"]
name = "idx_payment_orders_gateway_order_id"
columns = ["gateway_order_id"]
[[table.payment_orders.indexes]]
name = "idx_payment_orders_kind_status"
columns = ["order_kind", "status"]
[[table.payment_orders.indexes]]
name = "idx_payment_orders_product"
columns = ["product_id"]
[table.payment_gateway_configs]
domain = "wallet_billing"
order = 45
primary_key = ["provider"]
[[table.payment_gateway_configs.columns]]
name = "provider"
type = "text"
length = 64
[[table.payment_gateway_configs.columns]]
name = "enabled"
type = "bool"
default = false
[[table.payment_gateway_configs.columns]]
name = "endpoint_url"
type = "text"
length = 512
[[table.payment_gateway_configs.columns]]
name = "callback_base_url"
type = "text"
length = 512
nullable = true
[[table.payment_gateway_configs.columns]]
name = "merchant_id"
type = "text"
length = 128
[[table.payment_gateway_configs.columns]]
name = "merchant_key_encrypted"
type = "long_text"
nullable = true
[[table.payment_gateway_configs.columns]]
name = "pay_currency"
type = "text"
length = 16
default = "CNY"
[[table.payment_gateway_configs.columns]]
name = "usd_exchange_rate"
type = "float64"
default = { raw = "7.2" }
[[table.payment_gateway_configs.columns]]
name = "min_recharge_usd"
type = "float64"
default = 1
[[table.payment_gateway_configs.columns]]
name = "channels_json"
type = "json"
nullable = true
[[table.payment_gateway_configs.columns]]
name = "created_at"
type = "unix_seconds"
[[table.payment_gateway_configs.columns]]
name = "updated_at"
type = "unix_seconds"
[table.payment_callbacks]
domain = "wallet_billing"
order = 50
@@ -468,6 +581,195 @@ nullable = true
name = "uq_payment_callbacks_callback_key"
columns = ["callback_key"]
[table.billing_plans]
domain = "wallet_billing"
order = 55
primary_key = ["id"]
[[table.billing_plans.columns]]
name = "id"
type = "text_id"
length = 64
[[table.billing_plans.columns]]
name = "title"
type = "text"
length = 128
[[table.billing_plans.columns]]
name = "description"
type = "long_text"
nullable = true
[[table.billing_plans.columns]]
name = "price_amount"
type = "float64"
[[table.billing_plans.columns]]
name = "price_currency"
type = "text"
length = 16
default = "CNY"
[[table.billing_plans.columns]]
name = "duration_unit"
type = "text"
length = 32
[[table.billing_plans.columns]]
name = "duration_value"
type = "int64"
[[table.billing_plans.columns]]
name = "enabled"
type = "bool"
default = true
[[table.billing_plans.columns]]
name = "sort_order"
type = "int64"
default = 0
[[table.billing_plans.columns]]
name = "max_active_per_user"
type = "int64"
default = 1
[[table.billing_plans.columns]]
name = "purchase_limit_scope"
type = "text"
length = 32
default = "active_period"
[[table.billing_plans.columns]]
name = "entitlements_json"
type = "json"
[[table.billing_plans.columns]]
name = "created_at"
type = "unix_seconds"
[[table.billing_plans.columns]]
name = "updated_at"
type = "unix_seconds"
[[table.billing_plans.indexes]]
name = "idx_billing_plans_enabled_sort"
columns = ["enabled", "sort_order"]
[table.user_plan_entitlements]
domain = "wallet_billing"
order = 56
primary_key = ["id"]
[[table.user_plan_entitlements.columns]]
name = "id"
type = "text_id"
length = 64
[[table.user_plan_entitlements.columns]]
name = "user_id"
type = "text_id"
length = 64
[[table.user_plan_entitlements.columns]]
name = "plan_id"
type = "text_id"
length = 64
[[table.user_plan_entitlements.columns]]
name = "payment_order_id"
type = "text_id"
length = 64
[[table.user_plan_entitlements.columns]]
name = "status"
type = "text"
length = 64
default = "active"
[[table.user_plan_entitlements.columns]]
name = "starts_at"
type = "unix_seconds"
[[table.user_plan_entitlements.columns]]
name = "expires_at"
type = "unix_seconds"
[[table.user_plan_entitlements.columns]]
name = "entitlements_snapshot"
type = "json"
[[table.user_plan_entitlements.columns]]
name = "created_at"
type = "unix_seconds"
[[table.user_plan_entitlements.columns]]
name = "updated_at"
type = "unix_seconds"
[[table.user_plan_entitlements.indexes]]
name = "idx_user_plan_entitlements_user_active"
columns = ["user_id", "status", "expires_at"]
[[table.user_plan_entitlements.indexes]]
name = "idx_user_plan_entitlements_order"
columns = ["payment_order_id"]
[table.entitlement_usage_ledgers]
domain = "wallet_billing"
order = 57
primary_key = ["id"]
[[table.entitlement_usage_ledgers.columns]]
name = "id"
type = "text_id"
length = 64
[[table.entitlement_usage_ledgers.columns]]
name = "user_entitlement_id"
type = "text_id"
length = 64
[[table.entitlement_usage_ledgers.columns]]
name = "user_id"
type = "text_id"
length = 64
[[table.entitlement_usage_ledgers.columns]]
name = "request_id"
type = "text"
length = 128
[[table.entitlement_usage_ledgers.columns]]
name = "amount_usd"
type = "float64"
[[table.entitlement_usage_ledgers.columns]]
name = "balance_before"
type = "float64"
[[table.entitlement_usage_ledgers.columns]]
name = "balance_after"
type = "float64"
[[table.entitlement_usage_ledgers.columns]]
name = "usage_date"
type = "text"
length = 16
[[table.entitlement_usage_ledgers.columns]]
name = "created_at"
type = "unix_seconds"
[[table.entitlement_usage_ledgers.uniques]]
name = "uq_entitlement_usage_request"
columns = ["user_entitlement_id", "request_id"]
[[table.entitlement_usage_ledgers.indexes]]
name = "idx_entitlement_usage_user_date"
columns = ["user_id", "usage_date"]
[[table.payment_callbacks.indexes]]
name = "idx_payment_callbacks_order"
columns = ["order_no"]

View File

@@ -7,7 +7,7 @@ use tracing::info;
// Generated by build.rs from schema/bootstrap/postgres.
pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str =
include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql"));
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260512000000;
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260512110000;
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
SELECT COUNT(*)::BIGINT

View File

@@ -302,6 +302,8 @@ fn empty_database_snapshot_covers_current_cutoff_versions() {
20260511120000,
20260511130000,
20260512000000,
20260512090000,
20260512110000,
]
);
}
@@ -374,6 +376,18 @@ fn empty_database_snapshot_sql_includes_usage_body_blobs() {
));
}
#[test]
fn empty_database_snapshot_sql_includes_payment_gateway_and_plans() {
assert!(EMPTY_DATABASE_SNAPSHOT_SQL.contains("payment_provider character varying(64)"));
assert!(EMPTY_DATABASE_SNAPSHOT_SQL
.contains("CREATE TABLE IF NOT EXISTS public.payment_gateway_configs"));
assert!(EMPTY_DATABASE_SNAPSHOT_SQL.contains("CREATE TABLE IF NOT EXISTS public.billing_plans"));
assert!(EMPTY_DATABASE_SNAPSHOT_SQL
.contains("purchase_limit_scope character varying(32) DEFAULT 'active_period'"));
assert!(EMPTY_DATABASE_SNAPSHOT_SQL
.contains("CREATE TABLE IF NOT EXISTS public.user_plan_entitlements"));
}
#[test]
fn provider_api_keys_api_formats_remains_nullable_in_baselines() {
let baseline_migration = POSTGRES_MIGRATOR
@@ -569,6 +583,8 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
20260511120000,
20260511130000,
20260512000000,
20260512090000,
20260512110000,
]
);
assert_eq!(
@@ -583,6 +599,8 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
20260511120000,
20260511130000,
20260512000000,
20260512090000,
20260512110000,
]
);
}
@@ -1096,6 +1114,8 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
20260511120000,
20260511130000,
20260512000000,
20260512090000,
20260512110000,
]
);
}

View File

@@ -3,7 +3,11 @@ use std::sync::RwLock;
use async_trait::async_trait;
use super::{BillingReadRepository, StoredBillingModelContext};
use super::{
AdminBillingMutationOutcome, BillingPlanRecord, BillingPlanWriteInput, BillingReadRepository,
PaymentGatewayConfigRecord, PaymentGatewayConfigWriteInput, StoredBillingModelContext,
UserDailyQuotaAvailabilityRecord, UserPlanEntitlementRecord,
};
use crate::DataLayerError;
type BillingContextKey = (String, String, Option<String>);
@@ -12,6 +16,9 @@ type BillingContextMap = BTreeMap<BillingContextKey, StoredBillingModelContext>;
#[derive(Debug, Default)]
pub struct InMemoryBillingReadRepository {
by_key: RwLock<BillingContextMap>,
gateway_configs_by_provider: RwLock<BTreeMap<String, PaymentGatewayConfigRecord>>,
billing_plans_by_id: RwLock<BTreeMap<String, BillingPlanRecord>>,
entitlements_by_id: RwLock<BTreeMap<String, UserPlanEntitlementRecord>>,
}
impl InMemoryBillingReadRepository {
@@ -32,10 +39,88 @@ impl InMemoryBillingReadRepository {
}
Self {
by_key: RwLock::new(by_key),
gateway_configs_by_provider: RwLock::new(BTreeMap::new()),
billing_plans_by_id: RwLock::new(BTreeMap::new()),
entitlements_by_id: RwLock::new(BTreeMap::new()),
}
}
}
fn current_unix_secs() -> u64 {
chrono::Utc::now().timestamp().max(0) as u64
}
fn billing_plan_from_input(
id: String,
input: &BillingPlanWriteInput,
created_at: u64,
) -> BillingPlanRecord {
BillingPlanRecord {
id,
title: input.title.clone(),
description: input.description.clone(),
price_amount: input.price_amount,
price_currency: input.price_currency.clone(),
duration_unit: input.duration_unit.clone(),
duration_value: input.duration_value,
enabled: input.enabled,
sort_order: input.sort_order,
max_active_per_user: input.max_active_per_user,
purchase_limit_scope: input.purchase_limit_scope.clone(),
entitlements_json: input.entitlements_json.clone(),
created_at_unix_secs: created_at,
updated_at_unix_secs: current_unix_secs(),
}
}
fn daily_quota_availability_from_entitlements(
entitlements: impl IntoIterator<Item = UserPlanEntitlementRecord>,
now: u64,
) -> UserDailyQuotaAvailabilityRecord {
let mut has_active_daily_quota = false;
let mut total_quota_usd = 0.0;
let used_usd = 0.0;
let mut remaining_usd = 0.0;
let mut allow_wallet_overage = true;
for entitlement in entitlements {
if entitlement.status != "active"
|| entitlement.starts_at_unix_secs > now
|| entitlement.expires_at_unix_secs <= now
{
continue;
}
let Some(items) = entitlement.entitlements_snapshot.as_array() else {
continue;
};
for item in items {
if item.get("type").and_then(serde_json::Value::as_str) != Some("daily_quota") {
continue;
}
let daily_quota_usd = item
.get("daily_quota_usd")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 {
continue;
}
has_active_daily_quota = true;
total_quota_usd += daily_quota_usd;
remaining_usd += daily_quota_usd;
allow_wallet_overage &= item
.get("allow_wallet_overage")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
}
}
UserDailyQuotaAvailabilityRecord {
has_active_daily_quota,
total_quota_usd,
used_usd,
remaining_usd,
allow_wallet_overage,
}
}
#[async_trait]
impl BillingReadRepository for InMemoryBillingReadRepository {
async fn find_model_context(
@@ -101,6 +186,204 @@ impl BillingReadRepository for InMemoryBillingReadRepository {
})
.map(|(_, value)| value.clone()))
}
async fn find_payment_gateway_config(
&self,
provider: &str,
) -> Result<Option<PaymentGatewayConfigRecord>, DataLayerError> {
Ok(self
.gateway_configs_by_provider
.read()
.expect("billing repository lock")
.get(&provider.trim().to_ascii_lowercase())
.cloned())
}
async fn upsert_payment_gateway_config(
&self,
input: &PaymentGatewayConfigWriteInput,
) -> Result<AdminBillingMutationOutcome<PaymentGatewayConfigRecord>, DataLayerError> {
let provider = input.provider.trim().to_ascii_lowercase();
let now = current_unix_secs();
let mut configs = self
.gateway_configs_by_provider
.write()
.expect("billing repository lock");
let created_at = configs
.get(&provider)
.map(|value| value.created_at_unix_secs)
.unwrap_or(now);
let merchant_key_encrypted = if input.preserve_existing_secret {
configs
.get(&provider)
.and_then(|value| value.merchant_key_encrypted.clone())
} else {
input.merchant_key_encrypted.clone()
};
let record = PaymentGatewayConfigRecord {
provider: provider.clone(),
enabled: input.enabled,
endpoint_url: input.endpoint_url.clone(),
callback_base_url: input.callback_base_url.clone(),
merchant_id: input.merchant_id.clone(),
merchant_key_encrypted,
pay_currency: input.pay_currency.clone(),
usd_exchange_rate: input.usd_exchange_rate,
min_recharge_usd: input.min_recharge_usd,
channels_json: input.channels_json.clone(),
created_at_unix_secs: created_at,
updated_at_unix_secs: now,
};
configs.insert(provider, record.clone());
Ok(AdminBillingMutationOutcome::Applied(record))
}
async fn list_billing_plans(
&self,
include_disabled: bool,
) -> Result<Option<Vec<BillingPlanRecord>>, DataLayerError> {
let mut items = self
.billing_plans_by_id
.read()
.expect("billing repository lock")
.values()
.filter(|item| include_disabled || item.enabled)
.cloned()
.collect::<Vec<_>>();
items.sort_by(|left, right| {
left.sort_order
.cmp(&right.sort_order)
.then_with(|| left.price_amount.total_cmp(&right.price_amount))
.then_with(|| left.id.cmp(&right.id))
});
Ok(Some(items))
}
async fn find_billing_plan(
&self,
plan_id: &str,
) -> Result<Option<BillingPlanRecord>, DataLayerError> {
Ok(self
.billing_plans_by_id
.read()
.expect("billing repository lock")
.get(plan_id)
.cloned())
}
async fn create_billing_plan(
&self,
input: &BillingPlanWriteInput,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let id = uuid::Uuid::new_v4().to_string();
let record = billing_plan_from_input(id.clone(), input, current_unix_secs());
self.billing_plans_by_id
.write()
.expect("billing repository lock")
.insert(id, record.clone());
Ok(AdminBillingMutationOutcome::Applied(record))
}
async fn update_billing_plan(
&self,
plan_id: &str,
input: &BillingPlanWriteInput,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let mut plans = self
.billing_plans_by_id
.write()
.expect("billing repository lock");
let Some(existing) = plans.get(plan_id).cloned() else {
return Ok(AdminBillingMutationOutcome::NotFound);
};
let record =
billing_plan_from_input(plan_id.to_string(), input, existing.created_at_unix_secs);
plans.insert(plan_id.to_string(), record.clone());
Ok(AdminBillingMutationOutcome::Applied(record))
}
async fn set_billing_plan_enabled(
&self,
plan_id: &str,
enabled: bool,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let mut plans = self
.billing_plans_by_id
.write()
.expect("billing repository lock");
let Some(record) = plans.get_mut(plan_id) else {
return Ok(AdminBillingMutationOutcome::NotFound);
};
record.enabled = enabled;
record.updated_at_unix_secs = current_unix_secs();
Ok(AdminBillingMutationOutcome::Applied(record.clone()))
}
async fn delete_billing_plan(
&self,
plan_id: &str,
) -> Result<AdminBillingMutationOutcome<()>, DataLayerError> {
let mut plans = self
.billing_plans_by_id
.write()
.expect("billing repository lock");
if !plans.contains_key(plan_id) {
return Ok(AdminBillingMutationOutcome::NotFound);
}
let has_entitlements = self
.entitlements_by_id
.read()
.expect("billing repository lock")
.values()
.any(|item| item.plan_id == plan_id);
if has_entitlements {
return Ok(AdminBillingMutationOutcome::Invalid(
"套餐已有订单或权益,不能删除,请停用该套餐".to_string(),
));
}
plans.remove(plan_id);
Ok(AdminBillingMutationOutcome::Applied(()))
}
async fn list_user_plan_entitlements(
&self,
user_id: &str,
) -> Result<Option<Vec<UserPlanEntitlementRecord>>, DataLayerError> {
let now = current_unix_secs();
let mut items = self
.entitlements_by_id
.read()
.expect("billing repository lock")
.values()
.filter(|item| {
item.user_id == user_id
&& item.status == "active"
&& item.expires_at_unix_secs > now
})
.cloned()
.collect::<Vec<_>>();
items.sort_by_key(|item| item.expires_at_unix_secs);
Ok(Some(items))
}
async fn find_user_daily_quota_availability(
&self,
user_id: &str,
) -> Result<Option<UserDailyQuotaAvailabilityRecord>, DataLayerError> {
let now = current_unix_secs();
let entitlements = self
.entitlements_by_id
.read()
.expect("billing repository lock")
.values()
.filter(|item| item.user_id == user_id)
.cloned()
.collect::<Vec<_>>();
Ok(Some(daily_quota_availability_from_entitlements(
entitlements,
now,
)))
}
}
fn find_context_by_provider_model_name(

View File

@@ -7,7 +7,9 @@ mod sqlite;
pub(crate) use aether_data_contracts::repository::billing::{
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingMutationOutcome,
AdminBillingPresetApplyResult, AdminBillingRuleRecord, AdminBillingRuleWriteInput,
BillingReadRepository, StoredBillingModelContext,
BillingPlanRecord, BillingPlanWriteInput, BillingReadRepository, PaymentGatewayConfigRecord,
PaymentGatewayConfigWriteInput, StoredBillingModelContext, UserDailyQuotaAvailabilityRecord,
UserPlanEntitlementRecord,
};
pub use memory::InMemoryBillingReadRepository;
pub use mysql::MysqlBillingReadRepository;

View File

@@ -4,7 +4,9 @@ use sqlx::{mysql::MySqlRow, Row};
use super::{
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingMutationOutcome,
AdminBillingPresetApplyResult, AdminBillingRuleRecord, AdminBillingRuleWriteInput,
BillingReadRepository, StoredBillingModelContext,
BillingPlanRecord, BillingPlanWriteInput, BillingReadRepository, PaymentGatewayConfigRecord,
PaymentGatewayConfigWriteInput, StoredBillingModelContext, UserDailyQuotaAvailabilityRecord,
UserPlanEntitlementRecord,
};
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
@@ -609,8 +611,409 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
},
))
}
async fn find_payment_gateway_config(
&self,
provider: &str,
) -> Result<Option<PaymentGatewayConfigRecord>, DataLayerError> {
let row = sqlx::query(
r#"
SELECT
provider, enabled, endpoint_url, callback_base_url, merchant_id,
merchant_key_encrypted, pay_currency, usd_exchange_rate, min_recharge_usd,
channels_json, created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM payment_gateway_configs
WHERE provider = ?
LIMIT 1
"#,
)
.bind(provider.trim().to_ascii_lowercase())
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
row.as_ref()
.map(map_payment_gateway_config_mysql)
.transpose()
}
async fn upsert_payment_gateway_config(
&self,
input: &PaymentGatewayConfigWriteInput,
) -> Result<AdminBillingMutationOutcome<PaymentGatewayConfigRecord>, DataLayerError> {
let provider = input.provider.trim().to_ascii_lowercase();
let existing_secret = if input.preserve_existing_secret {
sqlx::query_scalar::<_, String>(
"SELECT merchant_key_encrypted FROM payment_gateway_configs WHERE provider = ?",
)
.bind(&provider)
.fetch_optional(&self.pool)
.await
.map_sql_err()?
} else {
None
};
let secret = if input.preserve_existing_secret {
existing_secret
} else {
input.merchant_key_encrypted.clone()
};
let now = current_unix_secs_i64();
sqlx::query(
r#"
INSERT INTO payment_gateway_configs (
provider, enabled, endpoint_url, callback_base_url, merchant_id,
merchant_key_encrypted, pay_currency, usd_exchange_rate, min_recharge_usd,
channels_json, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
endpoint_url = VALUES(endpoint_url),
callback_base_url = VALUES(callback_base_url),
merchant_id = VALUES(merchant_id),
merchant_key_encrypted = VALUES(merchant_key_encrypted),
pay_currency = VALUES(pay_currency),
usd_exchange_rate = VALUES(usd_exchange_rate),
min_recharge_usd = VALUES(min_recharge_usd),
channels_json = VALUES(channels_json),
updated_at = VALUES(updated_at)
"#,
)
.bind(&provider)
.bind(input.enabled)
.bind(&input.endpoint_url)
.bind(input.callback_base_url.as_deref())
.bind(&input.merchant_id)
.bind(secret.as_deref())
.bind(&input.pay_currency)
.bind(input.usd_exchange_rate)
.bind(input.min_recharge_usd)
.bind(json_to_string(&input.channels_json)?)
.bind(now)
.bind(now)
.execute(&self.pool)
.await
.map_sql_err()?;
match self.find_payment_gateway_config(&provider).await? {
Some(record) => Ok(AdminBillingMutationOutcome::Applied(record)),
None => Err(DataLayerError::UnexpectedValue(
"upserted payment gateway config missing".to_string(),
)),
}
}
async fn list_billing_plans(
&self,
include_disabled: bool,
) -> Result<Option<Vec<BillingPlanRecord>>, DataLayerError> {
let rows = sqlx::query(
r#"
SELECT
id, title, description, price_amount, price_currency, duration_unit,
duration_value, enabled, sort_order, max_active_per_user, purchase_limit_scope,
entitlements_json,
created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM billing_plans
WHERE (? = TRUE OR enabled = TRUE)
ORDER BY sort_order ASC, price_amount ASC, id ASC
"#,
)
.bind(include_disabled)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
Ok(Some(
rows.iter()
.map(map_billing_plan_mysql)
.collect::<Result<Vec<_>, _>>()?,
))
}
async fn find_billing_plan(
&self,
plan_id: &str,
) -> Result<Option<BillingPlanRecord>, DataLayerError> {
let row = sqlx::query(
r#"
SELECT
id, title, description, price_amount, price_currency, duration_unit,
duration_value, enabled, sort_order, max_active_per_user, purchase_limit_scope,
entitlements_json,
created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM billing_plans
WHERE id = ?
LIMIT 1
"#,
)
.bind(plan_id)
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
row.as_ref().map(map_billing_plan_mysql).transpose()
}
async fn create_billing_plan(
&self,
input: &BillingPlanWriteInput,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let id = uuid::Uuid::new_v4().to_string();
let now = current_unix_secs_i64();
sqlx::query(BILLING_PLAN_INSERT_MYSQL)
.bind(&id)
.bind(&input.title)
.bind(input.description.as_deref())
.bind(input.price_amount)
.bind(&input.price_currency)
.bind(&input.duration_unit)
.bind(input.duration_value)
.bind(input.enabled)
.bind(input.sort_order)
.bind(input.max_active_per_user)
.bind(&input.purchase_limit_scope)
.bind(json_to_string(&input.entitlements_json)?)
.bind(now)
.bind(now)
.execute(&self.pool)
.await
.map_sql_err()?;
match self.find_billing_plan(&id).await? {
Some(record) => Ok(AdminBillingMutationOutcome::Applied(record)),
None => Err(DataLayerError::UnexpectedValue(
"created billing plan missing".to_string(),
)),
}
}
async fn update_billing_plan(
&self,
plan_id: &str,
input: &BillingPlanWriteInput,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let result = sqlx::query(BILLING_PLAN_UPDATE_MYSQL)
.bind(&input.title)
.bind(input.description.as_deref())
.bind(input.price_amount)
.bind(&input.price_currency)
.bind(&input.duration_unit)
.bind(input.duration_value)
.bind(input.enabled)
.bind(input.sort_order)
.bind(input.max_active_per_user)
.bind(&input.purchase_limit_scope)
.bind(json_to_string(&input.entitlements_json)?)
.bind(current_unix_secs_i64())
.bind(plan_id)
.execute(&self.pool)
.await
.map_sql_err()?;
if result.rows_affected() == 0 {
return Ok(AdminBillingMutationOutcome::NotFound);
}
match self.find_billing_plan(plan_id).await? {
Some(record) => Ok(AdminBillingMutationOutcome::Applied(record)),
None => Ok(AdminBillingMutationOutcome::NotFound),
}
}
async fn set_billing_plan_enabled(
&self,
plan_id: &str,
enabled: bool,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let result =
sqlx::query("UPDATE billing_plans SET enabled = ?, updated_at = ? WHERE id = ?")
.bind(enabled)
.bind(current_unix_secs_i64())
.bind(plan_id)
.execute(&self.pool)
.await
.map_sql_err()?;
if result.rows_affected() == 0 {
return Ok(AdminBillingMutationOutcome::NotFound);
}
match self.find_billing_plan(plan_id).await? {
Some(record) => Ok(AdminBillingMutationOutcome::Applied(record)),
None => Ok(AdminBillingMutationOutcome::NotFound),
}
}
async fn delete_billing_plan(
&self,
plan_id: &str,
) -> Result<AdminBillingMutationOutcome<()>, DataLayerError> {
let exists =
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM billing_plans WHERE id = ?")
.bind(plan_id)
.fetch_one(&self.pool)
.await
.map_sql_err()?;
if exists == 0 {
return Ok(AdminBillingMutationOutcome::NotFound);
}
let order_count = sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM payment_orders
WHERE product_id = ?
AND order_kind = 'plan_purchase'
"#,
)
.bind(plan_id)
.fetch_one(&self.pool)
.await
.map_sql_err()?;
let entitlement_count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM user_plan_entitlements WHERE plan_id = ?",
)
.bind(plan_id)
.fetch_one(&self.pool)
.await
.map_sql_err()?;
if order_count > 0 || entitlement_count > 0 {
return Ok(AdminBillingMutationOutcome::Invalid(
"套餐已有订单或权益,不能删除,请停用该套餐".to_string(),
));
}
let result = sqlx::query("DELETE FROM billing_plans WHERE id = ?")
.bind(plan_id)
.execute(&self.pool)
.await
.map_sql_err()?;
if result.rows_affected() == 0 {
Ok(AdminBillingMutationOutcome::NotFound)
} else {
Ok(AdminBillingMutationOutcome::Applied(()))
}
}
async fn list_user_plan_entitlements(
&self,
user_id: &str,
) -> Result<Option<Vec<UserPlanEntitlementRecord>>, DataLayerError> {
let rows = sqlx::query(
r#"
SELECT
id, user_id, plan_id, payment_order_id, status,
starts_at AS starts_at_unix_secs, expires_at AS expires_at_unix_secs,
entitlements_snapshot, created_at AS created_at_unix_secs,
updated_at AS updated_at_unix_secs
FROM user_plan_entitlements
WHERE user_id = ?
AND status = 'active'
AND expires_at > ?
ORDER BY expires_at ASC, created_at ASC
"#,
)
.bind(user_id)
.bind(current_unix_secs_i64())
.fetch_all(&self.pool)
.await
.map_sql_err()?;
Ok(Some(
rows.iter()
.map(map_user_plan_entitlement_mysql)
.collect::<Result<Vec<_>, _>>()?,
))
}
async fn find_user_daily_quota_availability(
&self,
user_id: &str,
) -> Result<Option<UserDailyQuotaAvailabilityRecord>, DataLayerError> {
let now_unix_secs = current_unix_secs_i64();
let rows = sqlx::query(
r#"
SELECT id, entitlements_snapshot
FROM user_plan_entitlements
WHERE user_id = ?
AND status = 'active'
AND starts_at <= ?
AND expires_at > ?
ORDER BY expires_at ASC, created_at ASC, id ASC
"#,
)
.bind(user_id)
.bind(now_unix_secs)
.bind(now_unix_secs)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
let now = chrono::Utc::now();
let mut grants = Vec::new();
for row in rows {
let entitlement_id: String = row.try_get("id").map_sql_err()?;
let entitlements = parse_json(row.try_get("entitlements_snapshot").ok().flatten())?
.unwrap_or_else(|| serde_json::json!([]));
grants.extend(daily_quota_grants_from_entitlement(
&entitlement_id,
&entitlements,
now,
)?);
}
let mut total_quota_usd = 0.0;
let mut used_usd = 0.0;
let mut remaining_usd = 0.0;
let mut allow_wallet_overage = true;
for grant in &grants {
allow_wallet_overage &= grant.allow_wallet_overage;
let used = sqlx::query_scalar::<_, f64>(
r#"
SELECT COALESCE(SUM(amount_usd), 0)
FROM entitlement_usage_ledgers
WHERE user_entitlement_id = ?
AND usage_date = ?
"#,
)
.bind(&grant.entitlement_id)
.bind(&grant.usage_date)
.fetch_one(&self.pool)
.await
.map_sql_err()?;
total_quota_usd += grant.daily_quota_usd;
used_usd += used.min(grant.daily_quota_usd).max(0.0);
remaining_usd += (grant.daily_quota_usd - used).max(0.0);
}
let has_active_daily_quota = !grants.is_empty();
Ok(Some(UserDailyQuotaAvailabilityRecord {
has_active_daily_quota,
total_quota_usd,
used_usd,
remaining_usd,
allow_wallet_overage: has_active_daily_quota && allow_wallet_overage,
}))
}
}
const BILLING_PLAN_INSERT_MYSQL: &str = r#"
INSERT INTO billing_plans (
id, title, description, price_amount, price_currency, duration_unit,
duration_value, enabled, sort_order, max_active_per_user, purchase_limit_scope,
entitlements_json,
created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#;
const BILLING_PLAN_UPDATE_MYSQL: &str = r#"
UPDATE billing_plans
SET title = ?,
description = ?,
price_amount = ?,
price_currency = ?,
duration_unit = ?,
duration_value = ?,
enabled = ?,
sort_order = ?,
max_active_per_user = ?,
purchase_limit_scope = ?,
entitlements_json = ?,
updated_at = ?
WHERE id = ?
"#;
struct RankedContext {
rank: u8,
is_available: bool,
@@ -751,10 +1154,153 @@ fn json_to_string(value: &serde_json::Value) -> Result<String, DataLayerError> {
})
}
#[derive(Debug)]
struct DailyQuotaGrant {
entitlement_id: String,
daily_quota_usd: f64,
usage_date: String,
allow_wallet_overage: bool,
}
fn daily_quota_usage_date(
reset_timezone: Option<&str>,
now: chrono::DateTime<chrono::Utc>,
) -> Result<String, DataLayerError> {
let timezone = reset_timezone
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("Asia/Shanghai")
.parse::<chrono_tz::Tz>()
.map_err(|err| DataLayerError::InvalidInput(format!("invalid reset_timezone: {err}")))?;
Ok(now.with_timezone(&timezone).date_naive().to_string())
}
fn daily_quota_grants_from_entitlement(
entitlement_id: &str,
entitlements: &serde_json::Value,
now: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<DailyQuotaGrant>, DataLayerError> {
let mut grants = Vec::new();
let Some(items) = entitlements.as_array() else {
return Ok(grants);
};
for item in items {
if item.get("type").and_then(serde_json::Value::as_str) != Some("daily_quota") {
continue;
}
let daily_quota_usd = item
.get("daily_quota_usd")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 {
continue;
}
grants.push(DailyQuotaGrant {
entitlement_id: entitlement_id.to_string(),
daily_quota_usd,
usage_date: daily_quota_usage_date(
item.get("reset_timezone")
.and_then(serde_json::Value::as_str),
now,
)?,
allow_wallet_overage: item
.get("allow_wallet_overage")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
});
}
Ok(grants)
}
fn read_count_mysql(row: &MySqlRow) -> Result<u64, DataLayerError> {
Ok(row.try_get::<i64, _>("total").map_sql_err()?.max(0) as u64)
}
fn map_payment_gateway_config_mysql(
row: &MySqlRow,
) -> Result<PaymentGatewayConfigRecord, DataLayerError> {
Ok(PaymentGatewayConfigRecord {
provider: row.try_get("provider").map_sql_err()?,
enabled: row.try_get("enabled").map_sql_err()?,
endpoint_url: row.try_get("endpoint_url").map_sql_err()?,
callback_base_url: row.try_get("callback_base_url").map_sql_err()?,
merchant_id: row.try_get("merchant_id").map_sql_err()?,
merchant_key_encrypted: row.try_get("merchant_key_encrypted").map_sql_err()?,
pay_currency: row.try_get("pay_currency").map_sql_err()?,
usd_exchange_rate: row.try_get("usd_exchange_rate").map_sql_err()?,
min_recharge_usd: row.try_get("min_recharge_usd").map_sql_err()?,
channels_json: parse_json(row.try_get("channels_json").ok().flatten())?
.unwrap_or_else(|| serde_json::json!([])),
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
})
}
fn map_billing_plan_mysql(row: &MySqlRow) -> Result<BillingPlanRecord, DataLayerError> {
Ok(BillingPlanRecord {
id: row.try_get("id").map_sql_err()?,
title: row.try_get("title").map_sql_err()?,
description: row.try_get("description").map_sql_err()?,
price_amount: row.try_get("price_amount").map_sql_err()?,
price_currency: row.try_get("price_currency").map_sql_err()?,
duration_unit: row.try_get("duration_unit").map_sql_err()?,
duration_value: row.try_get("duration_value").map_sql_err()?,
enabled: row.try_get("enabled").map_sql_err()?,
sort_order: row.try_get("sort_order").map_sql_err()?,
max_active_per_user: row.try_get("max_active_per_user").map_sql_err()?,
purchase_limit_scope: row
.try_get::<Option<String>, _>("purchase_limit_scope")
.map_sql_err()?
.unwrap_or_else(|| "active_period".to_string()),
entitlements_json: parse_json(row.try_get("entitlements_json").ok().flatten())?
.unwrap_or_else(|| serde_json::json!([])),
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
})
}
fn map_user_plan_entitlement_mysql(
row: &MySqlRow,
) -> Result<UserPlanEntitlementRecord, DataLayerError> {
Ok(UserPlanEntitlementRecord {
id: row.try_get("id").map_sql_err()?,
user_id: row.try_get("user_id").map_sql_err()?,
plan_id: row.try_get("plan_id").map_sql_err()?,
payment_order_id: row.try_get("payment_order_id").map_sql_err()?,
status: row.try_get("status").map_sql_err()?,
starts_at_unix_secs: row
.try_get::<i64, _>("starts_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
expires_at_unix_secs: row
.try_get::<i64, _>("expires_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
entitlements_snapshot: parse_json(row.try_get("entitlements_snapshot").ok().flatten())?
.unwrap_or_else(|| serde_json::json!([])),
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
})
}
async fn find_admin_billing_rule_mysql(
pool: &MysqlPool,
rule_id: &str,

View File

@@ -4,7 +4,9 @@ use sqlx::{PgPool, Row};
use super::{
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingMutationOutcome,
AdminBillingPresetApplyResult, AdminBillingRuleRecord, AdminBillingRuleWriteInput,
BillingReadRepository, StoredBillingModelContext,
BillingPlanRecord, BillingPlanWriteInput, BillingReadRepository, PaymentGatewayConfigRecord,
PaymentGatewayConfigWriteInput, StoredBillingModelContext, UserDailyQuotaAvailabilityRecord,
UserPlanEntitlementRecord,
};
use crate::{error::SqlxResultExt, DataLayerError};
@@ -687,8 +689,422 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW())
},
))
}
async fn find_payment_gateway_config(
&self,
provider: &str,
) -> Result<Option<PaymentGatewayConfigRecord>, DataLayerError> {
let row = sqlx::query(
r#"
SELECT
provider, enabled, endpoint_url, callback_base_url, merchant_id,
merchant_key_encrypted, pay_currency,
CAST(usd_exchange_rate AS DOUBLE PRECISION) AS usd_exchange_rate,
CAST(min_recharge_usd AS DOUBLE PRECISION) AS min_recharge_usd,
channels_json,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
FROM payment_gateway_configs
WHERE provider = $1
LIMIT 1
"#,
)
.bind(provider.trim().to_ascii_lowercase())
.fetch_optional(&self.pool)
.await
.map_postgres_err()?;
row.as_ref().map(map_payment_gateway_config_row).transpose()
}
async fn upsert_payment_gateway_config(
&self,
input: &PaymentGatewayConfigWriteInput,
) -> Result<AdminBillingMutationOutcome<PaymentGatewayConfigRecord>, DataLayerError> {
let provider = input.provider.trim().to_ascii_lowercase();
let row = sqlx::query(
r#"
INSERT INTO payment_gateway_configs (
provider, enabled, endpoint_url, callback_base_url, merchant_id,
merchant_key_encrypted, pay_currency, usd_exchange_rate, min_recharge_usd,
channels_json, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
ON CONFLICT (provider)
DO UPDATE SET
enabled = EXCLUDED.enabled,
endpoint_url = EXCLUDED.endpoint_url,
callback_base_url = EXCLUDED.callback_base_url,
merchant_id = EXCLUDED.merchant_id,
merchant_key_encrypted = CASE
WHEN $11::BOOL THEN payment_gateway_configs.merchant_key_encrypted
ELSE EXCLUDED.merchant_key_encrypted
END,
pay_currency = EXCLUDED.pay_currency,
usd_exchange_rate = EXCLUDED.usd_exchange_rate,
min_recharge_usd = EXCLUDED.min_recharge_usd,
channels_json = EXCLUDED.channels_json,
updated_at = NOW()
RETURNING
provider, enabled, endpoint_url, callback_base_url, merchant_id,
merchant_key_encrypted, pay_currency,
CAST(usd_exchange_rate AS DOUBLE PRECISION) AS usd_exchange_rate,
CAST(min_recharge_usd AS DOUBLE PRECISION) AS min_recharge_usd,
channels_json,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
"#,
)
.bind(&provider)
.bind(input.enabled)
.bind(&input.endpoint_url)
.bind(input.callback_base_url.as_deref())
.bind(&input.merchant_id)
.bind(input.merchant_key_encrypted.as_deref())
.bind(&input.pay_currency)
.bind(input.usd_exchange_rate)
.bind(input.min_recharge_usd)
.bind(&input.channels_json)
.bind(input.preserve_existing_secret)
.fetch_one(&self.pool)
.await
.map_postgres_err()?;
Ok(AdminBillingMutationOutcome::Applied(
map_payment_gateway_config_row(&row)?,
))
}
async fn list_billing_plans(
&self,
include_disabled: bool,
) -> Result<Option<Vec<BillingPlanRecord>>, DataLayerError> {
let rows = sqlx::query(
r#"
SELECT
id, title, description,
CAST(price_amount AS DOUBLE PRECISION) AS price_amount,
price_currency, duration_unit, duration_value, enabled, sort_order,
max_active_per_user, purchase_limit_scope, entitlements_json,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
FROM billing_plans
WHERE ($1::BOOL = TRUE OR enabled = TRUE)
ORDER BY sort_order ASC, price_amount ASC, id ASC
"#,
)
.bind(include_disabled)
.fetch_all(&self.pool)
.await
.map_postgres_err()?;
Ok(Some(
rows.iter()
.map(map_billing_plan_row)
.collect::<Result<Vec<_>, _>>()?,
))
}
async fn find_billing_plan(
&self,
plan_id: &str,
) -> Result<Option<BillingPlanRecord>, DataLayerError> {
let row = sqlx::query(
r#"
SELECT
id, title, description,
CAST(price_amount AS DOUBLE PRECISION) AS price_amount,
price_currency, duration_unit, duration_value, enabled, sort_order,
max_active_per_user, purchase_limit_scope, entitlements_json,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
FROM billing_plans
WHERE id = $1
LIMIT 1
"#,
)
.bind(plan_id)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?;
row.as_ref().map(map_billing_plan_row).transpose()
}
async fn create_billing_plan(
&self,
input: &BillingPlanWriteInput,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let id = uuid::Uuid::new_v4().to_string();
let row = sqlx::query(BILLING_PLAN_INSERT_RETURNING_SQL)
.bind(&id)
.bind(&input.title)
.bind(input.description.as_deref())
.bind(input.price_amount)
.bind(&input.price_currency)
.bind(&input.duration_unit)
.bind(input.duration_value)
.bind(input.enabled)
.bind(input.sort_order)
.bind(input.max_active_per_user)
.bind(&input.purchase_limit_scope)
.bind(&input.entitlements_json)
.fetch_one(&self.pool)
.await
.map_postgres_err()?;
Ok(AdminBillingMutationOutcome::Applied(map_billing_plan_row(
&row,
)?))
}
async fn update_billing_plan(
&self,
plan_id: &str,
input: &BillingPlanWriteInput,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let row = sqlx::query(BILLING_PLAN_UPDATE_RETURNING_SQL)
.bind(plan_id)
.bind(&input.title)
.bind(input.description.as_deref())
.bind(input.price_amount)
.bind(&input.price_currency)
.bind(&input.duration_unit)
.bind(input.duration_value)
.bind(input.enabled)
.bind(input.sort_order)
.bind(input.max_active_per_user)
.bind(&input.purchase_limit_scope)
.bind(&input.entitlements_json)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?;
match row {
Some(row) => Ok(AdminBillingMutationOutcome::Applied(map_billing_plan_row(
&row,
)?)),
None => Ok(AdminBillingMutationOutcome::NotFound),
}
}
async fn set_billing_plan_enabled(
&self,
plan_id: &str,
enabled: bool,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let row = sqlx::query(
r#"
UPDATE billing_plans
SET enabled = $2, updated_at = NOW()
WHERE id = $1
RETURNING
id, title, description,
CAST(price_amount AS DOUBLE PRECISION) AS price_amount,
price_currency, duration_unit, duration_value, enabled, sort_order,
max_active_per_user, purchase_limit_scope, entitlements_json,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
"#,
)
.bind(plan_id)
.bind(enabled)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?;
match row {
Some(row) => Ok(AdminBillingMutationOutcome::Applied(map_billing_plan_row(
&row,
)?)),
None => Ok(AdminBillingMutationOutcome::NotFound),
}
}
async fn delete_billing_plan(
&self,
plan_id: &str,
) -> Result<AdminBillingMutationOutcome<()>, DataLayerError> {
let exists = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*)::bigint FROM billing_plans WHERE id = $1",
)
.bind(plan_id)
.fetch_one(&self.pool)
.await
.map_postgres_err()?;
if exists == 0 {
return Ok(AdminBillingMutationOutcome::NotFound);
}
let order_count = sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)::bigint
FROM payment_orders
WHERE product_id = $1
AND order_kind = 'plan_purchase'
"#,
)
.bind(plan_id)
.fetch_one(&self.pool)
.await
.map_postgres_err()?;
let entitlement_count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*)::bigint FROM user_plan_entitlements WHERE plan_id = $1",
)
.bind(plan_id)
.fetch_one(&self.pool)
.await
.map_postgres_err()?;
if order_count > 0 || entitlement_count > 0 {
return Ok(AdminBillingMutationOutcome::Invalid(
"套餐已有订单或权益,不能删除,请停用该套餐".to_string(),
));
}
let result = sqlx::query("DELETE FROM billing_plans WHERE id = $1")
.bind(plan_id)
.execute(&self.pool)
.await
.map_postgres_err()?;
if result.rows_affected() == 0 {
Ok(AdminBillingMutationOutcome::NotFound)
} else {
Ok(AdminBillingMutationOutcome::Applied(()))
}
}
async fn list_user_plan_entitlements(
&self,
user_id: &str,
) -> Result<Option<Vec<UserPlanEntitlementRecord>>, DataLayerError> {
let rows = sqlx::query(
r#"
SELECT
id, user_id, plan_id, payment_order_id, status,
CAST(EXTRACT(EPOCH FROM starts_at) AS BIGINT) AS starts_at_unix_secs,
CAST(EXTRACT(EPOCH FROM expires_at) AS BIGINT) AS expires_at_unix_secs,
entitlements_snapshot,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
FROM user_plan_entitlements
WHERE user_id = $1
AND status = 'active'
AND expires_at > NOW()
ORDER BY expires_at ASC, created_at ASC
"#,
)
.bind(user_id)
.fetch_all(&self.pool)
.await
.map_postgres_err()?;
Ok(Some(
rows.iter()
.map(map_user_plan_entitlement_row)
.collect::<Result<Vec<_>, _>>()?,
))
}
async fn find_user_daily_quota_availability(
&self,
user_id: &str,
) -> Result<Option<UserDailyQuotaAvailabilityRecord>, DataLayerError> {
let rows = sqlx::query(
r#"
SELECT id, entitlements_snapshot
FROM user_plan_entitlements
WHERE user_id = $1
AND status = 'active'
AND starts_at <= NOW()
AND expires_at > NOW()
ORDER BY expires_at ASC, created_at ASC, id ASC
"#,
)
.bind(user_id)
.fetch_all(&self.pool)
.await
.map_postgres_err()?;
let now = chrono::Utc::now();
let mut grants = Vec::new();
for row in rows {
let entitlement_id: String = row.try_get("id").map_postgres_err()?;
let entitlements: serde_json::Value =
row.try_get("entitlements_snapshot").map_postgres_err()?;
grants.extend(daily_quota_grants_from_entitlement(
&entitlement_id,
&entitlements,
now,
)?);
}
let mut total_quota_usd = 0.0;
let mut used_usd = 0.0;
let mut remaining_usd = 0.0;
let mut allow_wallet_overage = true;
for grant in &grants {
allow_wallet_overage &= grant.allow_wallet_overage;
let used = sqlx::query_scalar::<_, Option<f64>>(
r#"
SELECT CAST(COALESCE(SUM(amount_usd), 0) AS DOUBLE PRECISION)
FROM entitlement_usage_ledgers
WHERE user_entitlement_id = $1
AND usage_date = $2
"#,
)
.bind(&grant.entitlement_id)
.bind(&grant.usage_date)
.fetch_one(&self.pool)
.await
.map_postgres_err()?
.unwrap_or(0.0);
total_quota_usd += grant.daily_quota_usd;
used_usd += used.min(grant.daily_quota_usd).max(0.0);
remaining_usd += (grant.daily_quota_usd - used).max(0.0);
}
let has_active_daily_quota = !grants.is_empty();
Ok(Some(UserDailyQuotaAvailabilityRecord {
has_active_daily_quota,
total_quota_usd,
used_usd,
remaining_usd,
allow_wallet_overage: has_active_daily_quota && allow_wallet_overage,
}))
}
}
const BILLING_PLAN_INSERT_RETURNING_SQL: &str = r#"
INSERT INTO billing_plans (
id, title, description, price_amount, price_currency, duration_unit,
duration_value, enabled, sort_order, max_active_per_user, purchase_limit_scope,
entitlements_json, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), NOW())
RETURNING
id, title, description,
CAST(price_amount AS DOUBLE PRECISION) AS price_amount,
price_currency, duration_unit, duration_value, enabled, sort_order,
max_active_per_user, purchase_limit_scope, entitlements_json,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
"#;
const BILLING_PLAN_UPDATE_RETURNING_SQL: &str = r#"
UPDATE billing_plans
SET
title = $2,
description = $3,
price_amount = $4,
price_currency = $5,
duration_unit = $6,
duration_value = $7,
enabled = $8,
sort_order = $9,
max_active_per_user = $10,
purchase_limit_scope = $11,
entitlements_json = $12,
updated_at = NOW()
WHERE id = $1
RETURNING
id, title, description,
CAST(price_amount AS DOUBLE PRECISION) AS price_amount,
price_currency, duration_unit, duration_value, enabled, sort_order,
max_active_per_user, purchase_limit_scope, entitlements_json,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
"#;
fn map_row(row: &sqlx::postgres::PgRow) -> Result<StoredBillingModelContext, DataLayerError> {
StoredBillingModelContext::new(
row.try_get("provider_id").map_postgres_err()?,
@@ -718,6 +1134,146 @@ fn read_count(row: sqlx::postgres::PgRow) -> Result<u64, DataLayerError> {
Ok(row.try_get::<i64, _>("total").map_postgres_err()?.max(0) as u64)
}
#[derive(Debug)]
struct DailyQuotaGrant {
entitlement_id: String,
daily_quota_usd: f64,
usage_date: String,
allow_wallet_overage: bool,
}
fn daily_quota_usage_date(
reset_timezone: Option<&str>,
now: chrono::DateTime<chrono::Utc>,
) -> Result<String, DataLayerError> {
let timezone = reset_timezone
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("Asia/Shanghai")
.parse::<chrono_tz::Tz>()
.map_err(|err| DataLayerError::InvalidInput(format!("invalid reset_timezone: {err}")))?;
Ok(now.with_timezone(&timezone).date_naive().to_string())
}
fn daily_quota_grants_from_entitlement(
entitlement_id: &str,
entitlements: &serde_json::Value,
now: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<DailyQuotaGrant>, DataLayerError> {
let mut grants = Vec::new();
let Some(items) = entitlements.as_array() else {
return Ok(grants);
};
for item in items {
if item.get("type").and_then(serde_json::Value::as_str) != Some("daily_quota") {
continue;
}
let daily_quota_usd = item
.get("daily_quota_usd")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 {
continue;
}
grants.push(DailyQuotaGrant {
entitlement_id: entitlement_id.to_string(),
daily_quota_usd,
usage_date: daily_quota_usage_date(
item.get("reset_timezone")
.and_then(serde_json::Value::as_str),
now,
)?,
allow_wallet_overage: item
.get("allow_wallet_overage")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
});
}
Ok(grants)
}
fn map_payment_gateway_config_row(
row: &sqlx::postgres::PgRow,
) -> Result<PaymentGatewayConfigRecord, DataLayerError> {
Ok(PaymentGatewayConfigRecord {
provider: row.try_get("provider").map_postgres_err()?,
enabled: row.try_get("enabled").map_postgres_err()?,
endpoint_url: row.try_get("endpoint_url").map_postgres_err()?,
callback_base_url: row.try_get("callback_base_url").map_postgres_err()?,
merchant_id: row.try_get("merchant_id").map_postgres_err()?,
merchant_key_encrypted: row.try_get("merchant_key_encrypted").map_postgres_err()?,
pay_currency: row.try_get("pay_currency").map_postgres_err()?,
usd_exchange_rate: row.try_get("usd_exchange_rate").map_postgres_err()?,
min_recharge_usd: row.try_get("min_recharge_usd").map_postgres_err()?,
channels_json: row
.try_get::<Option<serde_json::Value>, _>("channels_json")
.map_postgres_err()?
.unwrap_or_else(|| serde_json::json!([])),
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_postgres_err()?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_postgres_err()?
.max(0) as u64,
})
}
fn map_billing_plan_row(row: &sqlx::postgres::PgRow) -> Result<BillingPlanRecord, DataLayerError> {
Ok(BillingPlanRecord {
id: row.try_get("id").map_postgres_err()?,
title: row.try_get("title").map_postgres_err()?,
description: row.try_get("description").map_postgres_err()?,
price_amount: row.try_get("price_amount").map_postgres_err()?,
price_currency: row.try_get("price_currency").map_postgres_err()?,
duration_unit: row.try_get("duration_unit").map_postgres_err()?,
duration_value: row.try_get("duration_value").map_postgres_err()?,
enabled: row.try_get("enabled").map_postgres_err()?,
sort_order: row.try_get("sort_order").map_postgres_err()?,
max_active_per_user: row.try_get("max_active_per_user").map_postgres_err()?,
purchase_limit_scope: row.try_get("purchase_limit_scope").map_postgres_err()?,
entitlements_json: row.try_get("entitlements_json").map_postgres_err()?,
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_postgres_err()?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_postgres_err()?
.max(0) as u64,
})
}
fn map_user_plan_entitlement_row(
row: &sqlx::postgres::PgRow,
) -> Result<UserPlanEntitlementRecord, DataLayerError> {
Ok(UserPlanEntitlementRecord {
id: row.try_get("id").map_postgres_err()?,
user_id: row.try_get("user_id").map_postgres_err()?,
plan_id: row.try_get("plan_id").map_postgres_err()?,
payment_order_id: row.try_get("payment_order_id").map_postgres_err()?,
status: row.try_get("status").map_postgres_err()?,
starts_at_unix_secs: row
.try_get::<i64, _>("starts_at_unix_secs")
.map_postgres_err()?
.max(0) as u64,
expires_at_unix_secs: row
.try_get::<i64, _>("expires_at_unix_secs")
.map_postgres_err()?
.max(0) as u64,
entitlements_snapshot: row.try_get("entitlements_snapshot").map_postgres_err()?,
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_postgres_err()?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_postgres_err()?
.max(0) as u64,
})
}
fn map_admin_billing_rule_row(
row: &sqlx::postgres::PgRow,
) -> Result<AdminBillingRuleRecord, DataLayerError> {

View File

@@ -4,7 +4,9 @@ use sqlx::{sqlite::SqliteRow, Row};
use super::{
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingMutationOutcome,
AdminBillingPresetApplyResult, AdminBillingRuleRecord, AdminBillingRuleWriteInput,
BillingReadRepository, StoredBillingModelContext,
BillingPlanRecord, BillingPlanWriteInput, BillingReadRepository, PaymentGatewayConfigRecord,
PaymentGatewayConfigWriteInput, StoredBillingModelContext, UserDailyQuotaAvailabilityRecord,
UserPlanEntitlementRecord,
};
use crate::driver::sqlite::{sqlite_optional_real, SqlitePool};
use crate::error::SqlResultExt;
@@ -609,8 +611,409 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
},
))
}
async fn find_payment_gateway_config(
&self,
provider: &str,
) -> Result<Option<PaymentGatewayConfigRecord>, DataLayerError> {
let row = sqlx::query(
r#"
SELECT
provider, enabled, endpoint_url, callback_base_url, merchant_id,
merchant_key_encrypted, pay_currency, usd_exchange_rate, min_recharge_usd,
channels_json, created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM payment_gateway_configs
WHERE provider = ?
LIMIT 1
"#,
)
.bind(provider.trim().to_ascii_lowercase())
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
row.as_ref()
.map(map_payment_gateway_config_sqlite)
.transpose()
}
async fn upsert_payment_gateway_config(
&self,
input: &PaymentGatewayConfigWriteInput,
) -> Result<AdminBillingMutationOutcome<PaymentGatewayConfigRecord>, DataLayerError> {
let provider = input.provider.trim().to_ascii_lowercase();
let existing_secret = if input.preserve_existing_secret {
sqlx::query_scalar::<_, String>(
"SELECT merchant_key_encrypted FROM payment_gateway_configs WHERE provider = ?",
)
.bind(&provider)
.fetch_optional(&self.pool)
.await
.map_sql_err()?
} else {
None
};
let secret = if input.preserve_existing_secret {
existing_secret
} else {
input.merchant_key_encrypted.clone()
};
let now = current_unix_secs_i64();
sqlx::query(
r#"
INSERT INTO payment_gateway_configs (
provider, enabled, endpoint_url, callback_base_url, merchant_id,
merchant_key_encrypted, pay_currency, usd_exchange_rate, min_recharge_usd,
channels_json, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(provider) DO UPDATE SET
enabled = excluded.enabled,
endpoint_url = excluded.endpoint_url,
callback_base_url = excluded.callback_base_url,
merchant_id = excluded.merchant_id,
merchant_key_encrypted = excluded.merchant_key_encrypted,
pay_currency = excluded.pay_currency,
usd_exchange_rate = excluded.usd_exchange_rate,
min_recharge_usd = excluded.min_recharge_usd,
channels_json = excluded.channels_json,
updated_at = excluded.updated_at
"#,
)
.bind(&provider)
.bind(input.enabled)
.bind(&input.endpoint_url)
.bind(input.callback_base_url.as_deref())
.bind(&input.merchant_id)
.bind(secret.as_deref())
.bind(&input.pay_currency)
.bind(input.usd_exchange_rate)
.bind(input.min_recharge_usd)
.bind(json_to_string(&input.channels_json)?)
.bind(now)
.bind(now)
.execute(&self.pool)
.await
.map_sql_err()?;
match self.find_payment_gateway_config(&provider).await? {
Some(record) => Ok(AdminBillingMutationOutcome::Applied(record)),
None => Err(DataLayerError::UnexpectedValue(
"upserted payment gateway config missing".to_string(),
)),
}
}
async fn list_billing_plans(
&self,
include_disabled: bool,
) -> Result<Option<Vec<BillingPlanRecord>>, DataLayerError> {
let rows = sqlx::query(
r#"
SELECT
id, title, description, price_amount, price_currency, duration_unit,
duration_value, enabled, sort_order, max_active_per_user, purchase_limit_scope,
entitlements_json,
created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM billing_plans
WHERE (? = 1 OR enabled = 1)
ORDER BY sort_order ASC, price_amount ASC, id ASC
"#,
)
.bind(include_disabled)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
Ok(Some(
rows.iter()
.map(map_billing_plan_sqlite)
.collect::<Result<Vec<_>, _>>()?,
))
}
async fn find_billing_plan(
&self,
plan_id: &str,
) -> Result<Option<BillingPlanRecord>, DataLayerError> {
let row = sqlx::query(
r#"
SELECT
id, title, description, price_amount, price_currency, duration_unit,
duration_value, enabled, sort_order, max_active_per_user, purchase_limit_scope,
entitlements_json,
created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM billing_plans
WHERE id = ?
LIMIT 1
"#,
)
.bind(plan_id)
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
row.as_ref().map(map_billing_plan_sqlite).transpose()
}
async fn create_billing_plan(
&self,
input: &BillingPlanWriteInput,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let id = uuid::Uuid::new_v4().to_string();
let now = current_unix_secs_i64();
sqlx::query(BILLING_PLAN_INSERT_SQLITE)
.bind(&id)
.bind(&input.title)
.bind(input.description.as_deref())
.bind(input.price_amount)
.bind(&input.price_currency)
.bind(&input.duration_unit)
.bind(input.duration_value)
.bind(input.enabled)
.bind(input.sort_order)
.bind(input.max_active_per_user)
.bind(&input.purchase_limit_scope)
.bind(json_to_string(&input.entitlements_json)?)
.bind(now)
.bind(now)
.execute(&self.pool)
.await
.map_sql_err()?;
match self.find_billing_plan(&id).await? {
Some(record) => Ok(AdminBillingMutationOutcome::Applied(record)),
None => Err(DataLayerError::UnexpectedValue(
"created billing plan missing".to_string(),
)),
}
}
async fn update_billing_plan(
&self,
plan_id: &str,
input: &BillingPlanWriteInput,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let result = sqlx::query(BILLING_PLAN_UPDATE_SQLITE)
.bind(&input.title)
.bind(input.description.as_deref())
.bind(input.price_amount)
.bind(&input.price_currency)
.bind(&input.duration_unit)
.bind(input.duration_value)
.bind(input.enabled)
.bind(input.sort_order)
.bind(input.max_active_per_user)
.bind(&input.purchase_limit_scope)
.bind(json_to_string(&input.entitlements_json)?)
.bind(current_unix_secs_i64())
.bind(plan_id)
.execute(&self.pool)
.await
.map_sql_err()?;
if result.rows_affected() == 0 {
return Ok(AdminBillingMutationOutcome::NotFound);
}
match self.find_billing_plan(plan_id).await? {
Some(record) => Ok(AdminBillingMutationOutcome::Applied(record)),
None => Ok(AdminBillingMutationOutcome::NotFound),
}
}
async fn set_billing_plan_enabled(
&self,
plan_id: &str,
enabled: bool,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let result =
sqlx::query("UPDATE billing_plans SET enabled = ?, updated_at = ? WHERE id = ?")
.bind(enabled)
.bind(current_unix_secs_i64())
.bind(plan_id)
.execute(&self.pool)
.await
.map_sql_err()?;
if result.rows_affected() == 0 {
return Ok(AdminBillingMutationOutcome::NotFound);
}
match self.find_billing_plan(plan_id).await? {
Some(record) => Ok(AdminBillingMutationOutcome::Applied(record)),
None => Ok(AdminBillingMutationOutcome::NotFound),
}
}
async fn delete_billing_plan(
&self,
plan_id: &str,
) -> Result<AdminBillingMutationOutcome<()>, DataLayerError> {
let exists =
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM billing_plans WHERE id = ?")
.bind(plan_id)
.fetch_one(&self.pool)
.await
.map_sql_err()?;
if exists == 0 {
return Ok(AdminBillingMutationOutcome::NotFound);
}
let order_count = sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM payment_orders
WHERE product_id = ?
AND order_kind = 'plan_purchase'
"#,
)
.bind(plan_id)
.fetch_one(&self.pool)
.await
.map_sql_err()?;
let entitlement_count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM user_plan_entitlements WHERE plan_id = ?",
)
.bind(plan_id)
.fetch_one(&self.pool)
.await
.map_sql_err()?;
if order_count > 0 || entitlement_count > 0 {
return Ok(AdminBillingMutationOutcome::Invalid(
"套餐已有订单或权益,不能删除,请停用该套餐".to_string(),
));
}
let result = sqlx::query("DELETE FROM billing_plans WHERE id = ?")
.bind(plan_id)
.execute(&self.pool)
.await
.map_sql_err()?;
if result.rows_affected() == 0 {
Ok(AdminBillingMutationOutcome::NotFound)
} else {
Ok(AdminBillingMutationOutcome::Applied(()))
}
}
async fn list_user_plan_entitlements(
&self,
user_id: &str,
) -> Result<Option<Vec<UserPlanEntitlementRecord>>, DataLayerError> {
let rows = sqlx::query(
r#"
SELECT
id, user_id, plan_id, payment_order_id, status,
starts_at AS starts_at_unix_secs, expires_at AS expires_at_unix_secs,
entitlements_snapshot, created_at AS created_at_unix_secs,
updated_at AS updated_at_unix_secs
FROM user_plan_entitlements
WHERE user_id = ?
AND status = 'active'
AND expires_at > ?
ORDER BY expires_at ASC, created_at ASC
"#,
)
.bind(user_id)
.bind(current_unix_secs_i64())
.fetch_all(&self.pool)
.await
.map_sql_err()?;
Ok(Some(
rows.iter()
.map(map_user_plan_entitlement_sqlite)
.collect::<Result<Vec<_>, _>>()?,
))
}
async fn find_user_daily_quota_availability(
&self,
user_id: &str,
) -> Result<Option<UserDailyQuotaAvailabilityRecord>, DataLayerError> {
let now_unix_secs = current_unix_secs_i64();
let rows = sqlx::query(
r#"
SELECT id, entitlements_snapshot
FROM user_plan_entitlements
WHERE user_id = ?
AND status = 'active'
AND starts_at <= ?
AND expires_at > ?
ORDER BY expires_at ASC, created_at ASC, id ASC
"#,
)
.bind(user_id)
.bind(now_unix_secs)
.bind(now_unix_secs)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
let now = chrono::Utc::now();
let mut grants = Vec::new();
for row in rows {
let entitlement_id: String = row.try_get("id").map_sql_err()?;
let entitlements = parse_json(row.try_get("entitlements_snapshot").ok().flatten())?
.unwrap_or_else(|| serde_json::json!([]));
grants.extend(daily_quota_grants_from_entitlement(
&entitlement_id,
&entitlements,
now,
)?);
}
let mut total_quota_usd = 0.0;
let mut used_usd = 0.0;
let mut remaining_usd = 0.0;
let mut allow_wallet_overage = true;
for grant in &grants {
allow_wallet_overage &= grant.allow_wallet_overage;
let used = sqlx::query_scalar::<_, f64>(
r#"
SELECT COALESCE(SUM(amount_usd), 0)
FROM entitlement_usage_ledgers
WHERE user_entitlement_id = ?
AND usage_date = ?
"#,
)
.bind(&grant.entitlement_id)
.bind(&grant.usage_date)
.fetch_one(&self.pool)
.await
.map_sql_err()?;
total_quota_usd += grant.daily_quota_usd;
used_usd += used.min(grant.daily_quota_usd).max(0.0);
remaining_usd += (grant.daily_quota_usd - used).max(0.0);
}
let has_active_daily_quota = !grants.is_empty();
Ok(Some(UserDailyQuotaAvailabilityRecord {
has_active_daily_quota,
total_quota_usd,
used_usd,
remaining_usd,
allow_wallet_overage: has_active_daily_quota && allow_wallet_overage,
}))
}
}
const BILLING_PLAN_INSERT_SQLITE: &str = r#"
INSERT INTO billing_plans (
id, title, description, price_amount, price_currency, duration_unit,
duration_value, enabled, sort_order, max_active_per_user, purchase_limit_scope,
entitlements_json,
created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#;
const BILLING_PLAN_UPDATE_SQLITE: &str = r#"
UPDATE billing_plans
SET title = ?,
description = ?,
price_amount = ?,
price_currency = ?,
duration_unit = ?,
duration_value = ?,
enabled = ?,
sort_order = ?,
max_active_per_user = ?,
purchase_limit_scope = ?,
entitlements_json = ?,
updated_at = ?
WHERE id = ?
"#;
struct RankedContext {
rank: u8,
is_available: bool,
@@ -745,10 +1148,153 @@ fn json_to_string(value: &serde_json::Value) -> Result<String, DataLayerError> {
})
}
#[derive(Debug)]
struct DailyQuotaGrant {
entitlement_id: String,
daily_quota_usd: f64,
usage_date: String,
allow_wallet_overage: bool,
}
fn daily_quota_usage_date(
reset_timezone: Option<&str>,
now: chrono::DateTime<chrono::Utc>,
) -> Result<String, DataLayerError> {
let timezone = reset_timezone
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("Asia/Shanghai")
.parse::<chrono_tz::Tz>()
.map_err(|err| DataLayerError::InvalidInput(format!("invalid reset_timezone: {err}")))?;
Ok(now.with_timezone(&timezone).date_naive().to_string())
}
fn daily_quota_grants_from_entitlement(
entitlement_id: &str,
entitlements: &serde_json::Value,
now: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<DailyQuotaGrant>, DataLayerError> {
let mut grants = Vec::new();
let Some(items) = entitlements.as_array() else {
return Ok(grants);
};
for item in items {
if item.get("type").and_then(serde_json::Value::as_str) != Some("daily_quota") {
continue;
}
let daily_quota_usd = item
.get("daily_quota_usd")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 {
continue;
}
grants.push(DailyQuotaGrant {
entitlement_id: entitlement_id.to_string(),
daily_quota_usd,
usage_date: daily_quota_usage_date(
item.get("reset_timezone")
.and_then(serde_json::Value::as_str),
now,
)?,
allow_wallet_overage: item
.get("allow_wallet_overage")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
});
}
Ok(grants)
}
fn read_count_sqlite(row: &SqliteRow) -> Result<u64, DataLayerError> {
Ok(row.try_get::<i64, _>("total").map_sql_err()?.max(0) as u64)
}
fn map_payment_gateway_config_sqlite(
row: &SqliteRow,
) -> Result<PaymentGatewayConfigRecord, DataLayerError> {
Ok(PaymentGatewayConfigRecord {
provider: row.try_get("provider").map_sql_err()?,
enabled: row.try_get("enabled").map_sql_err()?,
endpoint_url: row.try_get("endpoint_url").map_sql_err()?,
callback_base_url: row.try_get("callback_base_url").map_sql_err()?,
merchant_id: row.try_get("merchant_id").map_sql_err()?,
merchant_key_encrypted: row.try_get("merchant_key_encrypted").map_sql_err()?,
pay_currency: row.try_get("pay_currency").map_sql_err()?,
usd_exchange_rate: sqlite_optional_real(row, "usd_exchange_rate")?.unwrap_or(0.0),
min_recharge_usd: sqlite_optional_real(row, "min_recharge_usd")?.unwrap_or(0.0),
channels_json: parse_json(row.try_get("channels_json").ok().flatten())?
.unwrap_or_else(|| serde_json::json!([])),
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
})
}
fn map_billing_plan_sqlite(row: &SqliteRow) -> Result<BillingPlanRecord, DataLayerError> {
Ok(BillingPlanRecord {
id: row.try_get("id").map_sql_err()?,
title: row.try_get("title").map_sql_err()?,
description: row.try_get("description").map_sql_err()?,
price_amount: sqlite_optional_real(row, "price_amount")?.unwrap_or(0.0),
price_currency: row.try_get("price_currency").map_sql_err()?,
duration_unit: row.try_get("duration_unit").map_sql_err()?,
duration_value: row.try_get("duration_value").map_sql_err()?,
enabled: row.try_get("enabled").map_sql_err()?,
sort_order: row.try_get("sort_order").map_sql_err()?,
max_active_per_user: row.try_get("max_active_per_user").map_sql_err()?,
purchase_limit_scope: row
.try_get::<Option<String>, _>("purchase_limit_scope")
.map_sql_err()?
.unwrap_or_else(|| "active_period".to_string()),
entitlements_json: parse_json(row.try_get("entitlements_json").ok().flatten())?
.unwrap_or_else(|| serde_json::json!([])),
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
})
}
fn map_user_plan_entitlement_sqlite(
row: &SqliteRow,
) -> Result<UserPlanEntitlementRecord, DataLayerError> {
Ok(UserPlanEntitlementRecord {
id: row.try_get("id").map_sql_err()?,
user_id: row.try_get("user_id").map_sql_err()?,
plan_id: row.try_get("plan_id").map_sql_err()?,
payment_order_id: row.try_get("payment_order_id").map_sql_err()?,
status: row.try_get("status").map_sql_err()?,
starts_at_unix_secs: row
.try_get::<i64, _>("starts_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
expires_at_unix_secs: row
.try_get::<i64, _>("expires_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
entitlements_snapshot: parse_json(row.try_get("entitlements_snapshot").ok().flatten())?
.unwrap_or_else(|| serde_json::json!([])),
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
})
}
async fn find_admin_billing_rule_sqlite(
pool: &SqlitePool,
rule_id: &str,
@@ -857,7 +1403,7 @@ mod tests {
use crate::lifecycle::migrate::run_sqlite_migrations;
use crate::repository::billing::{
AdminBillingCollectorWriteInput, AdminBillingMutationOutcome, AdminBillingRuleWriteInput,
BillingReadRepository,
BillingPlanWriteInput, BillingReadRepository,
};
#[tokio::test]
@@ -1010,6 +1556,96 @@ mod tests {
assert_eq!(preset.errors, Vec::<String>::new());
}
#[tokio::test]
async fn sqlite_repository_deletes_unused_billing_plans_only() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_sqlite_migrations(&pool)
.await
.expect("sqlite migrations should run");
let repository = SqliteBillingReadRepository::new(pool.clone());
let input = BillingPlanWriteInput {
title: "Daily Plan".to_string(),
description: None,
price_amount: 100.0,
price_currency: "CNY".to_string(),
duration_unit: "month".to_string(),
duration_value: 1,
enabled: true,
sort_order: 10,
max_active_per_user: 1,
purchase_limit_scope: "active_period".to_string(),
entitlements_json: json!([{
"type": "daily_quota",
"daily_quota_usd": 50.0,
"reset_timezone": "Asia/Shanghai",
"allow_wallet_overage": false
}]),
};
let removable = match repository
.create_billing_plan(&input)
.await
.expect("plan create should run")
{
AdminBillingMutationOutcome::Applied(plan) => plan,
other => panic!("unexpected plan create outcome: {other:?}"),
};
assert_eq!(
repository
.delete_billing_plan(&removable.id)
.await
.expect("plan delete should run"),
AdminBillingMutationOutcome::Applied(())
);
assert!(repository
.find_billing_plan(&removable.id)
.await
.expect("plan lookup should run")
.is_none());
let referenced = match repository
.create_billing_plan(&input)
.await
.expect("plan create should run")
{
AdminBillingMutationOutcome::Applied(plan) => plan,
other => panic!("unexpected plan create outcome: {other:?}"),
};
sqlx::query(
r#"
INSERT INTO payment_orders (
id, order_no, wallet_id, amount_usd, payment_method, order_kind,
product_id, fulfillment_status, status, created_at
)
VALUES ('order-1', 'order-no-1', 'wallet-1', 0, 'epay', 'plan_purchase',
?, 'pending', 'pending', 1)
"#,
)
.bind(&referenced.id)
.execute(&pool)
.await
.expect("payment order should seed");
match repository
.delete_billing_plan(&referenced.id)
.await
.expect("plan delete should run")
{
AdminBillingMutationOutcome::Invalid(detail) => {
assert!(detail.contains("不能删除"));
}
other => panic!("unexpected referenced plan delete outcome: {other:?}"),
}
assert!(repository
.find_billing_plan(&referenced.id)
.await
.expect("plan lookup should run")
.is_some());
}
async fn seed_billing_context(pool: &sqlx::SqlitePool) {
sqlx::query(
r#"

View File

@@ -3,7 +3,10 @@ use std::sync::{Arc, RwLock};
use async_trait::async_trait;
use super::{SettlementWriteRepository, StoredUsageSettlement, UsageSettlementInput};
use super::{
plan_finite_wallet_debit, SettlementWriteRepository, StoredUsageSettlement,
UsageSettlementInput, SETTLEMENT_EPSILON_USD,
};
use crate::repository::wallet::{InMemoryWalletRepository, StoredWalletSnapshot};
use crate::DataLayerError;
@@ -99,10 +102,10 @@ impl SettlementWriteRepository for InMemorySettlementRepository {
})));
}
let final_billing_status = if input.status == "completed" {
"settled"
let mut final_billing_status = if input.status == "completed" {
"settled".to_string()
} else {
"void"
"void".to_string()
};
let mut settlement = self.wallets.with_mut(|wallets| {
let wallet_id = input
@@ -156,17 +159,31 @@ impl SettlementWriteRepository for InMemorySettlementRepository {
if wallet.limit_mode.eq_ignore_ascii_case("unlimited") {
wallet.total_consumed += input.total_cost_usd;
} else {
let gift_deduction = before_gift.max(0.0).min(input.total_cost_usd);
let recharge_deduction = input.total_cost_usd - gift_deduction;
wallet.gift_balance = before_gift - gift_deduction;
wallet.balance = before_recharge - recharge_deduction;
wallet.total_consumed += input.total_cost_usd;
let debit_plan = plan_finite_wallet_debit(
before_recharge,
before_gift,
input.total_cost_usd,
);
if debit_plan.covered_usd() + SETTLEMENT_EPSILON_USD < input.total_cost_usd
{
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
} else {
wallet.balance = before_recharge - debit_plan.recharge_deduction;
wallet.gift_balance = before_gift - debit_plan.gift_deduction;
wallet.total_consumed += input.total_cost_usd;
}
}
}
settlement.wallet_recharge_balance_after = Some(wallet.balance);
settlement.wallet_gift_balance_after = Some(wallet.gift_balance);
settlement.wallet_balance_after = Some(wallet.balance + wallet.gift_balance);
} else if final_billing_status == "settled"
&& input.total_cost_usd > SETTLEMENT_EPSILON_USD
{
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
}
settlement
@@ -312,11 +329,40 @@ mod tests {
.expect("settlement should succeed")
.expect("settlement should exist");
assert_eq!(settlement.billing_status, "insufficient_quota");
assert_eq!(settlement.wallet_id, None);
assert_eq!(settlement.wallet_balance_before, None);
assert_eq!(settlement.wallet_balance_after, None);
}
#[tokio::test]
async fn finite_wallet_insufficient_balance_does_not_overdraw() {
let repository = InMemorySettlementRepository::seed(vec![sample_wallet()]);
let settlement = repository
.settle_usage(UsageSettlementInput {
request_id: "req-insufficient-wallet".to_string(),
user_id: Some("user-1".to_string()),
api_key_id: Some("key-1".to_string()),
api_key_is_standalone: false,
provider_id: Some("provider-1".to_string()),
status: "completed".to_string(),
billing_status: "pending".to_string(),
total_cost_usd: 15.0,
actual_total_cost_usd: 7.5,
finalized_at_unix_secs: Some(200),
})
.await
.expect("settlement should succeed")
.expect("settlement should exist");
assert_eq!(settlement.billing_status, "insufficient_quota");
assert_eq!(settlement.wallet_balance_before, Some(12.0));
assert_eq!(settlement.wallet_balance_after, Some(12.0));
assert_eq!(settlement.wallet_recharge_balance_after, Some(10.0));
assert_eq!(settlement.wallet_gift_balance_after, Some(2.0));
assert_eq!(settlement.provider_monthly_used_usd, None);
}
#[tokio::test]
async fn returns_stored_snapshot_when_usage_is_already_finalized() {
let repository = InMemorySettlementRepository::seed(vec![sample_wallet()]);

View File

@@ -3,6 +3,39 @@ mod mysql;
mod postgres;
mod sqlite;
const SETTLEMENT_EPSILON_USD: f64 = 0.000_000_01;
#[derive(Debug, Clone, Copy)]
struct WalletDebitPlan {
recharge_deduction: f64,
gift_deduction: f64,
}
impl WalletDebitPlan {
fn covered_usd(self) -> f64 {
self.recharge_deduction + self.gift_deduction
}
}
fn finite_wallet_available_usd(recharge_balance: f64, gift_balance: f64) -> f64 {
recharge_balance.max(0.0) + gift_balance.max(0.0)
}
fn plan_finite_wallet_debit(
recharge_balance: f64,
gift_balance: f64,
requested_usd: f64,
) -> WalletDebitPlan {
let recharge_deduction = recharge_balance.max(0.0).min(requested_usd.max(0.0));
let gift_deduction = gift_balance
.max(0.0)
.min((requested_usd - recharge_deduction).max(0.0));
WalletDebitPlan {
recharge_deduction,
gift_deduction,
}
}
#[allow(unused_imports)]
pub(crate) use aether_data_contracts::repository::settlement::{
SettlementRepository, SettlementWriteRepository, StoredUsageSettlement, UsageSettlementInput,

View File

@@ -1,7 +1,10 @@
use async_trait::async_trait;
use sqlx::{mysql::MySqlRow, Row};
use super::{SettlementWriteRepository, StoredUsageSettlement, UsageSettlementInput};
use super::{
finite_wallet_available_usd, plan_finite_wallet_debit, SettlementWriteRepository,
StoredUsageSettlement, UsageSettlementInput, SETTLEMENT_EPSILON_USD,
};
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
use crate::DataLayerError;
@@ -133,6 +136,197 @@ fn now_unix_secs() -> Result<i64, DataLayerError> {
.map_err(|_| DataLayerError::InvalidInput("timestamp overflow".to_string()))
}
#[derive(Debug, Default)]
struct DailyQuotaDebitResult {
debited_usd: f64,
insufficient: bool,
}
#[derive(Debug)]
struct DailyQuotaGrant {
entitlement_id: String,
daily_quota_usd: f64,
usage_date: String,
allow_wallet_overage: bool,
}
fn daily_quota_usage_date(
reset_timezone: Option<&str>,
now: chrono::DateTime<chrono::Utc>,
) -> Result<String, DataLayerError> {
let timezone = reset_timezone
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("Asia/Shanghai")
.parse::<chrono_tz::Tz>()
.map_err(|err| DataLayerError::InvalidInput(format!("invalid reset_timezone: {err}")))?;
Ok(now.with_timezone(&timezone).date_naive().to_string())
}
fn daily_quota_grants_from_entitlement(
entitlement_id: &str,
entitlements: &serde_json::Value,
now: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<DailyQuotaGrant>, DataLayerError> {
let mut grants = Vec::new();
let Some(items) = entitlements.as_array() else {
return Ok(grants);
};
for item in items {
if item.get("type").and_then(serde_json::Value::as_str) != Some("daily_quota") {
continue;
}
let daily_quota_usd = item
.get("daily_quota_usd")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 {
continue;
}
grants.push(DailyQuotaGrant {
entitlement_id: entitlement_id.to_string(),
daily_quota_usd,
usage_date: daily_quota_usage_date(
item.get("reset_timezone")
.and_then(serde_json::Value::as_str),
now,
)?,
allow_wallet_overage: item
.get("allow_wallet_overage")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
});
}
Ok(grants)
}
async fn consume_daily_quota_mysql(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
user_id: &str,
request_id: &str,
total_cost_usd: f64,
wallet_available_usd: Option<f64>,
now_unix_secs: i64,
) -> Result<DailyQuotaDebitResult, DataLayerError> {
if total_cost_usd <= 0.0 {
return Ok(DailyQuotaDebitResult::default());
}
let rows = sqlx::query(
r#"
SELECT id, entitlements_snapshot
FROM user_plan_entitlements
WHERE user_id = ?
AND status = 'active'
AND starts_at <= ?
AND expires_at > ?
ORDER BY expires_at ASC, created_at ASC, id ASC
FOR UPDATE
"#,
)
.bind(user_id)
.bind(now_unix_secs)
.bind(now_unix_secs)
.fetch_all(&mut **tx)
.await
.map_sql_err()?;
let now = chrono::Utc::now();
let mut grants = Vec::new();
for row in rows {
let entitlement_id: String = row.try_get("id").map_sql_err()?;
let entitlements_raw: String = row.try_get("entitlements_snapshot").map_sql_err()?;
let entitlements =
serde_json::from_str::<serde_json::Value>(&entitlements_raw).map_err(|err| {
DataLayerError::UnexpectedValue(format!(
"user_plan_entitlements.entitlements_snapshot invalid json: {err}"
))
})?;
grants.extend(daily_quota_grants_from_entitlement(
&entitlement_id,
&entitlements,
now,
)?);
}
if grants.is_empty() {
return Ok(DailyQuotaDebitResult::default());
}
let mut grants_with_remaining = Vec::new();
let mut total_remaining = 0.0;
let mut allow_wallet_overage = true;
for grant in grants {
allow_wallet_overage &= grant.allow_wallet_overage;
let used = sqlx::query_scalar::<_, f64>(
r#"
SELECT COALESCE(SUM(amount_usd), 0)
FROM entitlement_usage_ledgers
WHERE user_entitlement_id = ?
AND usage_date = ?
"#,
)
.bind(&grant.entitlement_id)
.bind(&grant.usage_date)
.fetch_one(&mut **tx)
.await
.map_sql_err()?;
let remaining = (grant.daily_quota_usd - used).max(0.0);
total_remaining += remaining;
grants_with_remaining.push((grant, remaining));
}
if !allow_wallet_overage && total_remaining + 0.000_000_01 < total_cost_usd {
return Ok(DailyQuotaDebitResult {
debited_usd: 0.0,
insufficient: true,
});
}
if allow_wallet_overage
&& wallet_available_usd.is_some_and(|available| {
total_remaining + available + SETTLEMENT_EPSILON_USD < total_cost_usd
})
{
return Ok(DailyQuotaDebitResult {
debited_usd: 0.0,
insufficient: true,
});
}
let mut remaining_cost = total_cost_usd;
let mut debited = 0.0;
for (grant, balance_before) in grants_with_remaining {
if remaining_cost <= 0.000_000_01 || balance_before <= 0.0 {
continue;
}
let amount = remaining_cost.min(balance_before);
let balance_after = balance_before - amount;
sqlx::query(
r#"
INSERT IGNORE INTO entitlement_usage_ledgers (
id, user_entitlement_id, user_id, request_id, amount_usd,
balance_before, balance_after, usage_date, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&grant.entitlement_id)
.bind(user_id)
.bind(request_id)
.bind(amount)
.bind(balance_before)
.bind(balance_after)
.bind(&grant.usage_date)
.bind(now_unix_secs)
.execute(&mut **tx)
.await
.map_sql_err()?;
remaining_cost -= amount;
debited += amount;
}
Ok(DailyQuotaDebitResult {
debited_usd: debited,
insufficient: false,
})
}
#[async_trait]
impl SettlementWriteRepository for MysqlSettlementRepository {
async fn settle_usage(
@@ -161,21 +355,24 @@ impl SettlementWriteRepository for MysqlSettlementRepository {
};
let current_billing_status: String = usage_row.try_get("billing_status").map_sql_err()?;
if current_billing_status == "settled" || current_billing_status == "void" {
if matches!(
current_billing_status.as_str(),
"settled" | "void" | "insufficient_quota"
) {
let settlement = settlement_from_row(&usage_row)?;
tx.commit().await.map_sql_err()?;
return Ok(Some(settlement));
}
let final_billing_status = if input.status == "completed" {
"settled"
let mut final_billing_status = if input.status == "completed" {
"settled".to_string()
} else {
"void"
"void".to_string()
};
let mut settlement = StoredUsageSettlement {
request_id: input.request_id.clone(),
wallet_id: None,
billing_status: final_billing_status.to_string(),
billing_status: final_billing_status.clone(),
wallet_balance_before: None,
wallet_balance_after: None,
wallet_recharge_balance_before: None,
@@ -253,22 +450,101 @@ FOR UPDATE
None
};
if let Some(wallet_row) = wallet_row {
let wallet_id: String = wallet_row.try_get("id").map_sql_err()?;
let before_recharge: f64 = wallet_row.try_get("balance").map_sql_err()?;
let before_gift: f64 = wallet_row.try_get("gift_balance").map_sql_err()?;
let limit_mode: String = wallet_row.try_get("limit_mode").map_sql_err()?;
let before_total = before_recharge + before_gift;
let mut after_recharge = before_recharge;
let mut after_gift = before_gift;
if !limit_mode.eq_ignore_ascii_case("unlimited") {
let gift_deduction = before_gift.max(0.0).min(input.total_cost_usd);
let recharge_deduction = input.total_cost_usd - gift_deduction;
after_gift = before_gift - gift_deduction;
after_recharge = before_recharge - recharge_deduction;
let wallet_available_usd = match wallet_row.as_ref() {
Some(row) => {
let limit_mode: String = row.try_get("limit_mode").map_sql_err()?;
if limit_mode.eq_ignore_ascii_case("unlimited") {
None
} else {
Some(finite_wallet_available_usd(
row.try_get("balance").map_sql_err()?,
row.try_get("gift_balance").map_sql_err()?,
))
}
}
sqlx::query(
r#"
None => Some(0.0),
};
let wallet_debit_cost_usd = if !api_key_is_standalone {
if let Some(user_id) = input.user_id.as_deref().filter(|value| !value.is_empty()) {
let quota = consume_daily_quota_mysql(
&mut tx,
user_id,
&input.request_id,
input.total_cost_usd,
wallet_available_usd,
updated_at,
)
.await?;
if quota.insufficient {
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
0.0
} else {
(input.total_cost_usd - quota.debited_usd).max(0.0)
}
} else {
input.total_cost_usd
}
} else {
input.total_cost_usd
};
if final_billing_status != "settled" {
sqlx::query(UPSERT_USAGE_SETTLEMENT_SNAPSHOT_SQL)
.bind(&settlement.request_id)
.bind(&settlement.billing_status)
.bind(settlement.wallet_id.as_deref())
.bind(settlement.wallet_balance_before)
.bind(settlement.wallet_balance_after)
.bind(settlement.wallet_recharge_balance_before)
.bind(settlement.wallet_recharge_balance_after)
.bind(settlement.wallet_gift_balance_before)
.bind(settlement.wallet_gift_balance_after)
.bind(settlement.provider_monthly_used_usd)
.bind(settlement.finalized_at_unix_secs.map(|value| value as i64))
.bind(updated_at)
.bind(updated_at)
.execute(&mut *tx)
.await
.map_sql_err()?;
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
.bind(&final_billing_status)
.bind(finalized_at)
.bind(&input.request_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
tx.commit().await.map_sql_err()?;
return Ok(Some(settlement));
}
if wallet_debit_cost_usd > SETTLEMENT_EPSILON_USD {
if let Some(wallet_row) = wallet_row {
let wallet_id: String = wallet_row.try_get("id").map_sql_err()?;
let before_recharge: f64 = wallet_row.try_get("balance").map_sql_err()?;
let before_gift: f64 = wallet_row.try_get("gift_balance").map_sql_err()?;
let limit_mode: String = wallet_row.try_get("limit_mode").map_sql_err()?;
let before_total = before_recharge + before_gift;
let mut after_recharge = before_recharge;
let mut after_gift = before_gift;
if !limit_mode.eq_ignore_ascii_case("unlimited") {
let debit_plan = plan_finite_wallet_debit(
before_recharge,
before_gift,
wallet_debit_cost_usd,
);
if debit_plan.covered_usd() + SETTLEMENT_EPSILON_USD < wallet_debit_cost_usd
{
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
} else {
after_recharge = before_recharge - debit_plan.recharge_deduction;
after_gift = before_gift - debit_plan.gift_deduction;
}
}
if final_billing_status == "settled" {
sqlx::query(
r#"
UPDATE wallets
SET
balance = ?,
@@ -277,23 +553,57 @@ SET
updated_at = ?
WHERE id = ?
"#,
)
.bind(after_recharge)
.bind(after_gift)
.bind(input.total_cost_usd)
.bind(updated_at)
.bind(&wallet_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
)
.bind(after_recharge)
.bind(after_gift)
.bind(wallet_debit_cost_usd)
.bind(updated_at)
.bind(&wallet_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
}
settlement.wallet_id = Some(wallet_id);
settlement.wallet_balance_before = Some(before_total);
settlement.wallet_balance_after = Some(after_recharge + after_gift);
settlement.wallet_recharge_balance_before = Some(before_recharge);
settlement.wallet_recharge_balance_after = Some(after_recharge);
settlement.wallet_gift_balance_before = Some(before_gift);
settlement.wallet_gift_balance_after = Some(after_gift);
settlement.wallet_id = Some(wallet_id);
settlement.wallet_balance_before = Some(before_total);
settlement.wallet_balance_after = Some(after_recharge + after_gift);
settlement.wallet_recharge_balance_before = Some(before_recharge);
settlement.wallet_recharge_balance_after = Some(after_recharge);
settlement.wallet_gift_balance_before = Some(before_gift);
settlement.wallet_gift_balance_after = Some(after_gift);
} else {
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
}
}
if final_billing_status != "settled" {
sqlx::query(UPSERT_USAGE_SETTLEMENT_SNAPSHOT_SQL)
.bind(&settlement.request_id)
.bind(&settlement.billing_status)
.bind(settlement.wallet_id.as_deref())
.bind(settlement.wallet_balance_before)
.bind(settlement.wallet_balance_after)
.bind(settlement.wallet_recharge_balance_before)
.bind(settlement.wallet_recharge_balance_after)
.bind(settlement.wallet_gift_balance_before)
.bind(settlement.wallet_gift_balance_after)
.bind(settlement.provider_monthly_used_usd)
.bind(settlement.finalized_at_unix_secs.map(|value| value as i64))
.bind(updated_at)
.bind(updated_at)
.execute(&mut *tx)
.await
.map_sql_err()?;
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
.bind(&final_billing_status)
.bind(finalized_at)
.bind(&input.request_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
tx.commit().await.map_sql_err()?;
return Ok(Some(settlement));
}
if let Some(provider_id) = input
@@ -347,7 +657,7 @@ WHERE id = ?
.map_sql_err()?;
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
.bind(final_billing_status)
.bind(&final_billing_status)
.bind(finalized_at)
.bind(&input.request_id)
.execute(&mut *tx)

View File

@@ -1,7 +1,10 @@
use async_trait::async_trait;
use sqlx::{PgPool, Row};
use super::{SettlementWriteRepository, StoredUsageSettlement, UsageSettlementInput};
use super::{
finite_wallet_available_usd, plan_finite_wallet_debit, SettlementWriteRepository,
StoredUsageSettlement, UsageSettlementInput, SETTLEMENT_EPSILON_USD,
};
use crate::driver::postgres::PostgresTransactionRunner;
use crate::error::SqlxResultExt;
use crate::DataLayerError;
@@ -190,6 +193,192 @@ where
Ok(())
}
#[derive(Debug, Default)]
struct DailyQuotaDebitResult {
debited_usd: f64,
insufficient: bool,
}
#[derive(Debug)]
struct DailyQuotaGrant {
entitlement_id: String,
daily_quota_usd: f64,
usage_date: String,
allow_wallet_overage: bool,
}
fn daily_quota_usage_date(
reset_timezone: Option<&str>,
now: chrono::DateTime<chrono::Utc>,
) -> Result<String, DataLayerError> {
let timezone = reset_timezone
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("Asia/Shanghai")
.parse::<chrono_tz::Tz>()
.map_err(|err| DataLayerError::InvalidInput(format!("invalid reset_timezone: {err}")))?;
Ok(now.with_timezone(&timezone).date_naive().to_string())
}
fn daily_quota_grants_from_entitlement(
entitlement_id: &str,
entitlements: &serde_json::Value,
now: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<DailyQuotaGrant>, DataLayerError> {
let mut grants = Vec::new();
let Some(items) = entitlements.as_array() else {
return Ok(grants);
};
for item in items {
if item.get("type").and_then(serde_json::Value::as_str) != Some("daily_quota") {
continue;
}
let daily_quota_usd = item
.get("daily_quota_usd")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 {
continue;
}
let usage_date = daily_quota_usage_date(
item.get("reset_timezone")
.and_then(serde_json::Value::as_str),
now,
)?;
grants.push(DailyQuotaGrant {
entitlement_id: entitlement_id.to_string(),
daily_quota_usd,
usage_date,
allow_wallet_overage: item
.get("allow_wallet_overage")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
});
}
Ok(grants)
}
async fn consume_daily_quota_postgres(
tx: &mut crate::driver::postgres::PostgresTransaction,
user_id: &str,
request_id: &str,
total_cost_usd: f64,
wallet_available_usd: Option<f64>,
) -> Result<DailyQuotaDebitResult, DataLayerError> {
if total_cost_usd <= 0.0 {
return Ok(DailyQuotaDebitResult::default());
}
let now = chrono::Utc::now();
let entitlement_rows = sqlx::query(
r#"
SELECT id, entitlements_snapshot
FROM user_plan_entitlements
WHERE user_id = $1
AND status = 'active'
AND starts_at <= NOW()
AND expires_at > NOW()
ORDER BY expires_at ASC, created_at ASC, id ASC
FOR UPDATE
"#,
)
.bind(user_id)
.fetch_all(&mut **tx)
.await
.map_postgres_err()?;
let mut grants = Vec::new();
for row in entitlement_rows {
let entitlement_id: String = row.try_get("id").map_postgres_err()?;
let entitlements: serde_json::Value =
row.try_get("entitlements_snapshot").map_postgres_err()?;
grants.extend(daily_quota_grants_from_entitlement(
&entitlement_id,
&entitlements,
now,
)?);
}
if grants.is_empty() {
return Ok(DailyQuotaDebitResult::default());
}
let mut grants_with_remaining = Vec::new();
let mut total_remaining = 0.0;
let mut allow_wallet_overage = true;
for grant in grants {
allow_wallet_overage &= grant.allow_wallet_overage;
let used = sqlx::query_scalar::<_, Option<f64>>(
r#"
SELECT CAST(COALESCE(SUM(amount_usd), 0) AS DOUBLE PRECISION)
FROM entitlement_usage_ledgers
WHERE user_entitlement_id = $1
AND usage_date = $2
"#,
)
.bind(&grant.entitlement_id)
.bind(&grant.usage_date)
.fetch_one(&mut **tx)
.await
.map_postgres_err()?
.unwrap_or(0.0);
let remaining = (grant.daily_quota_usd - used).max(0.0);
total_remaining += remaining;
grants_with_remaining.push((grant, remaining));
}
if !allow_wallet_overage && total_remaining + 0.000_000_01 < total_cost_usd {
return Ok(DailyQuotaDebitResult {
debited_usd: 0.0,
insufficient: true,
});
}
if allow_wallet_overage
&& wallet_available_usd.is_some_and(|available| {
total_remaining + available + SETTLEMENT_EPSILON_USD < total_cost_usd
})
{
return Ok(DailyQuotaDebitResult {
debited_usd: 0.0,
insufficient: true,
});
}
let mut remaining_cost = total_cost_usd;
let mut debited = 0.0;
for (grant, balance_before) in grants_with_remaining {
if remaining_cost <= 0.000_000_01 || balance_before <= 0.0 {
continue;
}
let amount = remaining_cost.min(balance_before);
let balance_after = balance_before - amount;
sqlx::query(
r#"
INSERT INTO entitlement_usage_ledgers (
id, user_entitlement_id, user_id, request_id, amount_usd,
balance_before, balance_after, usage_date, created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
ON CONFLICT (user_entitlement_id, request_id) DO NOTHING
"#,
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&grant.entitlement_id)
.bind(user_id)
.bind(request_id)
.bind(amount)
.bind(balance_before)
.bind(balance_after)
.bind(&grant.usage_date)
.execute(&mut **tx)
.await
.map_postgres_err()?;
remaining_cost -= amount;
debited += amount;
}
Ok(DailyQuotaDebitResult {
debited_usd: debited,
insufficient: false,
})
}
#[async_trait]
impl SettlementWriteRepository for SqlxSettlementRepository {
async fn settle_usage(
@@ -212,14 +401,17 @@ impl SettlementWriteRepository for SqlxSettlementRepository {
let current_billing_status: String =
usage_row.try_get("billing_status").map_postgres_err()?;
if current_billing_status == "settled" || current_billing_status == "void" {
if matches!(
current_billing_status.as_str(),
"settled" | "void" | "insufficient_quota"
) {
return settlement_from_row(&usage_row).map(Some);
}
let final_billing_status = if input.status == "completed" {
"settled"
let mut final_billing_status = if input.status == "completed" {
"settled".to_string()
} else {
"void"
"void".to_string()
};
let finalized_at =
i64::try_from(input.finalized_at_unix_secs.unwrap_or_else(|| {
@@ -323,25 +515,92 @@ LIMIT 1
None
};
if let Some(wallet_row) = wallet_row {
let wallet_id: String = wallet_row.try_get("id").map_postgres_err()?;
let before_recharge: f64 =
wallet_row.try_get("balance").map_postgres_err()?;
let before_gift: f64 =
wallet_row.try_get("gift_balance").map_postgres_err()?;
let limit_mode: String =
wallet_row.try_get("limit_mode").map_postgres_err()?;
let before_total = before_recharge + before_gift;
let mut after_recharge = before_recharge;
let mut after_gift = before_gift;
if !limit_mode.eq_ignore_ascii_case("unlimited") {
let gift_deduction = before_gift.max(0.0).min(input.total_cost_usd);
let recharge_deduction = input.total_cost_usd - gift_deduction;
after_gift = before_gift - gift_deduction;
after_recharge = before_recharge - recharge_deduction;
let wallet_available_usd = match wallet_row.as_ref() {
Some(row) => {
let limit_mode: String =
row.try_get("limit_mode").map_postgres_err()?;
if limit_mode.eq_ignore_ascii_case("unlimited") {
None
} else {
Some(finite_wallet_available_usd(
row.try_get("balance").map_postgres_err()?,
row.try_get("gift_balance").map_postgres_err()?,
))
}
}
sqlx::query(
r#"
None => Some(0.0),
};
let wallet_debit_cost_usd = if !api_key_is_standalone {
if let Some(user_id) =
input.user_id.as_deref().filter(|value| !value.is_empty())
{
let quota = consume_daily_quota_postgres(
tx,
user_id,
&input.request_id,
input.total_cost_usd,
wallet_available_usd,
)
.await?;
if quota.insufficient {
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
0.0
} else {
(input.total_cost_usd - quota.debited_usd).max(0.0)
}
} else {
input.total_cost_usd
}
} else {
input.total_cost_usd
};
if final_billing_status != "settled" {
sync_usage_settlement_snapshot(&mut **tx, &settlement).await?;
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
.bind(&input.request_id)
.bind(&final_billing_status)
.bind(finalized_at)
.execute(&mut **tx)
.await
.map_postgres_err()?;
return Ok(Some(settlement));
}
if wallet_debit_cost_usd > SETTLEMENT_EPSILON_USD {
if let Some(wallet_row) = wallet_row {
let wallet_id: String =
wallet_row.try_get("id").map_postgres_err()?;
let before_recharge: f64 =
wallet_row.try_get("balance").map_postgres_err()?;
let before_gift: f64 =
wallet_row.try_get("gift_balance").map_postgres_err()?;
let limit_mode: String =
wallet_row.try_get("limit_mode").map_postgres_err()?;
let before_total = before_recharge + before_gift;
let mut after_recharge = before_recharge;
let mut after_gift = before_gift;
if !limit_mode.eq_ignore_ascii_case("unlimited") {
let debit_plan = plan_finite_wallet_debit(
before_recharge,
before_gift,
wallet_debit_cost_usd,
);
if debit_plan.covered_usd() + SETTLEMENT_EPSILON_USD
< wallet_debit_cost_usd
{
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
} else {
after_recharge =
before_recharge - debit_plan.recharge_deduction;
after_gift = before_gift - debit_plan.gift_deduction;
}
}
if final_billing_status == "settled" {
sqlx::query(
r#"
UPDATE wallets
SET
balance = $2,
@@ -350,22 +609,39 @@ SET
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(&wallet_id)
.bind(after_recharge)
.bind(after_gift)
.bind(input.total_cost_usd)
.execute(&mut **tx)
.await
.map_postgres_err()?;
)
.bind(&wallet_id)
.bind(after_recharge)
.bind(after_gift)
.bind(wallet_debit_cost_usd)
.execute(&mut **tx)
.await
.map_postgres_err()?;
}
settlement.wallet_id = Some(wallet_id.clone());
settlement.wallet_balance_before = Some(before_total);
settlement.wallet_balance_after = Some(after_recharge + after_gift);
settlement.wallet_recharge_balance_before = Some(before_recharge);
settlement.wallet_recharge_balance_after = Some(after_recharge);
settlement.wallet_gift_balance_before = Some(before_gift);
settlement.wallet_gift_balance_after = Some(after_gift);
settlement.wallet_id = Some(wallet_id.clone());
settlement.wallet_balance_before = Some(before_total);
settlement.wallet_balance_after = Some(after_recharge + after_gift);
settlement.wallet_recharge_balance_before = Some(before_recharge);
settlement.wallet_recharge_balance_after = Some(after_recharge);
settlement.wallet_gift_balance_before = Some(before_gift);
settlement.wallet_gift_balance_after = Some(after_gift);
} else {
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
}
}
if final_billing_status != "settled" {
sync_usage_settlement_snapshot(&mut **tx, &settlement).await?;
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
.bind(&input.request_id)
.bind(&final_billing_status)
.bind(finalized_at)
.execute(&mut **tx)
.await
.map_postgres_err()?;
return Ok(Some(settlement));
}
if let Some(provider_id) = input
@@ -396,7 +672,7 @@ RETURNING CAST(monthly_used_usd AS DOUBLE PRECISION) AS monthly_used_usd
sync_usage_settlement_snapshot(&mut **tx, &settlement).await?;
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
.bind(&input.request_id)
.bind(final_billing_status)
.bind(&final_billing_status)
.bind(finalized_at)
.execute(&mut **tx)
.await

View File

@@ -1,7 +1,10 @@
use async_trait::async_trait;
use sqlx::{sqlite::SqliteRow, Row};
use super::{SettlementWriteRepository, StoredUsageSettlement, UsageSettlementInput};
use super::{
finite_wallet_available_usd, plan_finite_wallet_debit, SettlementWriteRepository,
StoredUsageSettlement, UsageSettlementInput, SETTLEMENT_EPSILON_USD,
};
use crate::driver::sqlite::{sqlite_optional_real, sqlite_real, SqlitePool};
use crate::error::SqlResultExt;
use crate::DataLayerError;
@@ -147,6 +150,196 @@ fn now_unix_secs() -> Result<i64, DataLayerError> {
.map_err(|_| DataLayerError::InvalidInput("timestamp overflow".to_string()))
}
#[derive(Debug, Default)]
struct DailyQuotaDebitResult {
debited_usd: f64,
insufficient: bool,
}
#[derive(Debug)]
struct DailyQuotaGrant {
entitlement_id: String,
daily_quota_usd: f64,
usage_date: String,
allow_wallet_overage: bool,
}
fn daily_quota_usage_date(
reset_timezone: Option<&str>,
now: chrono::DateTime<chrono::Utc>,
) -> Result<String, DataLayerError> {
let timezone = reset_timezone
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("Asia/Shanghai")
.parse::<chrono_tz::Tz>()
.map_err(|err| DataLayerError::InvalidInput(format!("invalid reset_timezone: {err}")))?;
Ok(now.with_timezone(&timezone).date_naive().to_string())
}
fn daily_quota_grants_from_entitlement(
entitlement_id: &str,
entitlements: &serde_json::Value,
now: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<DailyQuotaGrant>, DataLayerError> {
let mut grants = Vec::new();
let Some(items) = entitlements.as_array() else {
return Ok(grants);
};
for item in items {
if item.get("type").and_then(serde_json::Value::as_str) != Some("daily_quota") {
continue;
}
let daily_quota_usd = item
.get("daily_quota_usd")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 {
continue;
}
grants.push(DailyQuotaGrant {
entitlement_id: entitlement_id.to_string(),
daily_quota_usd,
usage_date: daily_quota_usage_date(
item.get("reset_timezone")
.and_then(serde_json::Value::as_str),
now,
)?,
allow_wallet_overage: item
.get("allow_wallet_overage")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
});
}
Ok(grants)
}
async fn consume_daily_quota_sqlite(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
user_id: &str,
request_id: &str,
total_cost_usd: f64,
wallet_available_usd: Option<f64>,
now_unix_secs: i64,
) -> Result<DailyQuotaDebitResult, DataLayerError> {
if total_cost_usd <= 0.0 {
return Ok(DailyQuotaDebitResult::default());
}
let rows = sqlx::query(
r#"
SELECT id, entitlements_snapshot
FROM user_plan_entitlements
WHERE user_id = ?
AND status = 'active'
AND starts_at <= ?
AND expires_at > ?
ORDER BY expires_at ASC, created_at ASC, id ASC
"#,
)
.bind(user_id)
.bind(now_unix_secs)
.bind(now_unix_secs)
.fetch_all(&mut **tx)
.await
.map_sql_err()?;
let now = chrono::Utc::now();
let mut grants = Vec::new();
for row in rows {
let entitlement_id: String = row.try_get("id").map_sql_err()?;
let entitlements_raw: String = row.try_get("entitlements_snapshot").map_sql_err()?;
let entitlements =
serde_json::from_str::<serde_json::Value>(&entitlements_raw).map_err(|err| {
DataLayerError::UnexpectedValue(format!(
"user_plan_entitlements.entitlements_snapshot invalid json: {err}"
))
})?;
grants.extend(daily_quota_grants_from_entitlement(
&entitlement_id,
&entitlements,
now,
)?);
}
if grants.is_empty() {
return Ok(DailyQuotaDebitResult::default());
}
let mut grants_with_remaining = Vec::new();
let mut total_remaining = 0.0;
let mut allow_wallet_overage = true;
for grant in grants {
allow_wallet_overage &= grant.allow_wallet_overage;
let used = sqlx::query_scalar::<_, f64>(
r#"
SELECT COALESCE(SUM(amount_usd), 0)
FROM entitlement_usage_ledgers
WHERE user_entitlement_id = ?
AND usage_date = ?
"#,
)
.bind(&grant.entitlement_id)
.bind(&grant.usage_date)
.fetch_one(&mut **tx)
.await
.map_sql_err()?;
let remaining = (grant.daily_quota_usd - used).max(0.0);
total_remaining += remaining;
grants_with_remaining.push((grant, remaining));
}
if !allow_wallet_overage && total_remaining + 0.000_000_01 < total_cost_usd {
return Ok(DailyQuotaDebitResult {
debited_usd: 0.0,
insufficient: true,
});
}
if allow_wallet_overage
&& wallet_available_usd.is_some_and(|available| {
total_remaining + available + SETTLEMENT_EPSILON_USD < total_cost_usd
})
{
return Ok(DailyQuotaDebitResult {
debited_usd: 0.0,
insufficient: true,
});
}
let mut remaining_cost = total_cost_usd;
let mut debited = 0.0;
for (grant, balance_before) in grants_with_remaining {
if remaining_cost <= 0.000_000_01 || balance_before <= 0.0 {
continue;
}
let amount = remaining_cost.min(balance_before);
let balance_after = balance_before - amount;
sqlx::query(
r#"
INSERT OR IGNORE INTO entitlement_usage_ledgers (
id, user_entitlement_id, user_id, request_id, amount_usd,
balance_before, balance_after, usage_date, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&grant.entitlement_id)
.bind(user_id)
.bind(request_id)
.bind(amount)
.bind(balance_before)
.bind(balance_after)
.bind(&grant.usage_date)
.bind(now_unix_secs)
.execute(&mut **tx)
.await
.map_sql_err()?;
remaining_cost -= amount;
debited += amount;
}
Ok(DailyQuotaDebitResult {
debited_usd: debited,
insufficient: false,
})
}
#[async_trait]
impl SettlementWriteRepository for SqliteSettlementRepository {
async fn settle_usage(
@@ -175,21 +368,24 @@ impl SettlementWriteRepository for SqliteSettlementRepository {
};
let current_billing_status: String = usage_row.try_get("billing_status").map_sql_err()?;
if current_billing_status == "settled" || current_billing_status == "void" {
if matches!(
current_billing_status.as_str(),
"settled" | "void" | "insufficient_quota"
) {
let settlement = settlement_from_row(&usage_row)?;
tx.commit().await.map_sql_err()?;
return Ok(Some(settlement));
}
let final_billing_status = if input.status == "completed" {
"settled"
let mut final_billing_status = if input.status == "completed" {
"settled".to_string()
} else {
"void"
"void".to_string()
};
let mut settlement = StoredUsageSettlement {
request_id: input.request_id.clone(),
wallet_id: None,
billing_status: final_billing_status.to_string(),
billing_status: final_billing_status.clone(),
wallet_balance_before: None,
wallet_balance_after: None,
wallet_recharge_balance_before: None,
@@ -265,22 +461,101 @@ LIMIT 1
None
};
if let Some(wallet_row) = wallet_row {
let wallet_id: String = wallet_row.try_get("id").map_sql_err()?;
let before_recharge = sqlite_real(&wallet_row, "balance")?;
let before_gift = sqlite_real(&wallet_row, "gift_balance")?;
let limit_mode: String = wallet_row.try_get("limit_mode").map_sql_err()?;
let before_total = before_recharge + before_gift;
let mut after_recharge = before_recharge;
let mut after_gift = before_gift;
if !limit_mode.eq_ignore_ascii_case("unlimited") {
let gift_deduction = before_gift.max(0.0).min(input.total_cost_usd);
let recharge_deduction = input.total_cost_usd - gift_deduction;
after_gift = before_gift - gift_deduction;
after_recharge = before_recharge - recharge_deduction;
let wallet_available_usd = match wallet_row.as_ref() {
Some(row) => {
let limit_mode: String = row.try_get("limit_mode").map_sql_err()?;
if limit_mode.eq_ignore_ascii_case("unlimited") {
None
} else {
Some(finite_wallet_available_usd(
sqlite_real(row, "balance")?,
sqlite_real(row, "gift_balance")?,
))
}
}
sqlx::query(
r#"
None => Some(0.0),
};
let wallet_debit_cost_usd = if !api_key_is_standalone {
if let Some(user_id) = input.user_id.as_deref().filter(|value| !value.is_empty()) {
let quota = consume_daily_quota_sqlite(
&mut tx,
user_id,
&input.request_id,
input.total_cost_usd,
wallet_available_usd,
updated_at,
)
.await?;
if quota.insufficient {
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
0.0
} else {
(input.total_cost_usd - quota.debited_usd).max(0.0)
}
} else {
input.total_cost_usd
}
} else {
input.total_cost_usd
};
if final_billing_status != "settled" {
sqlx::query(UPSERT_USAGE_SETTLEMENT_SNAPSHOT_SQL)
.bind(&settlement.request_id)
.bind(&settlement.billing_status)
.bind(settlement.wallet_id.as_deref())
.bind(settlement.wallet_balance_before)
.bind(settlement.wallet_balance_after)
.bind(settlement.wallet_recharge_balance_before)
.bind(settlement.wallet_recharge_balance_after)
.bind(settlement.wallet_gift_balance_before)
.bind(settlement.wallet_gift_balance_after)
.bind(settlement.provider_monthly_used_usd)
.bind(settlement.finalized_at_unix_secs.map(|value| value as i64))
.bind(updated_at)
.bind(updated_at)
.execute(&mut *tx)
.await
.map_sql_err()?;
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
.bind(&final_billing_status)
.bind(finalized_at)
.bind(&input.request_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
tx.commit().await.map_sql_err()?;
return Ok(Some(settlement));
}
if wallet_debit_cost_usd > SETTLEMENT_EPSILON_USD {
if let Some(wallet_row) = wallet_row {
let wallet_id: String = wallet_row.try_get("id").map_sql_err()?;
let before_recharge = sqlite_real(&wallet_row, "balance")?;
let before_gift = sqlite_real(&wallet_row, "gift_balance")?;
let limit_mode: String = wallet_row.try_get("limit_mode").map_sql_err()?;
let before_total = before_recharge + before_gift;
let mut after_recharge = before_recharge;
let mut after_gift = before_gift;
if !limit_mode.eq_ignore_ascii_case("unlimited") {
let debit_plan = plan_finite_wallet_debit(
before_recharge,
before_gift,
wallet_debit_cost_usd,
);
if debit_plan.covered_usd() + SETTLEMENT_EPSILON_USD < wallet_debit_cost_usd
{
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
} else {
after_recharge = before_recharge - debit_plan.recharge_deduction;
after_gift = before_gift - debit_plan.gift_deduction;
}
}
if final_billing_status == "settled" {
sqlx::query(
r#"
UPDATE wallets
SET
balance = ?,
@@ -289,23 +564,57 @@ SET
updated_at = ?
WHERE id = ?
"#,
)
.bind(after_recharge)
.bind(after_gift)
.bind(input.total_cost_usd)
.bind(updated_at)
.bind(&wallet_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
)
.bind(after_recharge)
.bind(after_gift)
.bind(wallet_debit_cost_usd)
.bind(updated_at)
.bind(&wallet_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
}
settlement.wallet_id = Some(wallet_id);
settlement.wallet_balance_before = Some(before_total);
settlement.wallet_balance_after = Some(after_recharge + after_gift);
settlement.wallet_recharge_balance_before = Some(before_recharge);
settlement.wallet_recharge_balance_after = Some(after_recharge);
settlement.wallet_gift_balance_before = Some(before_gift);
settlement.wallet_gift_balance_after = Some(after_gift);
settlement.wallet_id = Some(wallet_id);
settlement.wallet_balance_before = Some(before_total);
settlement.wallet_balance_after = Some(after_recharge + after_gift);
settlement.wallet_recharge_balance_before = Some(before_recharge);
settlement.wallet_recharge_balance_after = Some(after_recharge);
settlement.wallet_gift_balance_before = Some(before_gift);
settlement.wallet_gift_balance_after = Some(after_gift);
} else {
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
}
}
if final_billing_status != "settled" {
sqlx::query(UPSERT_USAGE_SETTLEMENT_SNAPSHOT_SQL)
.bind(&settlement.request_id)
.bind(&settlement.billing_status)
.bind(settlement.wallet_id.as_deref())
.bind(settlement.wallet_balance_before)
.bind(settlement.wallet_balance_after)
.bind(settlement.wallet_recharge_balance_before)
.bind(settlement.wallet_recharge_balance_after)
.bind(settlement.wallet_gift_balance_before)
.bind(settlement.wallet_gift_balance_after)
.bind(settlement.provider_monthly_used_usd)
.bind(settlement.finalized_at_unix_secs.map(|value| value as i64))
.bind(updated_at)
.bind(updated_at)
.execute(&mut *tx)
.await
.map_sql_err()?;
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
.bind(&final_billing_status)
.bind(finalized_at)
.bind(&input.request_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
tx.commit().await.map_sql_err()?;
return Ok(Some(settlement));
}
if let Some(provider_id) = input
@@ -360,7 +669,7 @@ WHERE id = ?
.map_sql_err()?;
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
.bind(final_billing_status)
.bind(&final_billing_status)
.bind(finalized_at)
.bind(&input.request_id)
.execute(&mut *tx)
@@ -413,8 +722,8 @@ mod tests {
assert_eq!(settlement.wallet_id.as_deref(), Some("wallet-1"));
assert_eq!(settlement.wallet_balance_before, Some(12.0));
assert_eq!(settlement.wallet_balance_after, Some(9.0));
assert_eq!(settlement.wallet_recharge_balance_after, Some(9.0));
assert_eq!(settlement.wallet_gift_balance_after, Some(0.0));
assert_eq!(settlement.wallet_recharge_balance_after, Some(7.0));
assert_eq!(settlement.wallet_gift_balance_after, Some(2.0));
assert_eq!(settlement.provider_monthly_used_usd, Some(7.0));
let wallet = sqlx::query(
@@ -423,8 +732,8 @@ mod tests {
.fetch_one(&pool)
.await
.expect("wallet should load");
assert_eq!(wallet.try_get::<f64, _>("balance").unwrap(), 9.0);
assert_eq!(wallet.try_get::<f64, _>("gift_balance").unwrap(), 0.0);
assert_eq!(wallet.try_get::<f64, _>("balance").unwrap(), 7.0);
assert_eq!(wallet.try_get::<f64, _>("gift_balance").unwrap(), 2.0);
assert_eq!(wallet.try_get::<f64, _>("total_consumed").unwrap(), 3.0);
let second = repository

View File

@@ -9,10 +9,10 @@ use super::types::{
AdminRedeemCodeBatchListQuery, AdminRedeemCodeListQuery, AdminWalletLedgerQuery,
AdminWalletListQuery, AdminWalletRefundRequestListQuery, CompleteAdminWalletRefundInput,
CreateAdminRedeemCodeBatchInput, CreateAdminRedeemCodeBatchResult,
CreateManualWalletRechargeInput, CreateWalletRechargeOrderInput,
CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput,
CreateWalletRefundRequestOutcome, CreatedAdminRedeemCodePlaintext,
CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
CreateManualWalletRechargeInput, CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome,
CreateWalletRechargeOrderInput, CreateWalletRechargeOrderOutcome,
CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome,
CreatedAdminRedeemCodePlaintext, CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput, FailAdminWalletRefundInput,
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
RedeemWalletCodeInput, RedeemWalletCodeOutcome, StoredAdminPaymentCallback,
@@ -947,6 +947,118 @@ impl WalletWriteRepository for InMemoryWalletRepository {
Ok(CreateWalletRechargeOrderOutcome::Created(order))
}
async fn create_plan_purchase_order(
&self,
input: CreatePlanPurchaseOrderInput,
) -> Result<CreatePlanPurchaseOrderOutcome, DataLayerError> {
let wallet_id = {
let wallets = self.wallets_by_id.read().expect("wallet repo lock");
let Some(wallet) = wallets
.values()
.find(|wallet| wallet.user_id.as_deref() == Some(input.user_id.as_str()))
else {
return Ok(CreatePlanPurchaseOrderOutcome::WalletInactive);
};
if wallet.status != "active" {
return Ok(CreatePlanPurchaseOrderOutcome::WalletInactive);
}
wallet.id.clone()
};
let max_active_per_user = input
.product_snapshot
.get("max_active_per_user")
.and_then(serde_json::Value::as_i64)
.unwrap_or(1)
.max(1);
let purchase_limit_scope = input
.product_snapshot
.get("purchase_limit_scope")
.and_then(serde_json::Value::as_str)
.unwrap_or("active_period");
if purchase_limit_scope != "unlimited" {
let now_secs = current_unix_secs();
let existing_count = self
.payment_orders_by_id
.read()
.expect("wallet repo lock")
.values()
.filter(|order| order.user_id.as_deref() == Some(input.user_id.as_str()))
.filter(|order| {
let Some(gateway_response) = order.gateway_response.as_ref() else {
return false;
};
gateway_response
.get("order_kind")
.and_then(serde_json::Value::as_str)
== Some("plan_purchase")
&& gateway_response
.get("product_id")
.and_then(serde_json::Value::as_str)
== Some(input.product_id.as_str())
})
.filter(|order| {
if order.status == "pending" {
return order
.expires_at_unix_secs
.is_some_and(|expires_at| expires_at > now_secs);
}
if purchase_limit_scope == "lifetime" {
return order.status == "credited";
}
order.status == "credited"
&& order
.expires_at_unix_secs
.is_some_and(|expires_at| expires_at > now_secs)
})
.count() as i64;
if existing_count >= max_active_per_user {
return Ok(CreatePlanPurchaseOrderOutcome::ActivePlanLimitReached);
}
}
let mut gateway_response = match input.gateway_response {
serde_json::Value::Object(map) => map,
value => {
let mut map = serde_json::Map::new();
map.insert("raw".to_string(), value);
map
}
};
gateway_response.insert(
"order_kind".to_string(),
serde_json::Value::String("plan_purchase".to_string()),
);
gateway_response.insert(
"product_id".to_string(),
serde_json::Value::String(input.product_id),
);
gateway_response.insert("product_snapshot".to_string(), input.product_snapshot);
let order = StoredAdminPaymentOrder {
id: format!("payment-order-{}", uuid::Uuid::new_v4()),
order_no: input.order_no,
wallet_id,
user_id: Some(input.user_id),
amount_usd: input.amount_usd,
pay_amount: Some(input.pay_amount),
pay_currency: Some(input.pay_currency),
exchange_rate: Some(input.exchange_rate),
refunded_amount_usd: 0.0,
refundable_amount_usd: 0.0,
payment_method: input.payment_method,
gateway_order_id: Some(input.gateway_order_id),
gateway_response: Some(serde_json::Value::Object(gateway_response)),
status: "pending".to_string(),
created_at_unix_ms: current_unix_ms(),
paid_at_unix_secs: None,
credited_at_unix_secs: None,
expires_at_unix_secs: Some(input.expires_at_unix_secs),
};
self.payment_orders_by_id
.write()
.expect("wallet repo lock")
.insert(order.id.clone(), order.clone());
Ok(CreatePlanPurchaseOrderOutcome::Created(order))
}
async fn create_wallet_refund_request(
&self,
input: CreateWalletRefundRequestInput,
@@ -1541,9 +1653,11 @@ impl WalletWriteRepository for InMemoryWalletRepository {
mod tests {
use super::{InMemoryWalletRepository, WalletReadSeed};
use crate::repository::wallet::{
AdminWalletListQuery, StoredAdminPaymentOrder, StoredAdminWalletRefund,
StoredWalletSnapshot, WalletLookupKey, WalletReadRepository,
AdminWalletListQuery, CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome,
StoredAdminPaymentOrder, StoredAdminWalletRefund, StoredWalletSnapshot, WalletLookupKey,
WalletReadRepository, WalletWriteRepository,
};
use serde_json::json;
fn sample_wallet() -> StoredWalletSnapshot {
StoredWalletSnapshot::new(
@@ -1762,6 +1876,110 @@ mod tests {
assert!(history.items.is_empty());
}
#[tokio::test]
async fn lifetime_plan_purchase_blocks_duplicate_pending_order_in_memory() {
let repository = InMemoryWalletRepository::seed(vec![sample_wallet()]);
let snapshot = json!({
"id": "first-plan",
"duration_unit": "month",
"duration_value": 1,
"max_active_per_user": 1,
"purchase_limit_scope": "lifetime",
"entitlements": [
{
"type": "wallet_credit",
"amount_usd": 1.0,
"balance_bucket": "gift"
}
]
});
let first = repository
.create_plan_purchase_order(CreatePlanPurchaseOrderInput {
preferred_wallet_id: None,
user_id: "user-1".to_string(),
amount_usd: 1.0,
pay_amount: 7.2,
pay_currency: "CNY".to_string(),
exchange_rate: 7.2,
payment_method: "alipay".to_string(),
payment_provider: Some("epay".to_string()),
payment_channel: Some("alipay".to_string()),
gateway_order_id: "gateway-first-plan-1".to_string(),
gateway_response: json!({ "checkout": true }),
order_no: "order-first-plan-1".to_string(),
product_id: "first-plan".to_string(),
product_snapshot: snapshot.clone(),
expires_at_unix_secs: 4_102_444_800,
})
.await
.expect("first plan purchase should resolve");
assert!(matches!(first, CreatePlanPurchaseOrderOutcome::Created(_)));
let duplicate = repository
.create_plan_purchase_order(CreatePlanPurchaseOrderInput {
preferred_wallet_id: None,
user_id: "user-1".to_string(),
amount_usd: 1.0,
pay_amount: 7.2,
pay_currency: "CNY".to_string(),
exchange_rate: 7.2,
payment_method: "alipay".to_string(),
payment_provider: Some("epay".to_string()),
payment_channel: Some("alipay".to_string()),
gateway_order_id: "gateway-first-plan-2".to_string(),
gateway_response: json!({ "checkout": true }),
order_no: "order-first-plan-2".to_string(),
product_id: "first-plan".to_string(),
product_snapshot: snapshot,
expires_at_unix_secs: 4_102_444_800,
})
.await
.expect("duplicate plan purchase should resolve");
assert!(matches!(
duplicate,
CreatePlanPurchaseOrderOutcome::ActivePlanLimitReached
));
let unlimited_snapshot = json!({
"id": "unlimited-plan",
"duration_unit": "month",
"duration_value": 1,
"max_active_per_user": 1,
"purchase_limit_scope": "unlimited",
"entitlements": [
{
"type": "wallet_credit",
"amount_usd": 1.0,
"balance_bucket": "gift"
}
]
});
for index in 1..=2 {
let order = repository
.create_plan_purchase_order(CreatePlanPurchaseOrderInput {
preferred_wallet_id: None,
user_id: "user-1".to_string(),
amount_usd: 1.0,
pay_amount: 7.2,
pay_currency: "CNY".to_string(),
exchange_rate: 7.2,
payment_method: "alipay".to_string(),
payment_provider: Some("epay".to_string()),
payment_channel: Some("alipay".to_string()),
gateway_order_id: format!("gateway-unlimited-plan-{index}"),
gateway_response: json!({ "checkout": true }),
order_no: format!("order-unlimited-plan-{index}"),
product_id: "unlimited-plan".to_string(),
product_snapshot: unlimited_snapshot.clone(),
expires_at_unix_secs: 4_102_444_800,
})
.await
.expect("unlimited plan purchase should resolve");
assert!(matches!(order, CreatePlanPurchaseOrderOutcome::Created(_)));
}
}
#[tokio::test]
async fn counts_pending_user_refunds_and_payment_orders() {
let repository = InMemoryWalletRepository::seed_read_model(WalletReadSeed {

View File

@@ -15,9 +15,10 @@ pub use types::{
AdminWalletRefundRequestListQuery, AdminWalletTransactionRecord,
CompleteAdminWalletRefundInput, CreateAdminRedeemCodeBatchInput,
CreateAdminRedeemCodeBatchResult, CreateManualWalletRechargeInput,
CreateWalletRechargeOrderInput, CreateWalletRechargeOrderOutcome,
CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome,
CreatedAdminRedeemCodePlaintext, CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome, CreateWalletRechargeOrderInput,
CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput,
CreateWalletRefundRequestOutcome, CreatedAdminRedeemCodePlaintext,
CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput, FailAdminWalletRefundInput,
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
RedeemWalletCodeInput, RedeemWalletCodeOutcome, StoredAdminPaymentCallback,

View File

@@ -11,10 +11,10 @@ use super::{
AdminRedeemCodeListQuery, AdminWalletLedgerQuery, AdminWalletListQuery,
AdminWalletRefundRequestListQuery, CompleteAdminWalletRefundInput,
CreateAdminRedeemCodeBatchInput, CreateAdminRedeemCodeBatchResult,
CreateManualWalletRechargeInput, CreateWalletRechargeOrderInput,
CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput,
CreateWalletRefundRequestOutcome, CreatedAdminRedeemCodePlaintext,
CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
CreateManualWalletRechargeInput, CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome,
CreateWalletRechargeOrderInput, CreateWalletRechargeOrderOutcome,
CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome,
CreatedAdminRedeemCodePlaintext, CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput, FailAdminWalletRefundInput,
InMemoryWalletRepository, ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput,
ProcessPaymentCallbackOutcome, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
@@ -75,6 +75,7 @@ FROM wallets
SELECT
id, order_no, wallet_id, user_id, amount_usd, pay_amount, pay_currency,
exchange_rate, refunded_amount_usd, refundable_amount_usd, payment_method,
payment_provider, payment_channel, order_kind, product_id, product_snapshot,
gateway_order_id, gateway_response, status,
created_at AS created_at_unix_ms,
paid_at AS paid_at_unix_secs,
@@ -653,9 +654,10 @@ VALUES (?, ?, 0, 0, 'finite', 'USD', 'active', 0, 0, 0, 0, ?, ?)
INSERT INTO payment_orders (
id, order_no, wallet_id, user_id, amount_usd, pay_amount, pay_currency,
exchange_rate, refunded_amount_usd, refundable_amount_usd, payment_method,
payment_provider, payment_channel, order_kind, fulfillment_status,
gateway_order_id, gateway_response, status, created_at, expires_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?, 'pending', ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?, 'wallet_recharge', 'pending', ?, ?, 'pending', ?, ?)
"#,
)
.bind(&order_id)
@@ -667,6 +669,8 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?, 'pending', ?, ?)
.bind(input.pay_currency.as_deref())
.bind(input.exchange_rate)
.bind(&input.payment_method)
.bind(input.payment_provider.as_deref())
.bind(input.payment_channel.as_deref())
.bind(&input.gateway_order_id)
.bind(gateway_response)
.bind(now)
@@ -682,6 +686,163 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?, 'pending', ?, ?)
))
}
async fn create_plan_purchase_order(
&self,
input: CreatePlanPurchaseOrderInput,
) -> Result<CreatePlanPurchaseOrderOutcome, DataLayerError> {
let now = current_unix_secs_i64();
let expires_at = i64::try_from(input.expires_at_unix_secs).map_err(|_| {
DataLayerError::InvalidInput("plan purchase expires_at overflow".to_string())
})?;
let gateway_response =
json_string(&input.gateway_response, "payment_orders.gateway_response")?;
let product_snapshot =
json_string(&input.product_snapshot, "payment_orders.product_snapshot")?;
let mut tx = self.pool.begin().await.map_sql_err()?;
let wallet_row = sqlx::query(
r#"
SELECT id, status
FROM wallets
WHERE user_id = ?
LIMIT 1
FOR UPDATE
"#,
)
.bind(&input.user_id)
.fetch_optional(&mut *tx)
.await
.map_sql_err()?;
let (wallet_id, wallet_status) = if let Some(row) = wallet_row {
(get::<String>(&row, "id")?, get::<String>(&row, "status")?)
} else {
let wallet_id = input
.preferred_wallet_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
sqlx::query(
r#"
INSERT INTO wallets (
id, user_id, balance, gift_balance, limit_mode, currency, status,
total_recharged, total_consumed, total_refunded, total_adjusted,
created_at, updated_at
)
VALUES (?, ?, 0, 0, 'finite', 'USD', 'active', 0, 0, 0, 0, ?, ?)
"#,
)
.bind(&wallet_id)
.bind(&input.user_id)
.bind(now)
.bind(now)
.execute(&mut *tx)
.await
.map_sql_err()?;
(wallet_id, "active".to_string())
};
if wallet_status != "active" {
tx.commit().await.map_sql_err()?;
return Ok(CreatePlanPurchaseOrderOutcome::WalletInactive);
}
let purchase_limit_scope = plan_purchase_limit_scope(&input.product_snapshot);
if purchase_limit_scope != "unlimited" {
let max_active_per_user = plan_max_active_per_user(&input.product_snapshot);
let mut active_count = if purchase_limit_scope == "lifetime" {
sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM user_plan_entitlements
WHERE user_id = ?
AND plan_id = ?
AND status = 'active'
"#,
)
.bind(&input.user_id)
.bind(&input.product_id)
.fetch_one(&mut *tx)
.await
.map_sql_err()?
} else {
sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM user_plan_entitlements
WHERE user_id = ?
AND plan_id = ?
AND status = 'active'
AND expires_at > ?
"#,
)
.bind(&input.user_id)
.bind(&input.product_id)
.bind(now)
.fetch_one(&mut *tx)
.await
.map_sql_err()?
};
active_count += sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM payment_orders
WHERE user_id = ?
AND product_id = ?
AND order_kind = 'plan_purchase'
AND status = 'pending'
AND expires_at > ?
"#,
)
.bind(&input.user_id)
.bind(&input.product_id)
.bind(now)
.fetch_one(&mut *tx)
.await
.map_sql_err()?;
if active_count >= max_active_per_user {
tx.commit().await.map_sql_err()?;
return Ok(CreatePlanPurchaseOrderOutcome::ActivePlanLimitReached);
}
}
let order_id = uuid::Uuid::new_v4().to_string();
sqlx::query(
r#"
INSERT INTO payment_orders (
id, order_no, wallet_id, user_id, amount_usd, pay_amount, pay_currency,
exchange_rate, refunded_amount_usd, refundable_amount_usd, payment_method,
payment_provider, payment_channel, order_kind, product_id, product_snapshot,
fulfillment_status, gateway_order_id, gateway_response, status, created_at, expires_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?, 'plan_purchase', ?, ?, 'pending', ?, ?, 'pending', ?, ?)
"#,
)
.bind(&order_id)
.bind(&input.order_no)
.bind(&wallet_id)
.bind(&input.user_id)
.bind(input.amount_usd)
.bind(input.pay_amount)
.bind(&input.pay_currency)
.bind(input.exchange_rate)
.bind(&input.payment_method)
.bind(input.payment_provider.as_deref())
.bind(input.payment_channel.as_deref())
.bind(&input.product_id)
.bind(product_snapshot)
.bind(&input.gateway_order_id)
.bind(gateway_response)
.bind(now)
.bind(expires_at)
.execute(&mut *tx)
.await
.map_sql_err()?;
let row = mysql_payment_order_by_id(&mut tx, &order_id).await?;
tx.commit().await.map_sql_err()?;
Ok(CreatePlanPurchaseOrderOutcome::Created(
map_payment_order_row(&row)?,
))
}
async fn create_wallet_refund_request(
&self,
input: CreateWalletRefundRequestInput,
@@ -949,11 +1110,22 @@ VALUES (?, NULL, ?, ?, ?, ?, ?, ?, 'received', ?, NULL, ?, NULL)
let order_no: String = get(&order_row, "order_no")?;
let order_wallet_id: String = get(&order_row, "wallet_id")?;
let order_payment_method: String = get(&order_row, "payment_method")?;
let order_payment_provider: Option<String> = get(&order_row, "payment_provider")?;
let order_payment_channel: Option<String> = get(&order_row, "payment_channel")?;
let order_kind: String = get(&order_row, "order_kind")?;
let order_amount_usd: f64 = get(&order_row, "amount_usd")?;
let order_pay_amount: Option<f64> = get(&order_row, "pay_amount")?;
let order_status: String = get(&order_row, "status")?;
let expires_at_unix_secs: Option<i64> = get(&order_row, "expires_at_unix_secs")?;
if (input.amount_usd - order_amount_usd).abs() > f64::EPSILON {
let amount_matches = if let (Some(callback_pay_amount), Some(order_pay_amount)) =
(input.pay_amount, order_pay_amount)
{
(callback_pay_amount - order_pay_amount).abs() <= 0.01
} else {
(input.amount_usd - order_amount_usd).abs() <= f64::EPSILON
};
if !amount_matches {
update_mysql_payment_callback_failure(
&mut tx,
&callback_id,
@@ -983,6 +1155,46 @@ VALUES (?, NULL, ?, ?, ?, ?, ?, ?, 'received', ?, NULL, ?, NULL)
error: "payment method mismatch".to_string(),
});
}
if let Some(expected_provider) = input.payment_provider.as_deref() {
if order_payment_provider
.as_deref()
.is_some_and(|value| !value.eq_ignore_ascii_case(expected_provider))
{
update_mysql_payment_callback_failure(
&mut tx,
&callback_id,
&input,
&payload,
"payment provider mismatch",
)
.await?;
tx.commit().await.map_sql_err()?;
return Ok(ProcessPaymentCallbackOutcome::Failed {
duplicate,
error: "payment provider mismatch".to_string(),
});
}
}
if let Some(expected_channel) = input.payment_channel.as_deref() {
if order_payment_channel
.as_deref()
.is_some_and(|value| !value.eq_ignore_ascii_case(expected_channel))
{
update_mysql_payment_callback_failure(
&mut tx,
&callback_id,
&input,
&payload,
"payment channel mismatch",
)
.await?;
tx.commit().await.map_sql_err()?;
return Ok(ProcessPaymentCallbackOutcome::Failed {
duplicate,
error: "payment channel mismatch".to_string(),
});
}
}
if order_status == "credited" {
mark_mysql_payment_callback_processed(
&mut tx,
@@ -1029,6 +1241,185 @@ VALUES (?, NULL, ?, ?, ?, ?, ?, ?, 'received', ?, NULL, ?, NULL)
});
}
if order_kind == "plan_purchase" {
let order_user_id: Option<String> = get(&order_row, "user_id")?;
let Some(user_id) = order_user_id else {
update_mysql_payment_callback_failure(
&mut tx,
&callback_id,
&input,
&payload,
"payment order user missing",
)
.await?;
tx.commit().await.map_sql_err()?;
return Ok(ProcessPaymentCallbackOutcome::Failed {
duplicate,
error: "payment order user missing".to_string(),
});
};
let product_id: Option<String> = get(&order_row, "product_id")?;
let snapshot = optional_json(
get::<Option<String>>(&order_row, "product_snapshot")?,
"payment_orders.product_snapshot",
)?
.unwrap_or_else(|| serde_json::json!({}));
let plan_id = product_id.unwrap_or_else(|| {
snapshot
.get("id")
.and_then(|value| value.as_str())
.unwrap_or("unknown")
.to_string()
});
let entitlements = plan_entitlements_snapshot(&snapshot);
let existing_entitlement_id = sqlx::query_scalar::<_, String>(
"SELECT id FROM user_plan_entitlements WHERE payment_order_id = ? LIMIT 1",
)
.bind(&order_id)
.fetch_optional(&mut *tx)
.await
.map_sql_err()?;
if existing_entitlement_id.is_none() {
sqlx::query("SELECT id FROM wallets WHERE id = ? LIMIT 1 FOR UPDATE")
.bind(&order_wallet_id)
.fetch_optional(&mut *tx)
.await
.map_sql_err()?;
let purchase_limit_scope = plan_purchase_limit_scope(&snapshot);
if purchase_limit_scope != "unlimited" {
let max_active_per_user = plan_max_active_per_user(&snapshot);
let active_count = if purchase_limit_scope == "lifetime" {
sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM user_plan_entitlements
WHERE user_id = ?
AND plan_id = ?
AND status = 'active'
"#,
)
.bind(&user_id)
.bind(&plan_id)
.fetch_one(&mut *tx)
.await
.map_sql_err()?
} else {
sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM user_plan_entitlements
WHERE user_id = ?
AND plan_id = ?
AND status = 'active'
AND expires_at > ?
"#,
)
.bind(&user_id)
.bind(&plan_id)
.bind(now)
.fetch_one(&mut *tx)
.await
.map_sql_err()?
};
if active_count >= max_active_per_user {
update_mysql_payment_callback_failure(
&mut tx,
&callback_id,
&input,
&payload,
"plan purchase limit reached",
)
.await?;
tx.commit().await.map_sql_err()?;
return Ok(ProcessPaymentCallbackOutcome::Failed {
duplicate,
error: "plan purchase limit reached".to_string(),
});
}
}
replace_matching_plan_entitlements_mysql(&mut tx, &user_id, &snapshot, now).await?;
sqlx::query(
r#"
INSERT INTO user_plan_entitlements (
id, user_id, plan_id, payment_order_id, status, starts_at, expires_at,
entitlements_snapshot, created_at, updated_at
)
VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)
"#,
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&user_id)
.bind(&plan_id)
.bind(&order_id)
.bind(now)
.bind(plan_expires_at_unix(&snapshot, now))
.bind(json_string(
&entitlements,
"user_plan_entitlements.entitlements_snapshot",
)?)
.bind(now)
.bind(now)
.execute(&mut *tx)
.await
.map_sql_err()?;
apply_plan_wallet_credit_mysql(
&mut tx,
&order_wallet_id,
&order_id,
&input.payment_method,
&entitlements,
now,
)
.await?;
}
sqlx::query(
r#"
UPDATE payment_orders
SET gateway_order_id = COALESCE(?, gateway_order_id),
gateway_response = ?,
pay_amount = COALESCE(?, pay_amount),
pay_currency = COALESCE(?, pay_currency),
exchange_rate = COALESCE(?, exchange_rate),
status = 'credited',
fulfillment_status = 'fulfilled',
fulfillment_error = NULL,
paid_at = COALESCE(paid_at, ?),
credited_at = ?,
refundable_amount_usd = 0
WHERE id = ?
"#,
)
.bind(input.gateway_order_id.as_deref())
.bind(&payload)
.bind(input.pay_amount)
.bind(input.pay_currency.as_deref())
.bind(input.exchange_rate)
.bind(now)
.bind(now)
.bind(&order_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
let updated_order_row = mysql_payment_order_by_id(&mut tx, &order_id).await?;
mark_mysql_payment_callback_processed(
&mut tx,
&callback_id,
&input,
&payload,
&order_id,
&order_no,
)
.await?;
tx.commit().await.map_sql_err()?;
return Ok(ProcessPaymentCallbackOutcome::Applied {
duplicate,
order_id,
order_no,
wallet_id: order_wallet_id,
order: map_payment_order_row(&updated_order_row)?,
});
}
let Some(wallet_row) = sqlx::query(
r#"
SELECT id, status, balance, gift_balance
@@ -1912,6 +2303,173 @@ WHERE id = ? AND wallet_id = ?
));
}
let order_kind: String = get(&order_row, "order_kind")?;
if order_kind == "plan_purchase" {
let order_user_id: Option<String> = get(&order_row, "user_id")?;
let Some(user_id) = order_user_id else {
tx.commit().await.map_sql_err()?;
return Ok(WalletMutationOutcome::Invalid(
"payment order user missing".to_string(),
));
};
let product_id: Option<String> = get(&order_row, "product_id")?;
let snapshot = optional_json(
get::<Option<String>>(&order_row, "product_snapshot")?,
"payment_orders.product_snapshot",
)?
.unwrap_or_else(|| serde_json::json!({}));
let plan_id = product_id.unwrap_or_else(|| {
snapshot
.get("id")
.and_then(|value| value.as_str())
.unwrap_or("unknown")
.to_string()
});
let entitlements = plan_entitlements_snapshot(&snapshot);
let existing_entitlement_id = sqlx::query_scalar::<_, String>(
"SELECT id FROM user_plan_entitlements WHERE payment_order_id = ? LIMIT 1",
)
.bind(&input.order_id)
.fetch_optional(&mut *tx)
.await
.map_sql_err()?;
if existing_entitlement_id.is_none() {
let purchase_limit_scope = plan_purchase_limit_scope(&snapshot);
if purchase_limit_scope != "unlimited" {
let max_active_per_user = plan_max_active_per_user(&snapshot);
let active_count = if purchase_limit_scope == "lifetime" {
sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM user_plan_entitlements
WHERE user_id = ?
AND plan_id = ?
AND status = 'active'
"#,
)
.bind(&user_id)
.bind(&plan_id)
.fetch_one(&mut *tx)
.await
.map_sql_err()?
} else {
sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM user_plan_entitlements
WHERE user_id = ?
AND plan_id = ?
AND status = 'active'
AND expires_at > ?
"#,
)
.bind(&user_id)
.bind(&plan_id)
.bind(now)
.fetch_one(&mut *tx)
.await
.map_sql_err()?
};
if active_count >= max_active_per_user {
tx.commit().await.map_sql_err()?;
return Ok(WalletMutationOutcome::Invalid(
"plan purchase limit reached".to_string(),
));
}
}
replace_matching_plan_entitlements_mysql(&mut tx, &user_id, &snapshot, now).await?;
sqlx::query(
r#"
INSERT INTO user_plan_entitlements (
id, user_id, plan_id, payment_order_id, status, starts_at, expires_at,
entitlements_snapshot, created_at, updated_at
)
VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)
"#,
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&user_id)
.bind(&plan_id)
.bind(&input.order_id)
.bind(now)
.bind(plan_expires_at_unix(&snapshot, now))
.bind(json_string(
&entitlements,
"user_plan_entitlements.entitlements_snapshot",
)?)
.bind(now)
.bind(now)
.execute(&mut *tx)
.await
.map_sql_err()?;
apply_plan_wallet_credit_mysql(
&mut tx,
&order.wallet_id,
&input.order_id,
&order.payment_method,
&entitlements,
now,
)
.await?;
}
let mut gateway_response = payment_gateway_response_map(order.gateway_response.clone());
if let Some(serde_json::Value::Object(map)) = input.gateway_response_patch.clone() {
gateway_response.extend(map);
}
gateway_response.insert("manual_credit".to_string(), serde_json::Value::Bool(true));
gateway_response.insert(
"credited_by".to_string(),
input
.operator_id
.clone()
.map(serde_json::Value::String)
.unwrap_or(serde_json::Value::Null),
);
let gateway_response = json_string(
&serde_json::Value::Object(gateway_response),
"payment_orders.gateway_response",
)?;
let next_gateway_order_id = input.gateway_order_id.clone().or(order.gateway_order_id);
let next_pay_amount = input.pay_amount.or(order.pay_amount);
let next_pay_currency = input.pay_currency.clone().or(order.pay_currency);
let next_exchange_rate = input.exchange_rate.or(order.exchange_rate);
let next_paid_at = order.paid_at_unix_secs.unwrap_or(now as u64) as i64;
sqlx::query(
r#"
UPDATE payment_orders
SET gateway_order_id = ?,
gateway_response = ?,
pay_amount = ?,
pay_currency = ?,
exchange_rate = ?,
status = 'credited',
fulfillment_status = 'fulfilled',
fulfillment_error = NULL,
paid_at = ?,
credited_at = ?,
refundable_amount_usd = 0
WHERE id = ?
"#,
)
.bind(next_gateway_order_id.as_deref())
.bind(&gateway_response)
.bind(next_pay_amount)
.bind(next_pay_currency.as_deref())
.bind(next_exchange_rate)
.bind(next_paid_at)
.bind(now)
.bind(&input.order_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
let order =
map_payment_order_row(&mysql_payment_order_by_id(&mut tx, &input.order_id).await?)?;
tx.commit().await.map_sql_err()?;
return Ok(WalletMutationOutcome::Applied((order, true)));
}
let Some(wallet_row) = mysql_wallet_by_id_for_update(&mut tx, &order.wallet_id).await?
else {
tx.commit().await.map_sql_err()?;
@@ -2592,6 +3150,240 @@ fn json_string(value: &serde_json::Value, field_name: &str) -> Result<String, Da
})
}
fn plan_entitlements_snapshot(snapshot: &serde_json::Value) -> serde_json::Value {
snapshot
.get("entitlements")
.or_else(|| snapshot.get("entitlements_json"))
.cloned()
.unwrap_or_else(|| serde_json::json!([]))
}
fn plan_max_active_per_user(snapshot: &serde_json::Value) -> i64 {
snapshot
.get("max_active_per_user")
.and_then(|value| value.as_i64())
.unwrap_or(1)
.max(1)
}
fn plan_purchase_limit_scope(snapshot: &serde_json::Value) -> &str {
match snapshot
.get("purchase_limit_scope")
.and_then(|value| value.as_str())
{
Some("lifetime") => "lifetime",
Some("unlimited") => "unlimited",
_ => "active_period",
}
}
fn plan_replacement_entitlement_types(snapshot: &serde_json::Value) -> Vec<&'static str> {
let entitlements = plan_entitlements_snapshot(snapshot);
let mut kinds = Vec::new();
if entitlement_snapshot_has_type(&entitlements, "daily_quota") {
kinds.push("daily_quota");
}
if entitlement_snapshot_has_type(&entitlements, "membership_group") {
kinds.push("membership_group");
}
kinds
}
fn entitlement_snapshot_has_type(snapshot: &serde_json::Value, entitlement_type: &str) -> bool {
snapshot.as_array().is_some_and(|items| {
items
.iter()
.any(|item| item.get("type").and_then(|value| value.as_str()) == Some(entitlement_type))
})
}
async fn replace_matching_plan_entitlements_mysql(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
user_id: &str,
snapshot: &serde_json::Value,
now: i64,
) -> Result<(), DataLayerError> {
let replacement_types = plan_replacement_entitlement_types(snapshot);
if replacement_types.is_empty() {
return Ok(());
}
let rows = sqlx::query(
r#"
SELECT id, entitlements_snapshot
FROM user_plan_entitlements
WHERE user_id = ?
AND status = 'active'
AND expires_at > ?
"#,
)
.bind(user_id)
.bind(now)
.fetch_all(&mut **tx)
.await
.map_sql_err()?;
for row in rows {
let entitlements = optional_json(
get::<Option<String>>(&row, "entitlements_snapshot")?,
"user_plan_entitlements.entitlements_snapshot",
)?
.unwrap_or_else(|| serde_json::json!([]));
let should_replace = replacement_types
.iter()
.any(|kind| entitlement_snapshot_has_type(&entitlements, kind));
if !should_replace {
continue;
}
let entitlement_id: String = get(&row, "id")?;
sqlx::query(
r#"
UPDATE user_plan_entitlements
SET status = 'replaced',
expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END,
updated_at = ?
WHERE id = ?
AND status = 'active'
AND expires_at > ?
"#,
)
.bind(now)
.bind(now)
.bind(now)
.bind(entitlement_id)
.bind(now)
.execute(&mut **tx)
.await
.map_sql_err()?;
}
Ok(())
}
fn plan_expires_at_unix(snapshot: &serde_json::Value, starts_at_unix_secs: i64) -> i64 {
let duration_value = snapshot
.get("duration_value")
.and_then(|value| value.as_i64())
.unwrap_or(1)
.max(1);
let days = match snapshot
.get("duration_unit")
.and_then(|value| value.as_str())
.unwrap_or("month")
{
"day" | "custom" => duration_value,
"year" => 365 * duration_value,
_ => 30 * duration_value,
};
starts_at_unix_secs.saturating_add(days.saturating_mul(86_400))
}
async fn apply_plan_wallet_credit_mysql(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
wallet_id: &str,
order_id: &str,
payment_method: &str,
entitlements: &serde_json::Value,
now: i64,
) -> Result<(), DataLayerError> {
let credits = entitlements
.as_array()
.into_iter()
.flatten()
.filter(|item| item.get("type").and_then(|value| value.as_str()) == Some("wallet_credit"))
.filter_map(|item| {
let amount = item.get("amount_usd").and_then(|value| value.as_f64())?;
if amount <= 0.0 || !amount.is_finite() {
return None;
}
let bucket = item
.get("balance_bucket")
.and_then(|value| value.as_str())
.unwrap_or("gift")
.to_ascii_lowercase();
Some((amount, bucket))
})
.collect::<Vec<_>>();
if credits.is_empty() {
return Ok(());
}
let Some(wallet_row) = sqlx::query(
"SELECT id, status, balance, gift_balance FROM wallets WHERE id = ? LIMIT 1 FOR UPDATE",
)
.bind(wallet_id)
.fetch_optional(&mut **tx)
.await
.map_sql_err()?
else {
return Err(DataLayerError::UnexpectedValue(
"wallet not found for plan wallet_credit".to_string(),
));
};
let status: String = get(&wallet_row, "status")?;
if status != "active" {
return Err(DataLayerError::UnexpectedValue(
"wallet is not active for plan wallet_credit".to_string(),
));
}
let mut recharge_balance: f64 = get(&wallet_row, "balance")?;
let mut gift_balance: f64 = get(&wallet_row, "gift_balance")?;
for (amount, bucket) in credits {
let before_recharge = recharge_balance;
let before_gift = gift_balance;
let before_total = before_recharge + before_gift;
let credits_recharge = bucket == "recharge";
if credits_recharge {
recharge_balance += amount;
} else {
gift_balance += amount;
}
let after_total = recharge_balance + gift_balance;
sqlx::query(
r#"
UPDATE wallets
SET balance = ?,
gift_balance = ?,
total_recharged = total_recharged + ?,
updated_at = ?
WHERE id = ?
"#,
)
.bind(recharge_balance)
.bind(gift_balance)
.bind(if credits_recharge { amount } else { 0.0 })
.bind(now)
.bind(wallet_id)
.execute(&mut **tx)
.await
.map_sql_err()?;
sqlx::query(
r#"
INSERT INTO wallet_transactions (
id, wallet_id, category, reason_code, amount, balance_before, balance_after,
recharge_balance_before, recharge_balance_after, gift_balance_before,
gift_balance_after, link_type, link_id, operator_id, description, created_at
)
VALUES (?, ?, 'recharge', 'plan_wallet_credit', ?, ?, ?, ?, ?, ?, ?, 'payment_order', ?, NULL, ?, ?)
"#,
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(wallet_id)
.bind(amount)
.bind(before_total)
.bind(after_total)
.bind(before_recharge)
.bind(recharge_balance)
.bind(before_gift)
.bind(gift_balance)
.bind(order_id)
.bind(format!("套餐附赠余额({payment_method})"))
.bind(now)
.execute(&mut **tx)
.await
.map_sql_err()?;
}
Ok(())
}
fn default_refund_mode_for_payment_method(payment_method: &str) -> &'static str {
if matches!(
payment_method,
@@ -2752,6 +3544,7 @@ fn payment_order_select_sql(where_clause: &str) -> String {
SELECT
id, order_no, wallet_id, user_id, amount_usd, pay_amount, pay_currency,
exchange_rate, refunded_amount_usd, refundable_amount_usd, payment_method,
payment_provider, payment_channel, order_kind, product_id, product_snapshot,
gateway_order_id, gateway_response, status,
created_at AS created_at_unix_ms,
paid_at AS paid_at_unix_secs,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -606,6 +606,8 @@ pub struct CreateWalletRechargeOrderInput {
pub pay_currency: Option<String>,
pub exchange_rate: Option<f64>,
pub payment_method: String,
pub payment_provider: Option<String>,
pub payment_channel: Option<String>,
pub gateway_order_id: String,
pub gateway_response: serde_json::Value,
pub order_no: String,
@@ -619,6 +621,33 @@ pub enum CreateWalletRechargeOrderOutcome {
WalletInactive,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CreatePlanPurchaseOrderInput {
pub preferred_wallet_id: Option<String>,
pub user_id: String,
pub amount_usd: f64,
pub pay_amount: f64,
pub pay_currency: String,
pub exchange_rate: f64,
pub payment_method: String,
pub payment_provider: Option<String>,
pub payment_channel: Option<String>,
pub gateway_order_id: String,
pub gateway_response: serde_json::Value,
pub order_no: String,
pub product_id: String,
pub product_snapshot: serde_json::Value,
pub expires_at_unix_secs: u64,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[allow(clippy::large_enum_variant)]
pub enum CreatePlanPurchaseOrderOutcome {
Created(StoredAdminPaymentOrder),
WalletInactive,
ActivePlanLimitReached,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CreateWalletRefundRequestInput {
pub wallet_id: String,
@@ -648,6 +677,8 @@ pub enum CreateWalletRefundRequestOutcome {
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ProcessPaymentCallbackInput {
pub payment_method: String,
pub payment_provider: Option<String>,
pub payment_channel: Option<String>,
pub callback_key: String,
pub order_no: Option<String>,
pub gateway_order_id: Option<String>,
@@ -932,6 +963,16 @@ pub trait WalletWriteRepository: Send + Sync {
input: CreateWalletRechargeOrderInput,
) -> Result<CreateWalletRechargeOrderOutcome, crate::DataLayerError>;
async fn create_plan_purchase_order(
&self,
input: CreatePlanPurchaseOrderInput,
) -> Result<CreatePlanPurchaseOrderOutcome, crate::DataLayerError> {
let _ = input;
Err(crate::DataLayerError::InvalidInput(
"plan purchase order creation is not available".to_string(),
))
}
async fn create_wallet_refund_request(
&self,
input: CreateWalletRefundRequestInput,