mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
fix(billing): select expired owners whose billing dates are null
The expired-owner filter expressed "no billing access" as
NOT: buildBillingAccessWhereInput(now). Prisma renders that as
NOT (status IN ('ACTIVE','TRIALING') OR "trialEndsAt" > $1 OR
"stripeCurrentPeriodEnd" > $2), and a SQL comparison against NULL is
unknown rather than false, so for a row with both dates empty the OR is
NULL and NOT NULL is NULL: the row is never returned.
Both dates empty is exactly what a canceled subscriber looks like, since
markSubscriptionCanceledByCustomerId clears trialEndsAt and Stripe no
longer reports current_period_end on the subscription. The scheduled
cleanup therefore matched nobody at all while reporting success, and
media of owners fifteen days past their grace period stayed in Bunny and
R2 indefinitely.
Each branch now names NULL explicitly. Disabling Stripe also selects
nobody instead of falling through to NOT {}, which Prisma drops
entirely: that left a filter keyed on the grace period alone, so a
self-hosted deployment running the cleanup would delete workspaces of
users it never charged.
The unit tests could not catch this, because as an object the old filter
reads correctly and no SQL is produced. The new coverage lives in
tests/api and runs against Postgres.
This commit is contained in:
+20
-1
@@ -113,11 +113,30 @@ export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.Use
|
||||
export function buildExpiredBillingWhereInput(now: Date = new Date()): Prisma.UserWhereInput {
|
||||
const cleanupCutoff = new Date(now.getTime() - STORAGE_CLEANUP_GRACE_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Without billing nothing can expire, so nobody is eligible. This used to fall through to
|
||||
// `NOT: {}`, which Prisma drops entirely, leaving a filter that matched on the grace period
|
||||
// alone: a self-hosted deployment running the cleanup script would delete the workspaces of
|
||||
// users it never charged.
|
||||
if (!isStripeFeatureEnabled()) {
|
||||
return { id: { in: [] } };
|
||||
}
|
||||
|
||||
// Spelled out as positive AND branches instead of `NOT: buildBillingAccessWhereInput(now)`.
|
||||
// Prisma renders that NOT as `NOT (status IN (...) OR "trialEndsAt" > $1 OR
|
||||
// "stripeCurrentPeriodEnd" > $2)`, and SQL comparisons against NULL are unknown rather than
|
||||
// false, so for a row with both dates empty the OR evaluates to NULL and NOT NULL is still
|
||||
// NULL: the row is never returned. Both columns empty is exactly what a canceled subscriber
|
||||
// looks like (markSubscriptionCanceledByCustomerId clears trialEndsAt, and Stripe no longer
|
||||
// reports current_period_end on the subscription), so the cleanup silently matched nobody.
|
||||
return {
|
||||
AND: [
|
||||
{
|
||||
NOT: buildBillingAccessWhereInput(now),
|
||||
subscriptionStatus: {
|
||||
notIn: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING],
|
||||
},
|
||||
},
|
||||
{ OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: now } }] },
|
||||
{ OR: [{ stripeCurrentPeriodEnd: null }, { stripeCurrentPeriodEnd: { lte: now } }] },
|
||||
{
|
||||
OR: [
|
||||
{ billingAccessEndedAt: { lte: cleanupCutoff } },
|
||||
|
||||
Reference in New Issue
Block a user