//! Everything tying a user to their Stripe account: the connect handshake, the //! currency they settle in, and the founder and tax flags that ride along. use sqlx::PgPool; use crate::db::UserId; use crate::db::models::DbUser; use crate::error::Result; /// Update user's Stripe Connect account information after OAuth #[tracing::instrument(skip_all)] pub async fn update_user_stripe_account( pool: &PgPool, user_id: UserId, stripe_account_id: &str, onboarding_complete: bool, payouts_enabled: bool, charges_enabled: bool, ) -> Result { let user = sqlx::query_as::<_, DbUser>( r" UPDATE users SET stripe_account_id = $2, stripe_onboarding_complete = $3, stripe_payouts_enabled = $4, stripe_charges_enabled = $5, updated_at = NOW() WHERE id = $1 RETURNING * ", ) .bind(user_id) .bind(stripe_account_id) .bind(onboarding_complete) .bind(payouts_enabled) .bind(charges_enabled) .fetch_one(pool) .await?; Ok(user) } /// Atomically set a user's Stripe Connect account ID, but only if one is not /// already set. Returns `Some(user)` on success, or `None` if another request /// already claimed the slot (race-condition guard). #[tracing::instrument(skip_all)] pub async fn try_set_stripe_account( pool: &PgPool, user_id: UserId, stripe_account_id: &str, ) -> Result> { let user = sqlx::query_as::<_, DbUser>( r" UPDATE users SET stripe_account_id = $2, stripe_onboarding_complete = false, stripe_payouts_enabled = false, stripe_charges_enabled = false, updated_at = NOW() WHERE id = $1 AND (stripe_account_id IS NULL OR stripe_account_id = '') RETURNING * ", ) .bind(user_id) .bind(stripe_account_id) .fetch_optional(pool) .await?; Ok(user) } /// Update user's Stripe status from webhook (finds user by stripe_account_id) #[tracing::instrument(skip_all)] pub async fn update_user_stripe_status( pool: &PgPool, stripe_account_id: &str, onboarding_complete: bool, payouts_enabled: bool, charges_enabled: bool, settlement_currency: Option, ) -> Result> { // COALESCE, not a plain assignment: `None` means Stripe told us nothing // usable this time (too early in onboarding, or a currency outside our six), // and overwriting a known currency with USD on that signal would redenominate // every price the creator has set. let user = sqlx::query_as::<_, DbUser>( r" UPDATE users SET stripe_onboarding_complete = $2, stripe_payouts_enabled = $3, stripe_charges_enabled = $4, settlement_currency = COALESCE($5, settlement_currency), updated_at = NOW() WHERE stripe_account_id = $1 RETURNING * ", ) .bind(stripe_account_id) .bind(onboarding_complete) .bind(payouts_enabled) .bind(charges_enabled) .bind(settlement_currency) .fetch_optional(pool) .await?; Ok(user) } /// The settlement currency currently stored for a connected account, if any. /// /// Read before a webhook write so a *change* can be distinguished from a /// restatement of the same value. Stripe re-sends `account.updated` constantly, /// so alerting on every write would be noise; alerting on none of them would /// leave a creator's prices silently meaning different money. #[tracing::instrument(skip_all)] pub async fn get_settlement_currency_by_stripe_account( pool: &PgPool, stripe_account_id: &str, ) -> Result> { let row: Option<(crate::currency::SettlementCurrency,)> = sqlx::query_as("SELECT settlement_currency FROM users WHERE stripe_account_id = $1") .bind(stripe_account_id) .fetch_optional(pool) .await?; Ok(row.map(|(c,)| c)) } /// The account behind a Stripe Connect account id. #[tracing::instrument(skip_all)] pub async fn get_user_id_by_stripe_account( pool: &PgPool, stripe_account_id: &str, ) -> Result> { let id = sqlx::query_scalar::<_, UserId>("SELECT id FROM users WHERE stripe_account_id = $1") .bind(stripe_account_id) .fetch_optional(pool) .await?; Ok(id) } /// Store a buyer's cross-currency conversion preference. /// /// A preference, not a lock: the checkout form decides each purchase, and this /// only changes what that form comes back pre-selected with next time. #[tracing::instrument(skip_all)] pub async fn set_conversion_preference( pool: &PgPool, user_id: UserId, conversion: crate::currency::ConversionChoice, ) -> Result<()> { sqlx::query("UPDATE users SET conversion_preference = $2, updated_at = NOW() WHERE id = $1") .bind(user_id) .bind(conversion) .execute(pool) .await?; Ok(()) } /// Mark a user as a founder. Called when they start a creator-tier /// subscription while the founder pricing window is open. Sticky; never /// reset, even on cancellation. Subsequent re-subscriptions during the /// window keep their founder status. After the window closes, eligibility /// is determined by `founder_locked_at` (stamped only for users with an /// active subscription at the close-time snapshot). /// /// **DIY exclusion**: DIY-tier accounts are not full members and must not /// qualify for founder pricing. This function does not enforce that, it sets /// `is_founder` unconditionally, so the exclusion is a caller obligation: only /// call this from creator-tier (Basic/SmallFiles/BigFiles/Everything) checkout /// paths. When DIY ships, its checkout path must NOT invoke this. #[tracing::instrument(skip_all)] pub async fn mark_user_as_founder(pool: &PgPool, user_id: UserId) -> Result<()> { sqlx::query( r" UPDATE users SET is_founder = TRUE, updated_at = NOW() WHERE id = $1 AND is_founder = FALSE ", ) .bind(user_id) .execute(pool) .await?; Ok(()) } /// Close the founder pricing window by stamping `founder_locked_at` on every /// user who is currently flagged `is_founder` AND has an active creator-tier /// subscription. Returns the number of users locked in. Idempotent: skips /// any user already locked. Intended to be called once from an admin tool /// at the moment the founder window closes. #[tracing::instrument(skip_all)] pub async fn lock_in_founders_with_active_subscriptions(pool: &PgPool) -> Result { let result = sqlx::query( r" UPDATE users u SET founder_locked_at = NOW(), updated_at = NOW() WHERE u.is_founder = TRUE AND u.founder_locked_at IS NULL AND EXISTS ( SELECT 1 FROM creator_subscriptions s WHERE s.user_id = u.id AND s.status = 'active' ) ", ) .execute(pool) .await?; Ok(result.rows_affected()) } /// Update a user's Stripe Tax toggle. #[tracing::instrument(skip_all)] pub async fn update_stripe_tax_enabled( pool: &PgPool, user_id: UserId, enabled: bool, ) -> Result<()> { sqlx::query( r" UPDATE users SET stripe_tax_enabled = $2, updated_at = NOW() WHERE id = $1 ", ) .bind(user_id) .bind(enabled) .execute(pool) .await?; Ok(()) } /// Disconnect a user's Stripe account #[tracing::instrument(skip_all)] pub async fn disconnect_user_stripe(pool: &PgPool, user_id: UserId) -> Result { let user = sqlx::query_as::<_, DbUser>( r" UPDATE users SET stripe_account_id = NULL, stripe_onboarding_complete = false, stripe_payouts_enabled = false, stripe_charges_enabled = false, updated_at = NOW() WHERE id = $1 RETURNING * ", ) .bind(user_id) .fetch_one(pool) .await?; Ok(user) }