use super::{CreatorTier, DbCreatorSubscription, PgPool, Result, UserId};
/// Create or reactivate a creator tier subscription record.
///
/// Uses ON CONFLICT DO UPDATE on the user_id unique index to handle
/// both duplicate webhooks and re-subscription after cancellation.
/// Returns `None` if the row already existed with the same stripe_subscription_id
/// (duplicate webhook), `Some` if this was a fresh insert or a re-subscription
/// with a different subscription ID.
#[tracing::instrument(skip_all)]
pub async fn create_creator_subscription<'e>(
executor: impl sqlx::PgExecutor<'e>,
user_id: UserId,
stripe_subscription_id: &str,
stripe_customer_id: &str,
tier: CreatorTier,
) -> Result> {
// Use WHERE clause on the DO UPDATE to only update if the subscription_id
// is different (new subscription) or status is not already active.
// When the WHERE fails, DO UPDATE becomes a no-op and RETURNING yields no row.
let sub = sqlx::query_as::<_, DbCreatorSubscription>(
r"
INSERT INTO creator_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, tier)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id) DO UPDATE
SET stripe_subscription_id = EXCLUDED.stripe_subscription_id,
stripe_customer_id = EXCLUDED.stripe_customer_id,
tier = EXCLUDED.tier,
status = 'active',
canceled_at = NULL,
grace_enforced_at = NULL
WHERE creator_subscriptions.stripe_subscription_id != EXCLUDED.stripe_subscription_id
OR creator_subscriptions.status != 'active'
RETURNING *
",
)
.bind(user_id)
.bind(stripe_subscription_id)
.bind(stripe_customer_id)
.bind(tier)
.fetch_optional(executor)
.await?;
Ok(sub)
}
/// Look up a creator subscription by its Stripe subscription ID.
#[tracing::instrument(skip_all)]
pub async fn get_creator_sub_by_stripe_id(
pool: &PgPool,
stripe_subscription_id: &str,
) -> Result > {
let sub = sqlx::query_as::<_, DbCreatorSubscription>(
"SELECT * FROM creator_subscriptions WHERE stripe_subscription_id = $1",
)
.bind(stripe_subscription_id)
.fetch_optional(pool)
.await?;
Ok(sub)
}
/// Get a user's creator subscription (any status).
#[tracing::instrument(skip_all)]
pub async fn get_creator_sub_by_user(
pool: &PgPool,
user_id: UserId,
) -> Result > {
let sub = sqlx::query_as::<_, DbCreatorSubscription>(
"SELECT * FROM creator_subscriptions WHERE user_id = $1",
)
.bind(user_id)
.fetch_optional(pool)
.await?;
Ok(sub)
}
/// Get the active creator tier for a user (None if no active subscription).
#[tracing::instrument(skip_all)]
pub async fn get_active_creator_tier(
pool: &PgPool,
user_id: UserId,
) -> Result > {
let tier = sqlx::query_scalar::<_, String>(
"SELECT tier FROM creator_subscriptions WHERE user_id = $1 AND status = 'active'",
)
.bind(user_id)
.fetch_optional(pool)
.await?;
match tier {
None => Ok(None),
// A present-but-unparseable tier means the DB holds a tier string the
// enum doesn't know (enum drift). Silently mapping that to `None` would
// strip a paying creator's entitlements with no signal, surface it
// loudly instead of swallowing it (Run 21). The `enum_drift` test and the
// DB CHECK constraint make this unreachable in practice.
Some(t) => match t.parse::() {
Ok(parsed) => Ok(Some(parsed)),
Err(_) => {
tracing::error!(
user_id = %user_id, tier = %t,
"active creator subscription has an unrecognized tier string (enum drift)"
);
Err(crate::error::AppError::Internal(anyhow::anyhow!(
"unrecognized creator tier '{t}' for user {user_id}"
)))
}
},
}
}
// Apply a Stripe-driven status and/or period update in one guarded statement.
// `canceled` is terminal; reactivation runs through `create_creator_subscription`'s
// `ON CONFLICT (user_id) DO UPDATE` at checkout, never through this path. Replaces
// the old split status/period setters (the period half lacked the guard). See
// `crate::db::subscription_writer`.
crate::db::subscription_writer::define_stripe_subscription_writer!(
apply_stripe_update,
"creator_subscriptions",
DbCreatorSubscription
);
/// Cancel a creator subscription (set status + canceled_at).
#[tracing::instrument(skip_all)]
pub async fn cancel_creator_sub(
pool: &PgPool,
stripe_subscription_id: &str,
) -> Result> {
let sub = sqlx::query_as::<_, DbCreatorSubscription>(
r"
UPDATE creator_subscriptions
SET status = 'canceled', canceled_at = COALESCE(canceled_at, NOW())
WHERE stripe_subscription_id = $1
RETURNING *
",
)
.bind(stripe_subscription_id)
.fetch_optional(pool)
.await?;
Ok(sub)
}
/// Sync the users.creator_tier column from the subscription status.
/// Called after checkout/update/cancel to keep the denormalized column in sync.
#[tracing::instrument(skip_all)]
pub async fn sync_user_creator_tier(pool: &PgPool, user_id: UserId) -> Result<()> {
sqlx::query(
r"
UPDATE users SET creator_tier = (
SELECT tier FROM creator_subscriptions
WHERE user_id = $1 AND status = 'active'
)
WHERE id = $1
",
)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
/// Get user IDs of creators with canceled subscriptions 30+ days ago
/// whose items have not yet been hidden.
#[tracing::instrument(skip_all)]
pub async fn get_expired_grace_creators(pool: &PgPool) -> Result> {
// Bounded batch per call. The scheduler enforces these inline on the tick
// (two DB round-trips per creator), so an unbounded result set would let a
// backlog stall the tick. `grace_enforced_at` is set as each creator is
// processed, so successive ticks drain the rest; ORDER BY oldest-first keeps
// it deterministic and starvation-free.
let ids: Vec = sqlx::query_scalar(
r"
SELECT user_id FROM creator_subscriptions
WHERE status = 'canceled'
AND canceled_at IS NOT NULL
AND canceled_at < NOW() - INTERVAL '30 days'
AND grace_enforced_at IS NULL
ORDER BY canceled_at ASC
LIMIT 200
",
)
.fetch_all(pool)
.await?;
Ok(ids)
}
/// Stamp `grace_enforced_at` for all given creators in one statement, paired with
/// `items::hide_all_items_for_users` on the post-grace sweep (Perf-S4, Run 9).
/// No-op on an empty slice.
#[tracing::instrument(skip_all)]
pub async fn mark_grace_enforced_batch(pool: &PgPool, user_ids: &[UserId]) -> Result<()> {
if user_ids.is_empty() {
return Ok(());
}
sqlx::query(
"UPDATE creator_subscriptions SET grace_enforced_at = NOW() WHERE user_id = ANY($1)",
)
.bind(user_ids)
.execute(pool)
.await?;
Ok(())
}
/// Count fully-paying creators, `status = 'active'` only.
///
/// Excludes trialing (free trial), past_due (payment failed but not yet
/// canceled), canceled-in-grace (winding down), and incomplete states.
/// This is the number that goes on the runway disclosure as "paying
/// creators today": revenue-bearing seats, no fudge.
#[tracing::instrument(skip_all)]
pub async fn count_active_paying(pool: &PgPool) -> Result {
let count: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM creator_subscriptions WHERE status = 'active'")
.fetch_one(pool)
.await?;
Ok(count.0)
}
/// Count creators in a trial or 30-day cancellation grace period.
///
/// These are not revenue-bearing today but represent the near-term
/// pipeline: trialing seats may convert, grace seats may resubscribe
/// before enforcement. Disclosed as a secondary number on the runway
/// surface so the headline `count_active_paying` stays strict.
#[tracing::instrument(skip_all)]
pub async fn count_trialing_or_grace(pool: &PgPool) -> Result {
let count: (i64,) = sqlx::query_as(
r"
SELECT COUNT(*) FROM creator_subscriptions
WHERE status = 'trialing'
OR (
status = 'canceled'
AND canceled_at IS NOT NULL
AND canceled_at > NOW() - INTERVAL '30 days'
AND grace_enforced_at IS NULL
)
",
)
.fetch_one(pool)
.await?;
Ok(count.0)
}
/// Check whether a user is in the 30-day cancellation grace period.
///
/// Returns `true` if the subscription is canceled but within 30 days of cancellation
/// and enforcement has not yet been applied.
#[tracing::instrument(skip_all)]
pub async fn is_in_grace_period(pool: &PgPool, user_id: UserId) -> Result {
let in_grace: bool = sqlx::query_scalar(
r"
SELECT EXISTS(
SELECT 1 FROM creator_subscriptions
WHERE user_id = $1
AND status = 'canceled'
AND canceled_at IS NOT NULL
AND canceled_at > NOW() - INTERVAL '30 days'
AND grace_enforced_at IS NULL
)
",
)
.bind(user_id)
.fetch_one(pool)
.await?;
Ok(in_grace)
}