mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: add payment gateway and billing plans
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE billing_plans
|
||||
ADD COLUMN purchase_limit_scope VARCHAR(32) NOT NULL DEFAULT 'active_period';
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE billing_plans
|
||||
ADD COLUMN purchase_limit_scope TEXT NOT NULL DEFAULT 'active_period';
|
||||
@@ -458,6 +458,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,
|
||||
@@ -469,6 +476,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: -
|
||||
--
|
||||
|
||||
@@ -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: -
|
||||
--
|
||||
@@ -597,6 +642,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: -
|
||||
--
|
||||
@@ -1152,6 +1212,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: -
|
||||
--
|
||||
|
||||
@@ -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: -
|
||||
--
|
||||
|
||||
@@ -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: -
|
||||
--
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 = 20260511120000;
|
||||
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260512110000;
|
||||
|
||||
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
|
||||
SELECT COUNT(*)::BIGINT
|
||||
|
||||
@@ -300,6 +300,8 @@ fn empty_database_snapshot_covers_current_cutoff_versions() {
|
||||
20260510120000,
|
||||
20260511000000,
|
||||
20260511120000,
|
||||
20260512090000,
|
||||
20260512110000,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -372,6 +374,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
|
||||
@@ -564,7 +578,9 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
|
||||
20260509000000,
|
||||
20260509120000,
|
||||
20260510120000,
|
||||
20260511120000
|
||||
20260511120000,
|
||||
20260512090000,
|
||||
20260512110000
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -576,7 +592,9 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
|
||||
20260509000000,
|
||||
20260509120000,
|
||||
20260510120000,
|
||||
20260511120000
|
||||
20260511120000,
|
||||
20260512090000,
|
||||
20260512110000
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -1088,6 +1106,8 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
|
||||
20260510120000,
|
||||
20260511000000,
|
||||
20260511120000,
|
||||
20260512090000,
|
||||
20260512110000,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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#"
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user