mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Merge pull request #48 from yusufipk/fix/expired-billing-cleanup-null-dates
fix(billing): select expired owners whose billing dates are null
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 {
|
export function buildExpiredBillingWhereInput(now: Date = new Date()): Prisma.UserWhereInput {
|
||||||
const cleanupCutoff = new Date(now.getTime() - STORAGE_CLEANUP_GRACE_DAYS * 24 * 60 * 60 * 1000);
|
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 {
|
return {
|
||||||
AND: [
|
AND: [
|
||||||
{
|
{
|
||||||
NOT: buildBillingAccessWhereInput(now),
|
subscriptionStatus: {
|
||||||
|
notIn: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
{ OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: now } }] },
|
||||||
|
{ OR: [{ stripeCurrentPeriodEnd: null }, { stripeCurrentPeriodEnd: { lte: now } }] },
|
||||||
{
|
{
|
||||||
OR: [
|
OR: [
|
||||||
{ billingAccessEndedAt: { lte: cleanupCutoff } },
|
{ billingAccessEndedAt: { lte: cleanupCutoff } },
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ async function main() {
|
|||||||
console.log(`[bunny-orphan-cleanup] Grace period: ${graceHours}h`);
|
console.log(`[bunny-orphan-cleanup] Grace period: ${graceHours}h`);
|
||||||
|
|
||||||
const expiredBillingCleanup = await cleanupExpiredBillingWorkspaces({ dryRun });
|
const expiredBillingCleanup = await cleanupExpiredBillingWorkspaces({ dryRun });
|
||||||
|
console.log(`[bunny-orphan-cleanup] Expired owners past grace: ${expiredBillingCleanup.owners}`);
|
||||||
console.log(
|
console.log(
|
||||||
`[bunny-orphan-cleanup] Expired owner workspaces scanned: ${expiredBillingCleanup.scanned}`
|
`[bunny-orphan-cleanup] Expired owner workspaces scanned: ${expiredBillingCleanup.scanned}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { db } from '../lib/db';
|
|||||||
import { buildExpiredBillingWhereInput } from '../lib/billing';
|
import { buildExpiredBillingWhereInput } from '../lib/billing';
|
||||||
import { collectWorkspaceMediaUrls, deleteMediaFilesBestEffort } from '../lib/r2-cleanup';
|
import { collectWorkspaceMediaUrls, deleteMediaFilesBestEffort } from '../lib/r2-cleanup';
|
||||||
import { cleanupBunnyStreamVideosBestEffort } from '../lib/bunny-stream-cleanup';
|
import { cleanupBunnyStreamVideosBestEffort } from '../lib/bunny-stream-cleanup';
|
||||||
|
import { logCleanupWarnings } from '../lib/cleanup-warnings';
|
||||||
|
|
||||||
type ExpiredWorkspaceTarget = {
|
type ExpiredWorkspaceTarget = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -9,46 +10,58 @@ type ExpiredWorkspaceTarget = {
|
|||||||
ownerEmail: string | null;
|
ownerEmail: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getExpiredWorkspaceTargets(): Promise<ExpiredWorkspaceTarget[]> {
|
/**
|
||||||
|
* The owners past their grace period, and the workspaces they own.
|
||||||
|
*
|
||||||
|
* Both counts are reported, because they answer different questions and a single number
|
||||||
|
* conflated them: zero workspaces can mean nobody expired, or that everyone who expired owns
|
||||||
|
* nothing. The first is normal, the second means media is being kept alive by rows the
|
||||||
|
* cleanup cannot reach, and telling them apart used to require a hand-written query.
|
||||||
|
*/
|
||||||
|
async function getExpiredWorkspaceTargets(): Promise<{
|
||||||
|
owners: number;
|
||||||
|
workspaces: ExpiredWorkspaceTarget[];
|
||||||
|
}> {
|
||||||
const expiredOwners = await db.user.findMany({
|
const expiredOwners = await db.user.findMany({
|
||||||
where: buildExpiredBillingWhereInput(),
|
where: buildExpiredBillingWhereInput(),
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (expiredOwners.length === 0) {
|
if (expiredOwners.length === 0) {
|
||||||
return [];
|
return { owners: 0, workspaces: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.workspace
|
const workspaces = await db.workspace.findMany({
|
||||||
.findMany({
|
where: {
|
||||||
where: {
|
ownerId: { in: expiredOwners.map((owner) => owner.id) },
|
||||||
ownerId: { in: expiredOwners.map((owner) => owner.id) },
|
},
|
||||||
},
|
select: {
|
||||||
select: {
|
id: true,
|
||||||
id: true,
|
ownerId: true,
|
||||||
ownerId: true,
|
owner: {
|
||||||
owner: {
|
select: {
|
||||||
select: {
|
email: true,
|
||||||
email: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
.then((workspaces) =>
|
});
|
||||||
workspaces.map((workspace) => ({
|
|
||||||
id: workspace.id,
|
return {
|
||||||
ownerId: workspace.ownerId,
|
owners: expiredOwners.length,
|
||||||
ownerEmail: workspace.owner.email,
|
workspaces: workspaces.map((workspace) => ({
|
||||||
}))
|
id: workspace.id,
|
||||||
);
|
ownerId: workspace.ownerId,
|
||||||
|
ownerEmail: workspace.owner.email,
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupExpiredBillingWorkspaces(options?: { dryRun?: boolean }) {
|
export async function cleanupExpiredBillingWorkspaces(options?: { dryRun?: boolean }) {
|
||||||
const dryRun = options?.dryRun ?? false;
|
const dryRun = options?.dryRun ?? false;
|
||||||
const workspaces = await getExpiredWorkspaceTargets();
|
const { owners, workspaces } = await getExpiredWorkspaceTargets();
|
||||||
|
|
||||||
if (workspaces.length === 0) {
|
if (workspaces.length === 0) {
|
||||||
return { scanned: 0, deleted: 0 };
|
return { owners, scanned: 0, deleted: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
let deleted = 0;
|
let deleted = 0;
|
||||||
@@ -102,12 +115,15 @@ export async function cleanupExpiredBillingWorkspaces(options?: { dryRun?: boole
|
|||||||
];
|
];
|
||||||
|
|
||||||
await db.workspace.delete({ where: { id: workspace.id } });
|
await db.workspace.delete({ where: { id: workspace.id } });
|
||||||
await Promise.all([
|
const [bunny, r2] = await Promise.all([
|
||||||
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
|
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
|
||||||
deleteMediaFilesBestEffort(mediaUrls),
|
deleteMediaFilesBestEffort(mediaUrls),
|
||||||
]);
|
]);
|
||||||
|
// The rows are already gone, so a refused delete leaves media nothing points at. The
|
||||||
|
// orphan sweep in the calling script picks those up, but only a log says it happened.
|
||||||
|
logCleanupWarnings({ entityType: 'workspace', entityId: workspace.id }, { bunny, r2 });
|
||||||
deleted += 1;
|
deleted += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { scanned: workspaces.length, deleted };
|
return { owners, scanned: workspaces.length, deleted };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ async function main() {
|
|||||||
console.log(`[r2-orphan-cleanup] Starting (${dryRun ? 'dry-run' : 'delete mode'})`);
|
console.log(`[r2-orphan-cleanup] Starting (${dryRun ? 'dry-run' : 'delete mode'})`);
|
||||||
|
|
||||||
const expiredBillingCleanup = await cleanupExpiredBillingWorkspaces({ dryRun });
|
const expiredBillingCleanup = await cleanupExpiredBillingWorkspaces({ dryRun });
|
||||||
|
console.log(`[r2-orphan-cleanup] Expired owners past grace: ${expiredBillingCleanup.owners}`);
|
||||||
console.log(
|
console.log(
|
||||||
`[r2-orphan-cleanup] Expired owner workspaces scanned: ${expiredBillingCleanup.scanned}`
|
`[r2-orphan-cleanup] Expired owner workspaces scanned: ${expiredBillingCleanup.scanned}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// Exercises scripts/expired-billing-cleanup.ts against a real database, because the bug this
|
||||||
|
// file exists for could not be seen any other way.
|
||||||
|
//
|
||||||
|
// The filter used to be written as `NOT: buildBillingAccessWhereInput(now)`, which the unit
|
||||||
|
// tests happily asserted on: as an object it reads correctly. Prisma renders it as
|
||||||
|
// `NOT (status IN (...) OR "trialEndsAt" > $1 OR "stripeCurrentPeriodEnd" > $2)`, and SQL
|
||||||
|
// comparisons against NULL are unknown rather than false, so a row with both dates empty
|
||||||
|
// evaluates to NOT NULL and is dropped. A canceled subscriber is exactly that row, so the
|
||||||
|
// scheduled cleanup deleted nothing at all while reporting success. Only a query against
|
||||||
|
// Postgres shows it, which is why these tests live here and not in tests/unit.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { BillingSubscriptionStatus } from '@prisma/client';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { cleanupExpiredBillingWorkspaces } from '../../scripts/expired-billing-cleanup';
|
||||||
|
import { createUser, createVersion, createVideo, seedProject } from '../factories';
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Every Bunny video id the cleanup asked Bunny to delete, in call order. */
|
||||||
|
let bunnyDeletes: string[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
bunnyDeletes = [];
|
||||||
|
vi.stubEnv('BUNNY_STREAM_API_KEY', 'test-bunny-key');
|
||||||
|
vi.stubEnv('BUNNY_STREAM_LIBRARY_ID', '999999');
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (url: string | URL, init?: { method?: string }) => {
|
||||||
|
// Matched on the parsed host rather than a substring of the href, so a request to some
|
||||||
|
// other service that merely mentions the Bunny host cannot be recorded as a Bunny
|
||||||
|
// delete. The recorder decides what the assertions see, so a loose match here would
|
||||||
|
// make a test pass for the wrong reason.
|
||||||
|
const target = new URL(typeof url === 'string' ? url : url.toString());
|
||||||
|
if (init?.method === 'DELETE' && target.host === 'video.bunnycdn.com') {
|
||||||
|
bunnyDeletes.push(target.pathname.split('/videos/')[1] ?? '');
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected fetch in test: ${init?.method ?? 'GET'} ${target.href}`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// tests/setup/api.ts already undoes stubbed envs after every test, but not globals, and a
|
||||||
|
// leaked fetch stub would swallow the next file's HTTP calls.
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An owner whose access ended `endedDaysAgo` ago, shaped the way Stripe actually leaves the
|
||||||
|
* row: the status is CANCELED and both date columns are empty, because
|
||||||
|
* markSubscriptionCanceledByCustomerId() clears trialEndsAt and Stripe no longer reports
|
||||||
|
* current_period_end on the subscription itself.
|
||||||
|
*/
|
||||||
|
async function seedCanceledOwner(endedDaysAgo: number) {
|
||||||
|
const owner = await createUser({
|
||||||
|
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
|
||||||
|
trialEndsAt: null,
|
||||||
|
stripeCurrentPeriodEnd: null,
|
||||||
|
billingAccessEndedAt: new Date(Date.now() - endedDaysAgo * DAY_MS),
|
||||||
|
});
|
||||||
|
const { workspace, project } = await seedProject({ ownerUser: owner });
|
||||||
|
const video = await createVideo({ projectId: project.id });
|
||||||
|
const version = await createVersion({
|
||||||
|
videoParentId: video.id,
|
||||||
|
providerId: 'bunny',
|
||||||
|
providerVideoId: `bunny-video-${workspace.id.slice(0, 12)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { owner, workspace, project, video, version };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('cleanupExpiredBillingWorkspaces', () => {
|
||||||
|
it('deletes the workspace of a canceled owner whose trial and period columns are null', async () => {
|
||||||
|
const { workspace, version } = await seedCanceledOwner(30);
|
||||||
|
|
||||||
|
const result = await cleanupExpiredBillingWorkspaces();
|
||||||
|
|
||||||
|
expect(result).toEqual({ owners: 1, scanned: 1, deleted: 1 });
|
||||||
|
expect(await db.workspace.findUnique({ where: { id: workspace.id } })).toBeNull();
|
||||||
|
expect(bunnyDeletes).toEqual([version.videoId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an owner inside the fifteen day grace period alone', async () => {
|
||||||
|
const { workspace } = await seedCanceledOwner(5);
|
||||||
|
|
||||||
|
const result = await cleanupExpiredBillingWorkspaces();
|
||||||
|
|
||||||
|
expect(result).toEqual({ owners: 0, scanned: 0, deleted: 0 });
|
||||||
|
expect(await db.workspace.findUnique({ where: { id: workspace.id } })).not.toBeNull();
|
||||||
|
expect(bunnyDeletes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an owner with billing access alone', async () => {
|
||||||
|
const { workspace } = await seedProject();
|
||||||
|
|
||||||
|
const result = await cleanupExpiredBillingWorkspaces();
|
||||||
|
|
||||||
|
expect(result).toEqual({ owners: 0, scanned: 0, deleted: 0 });
|
||||||
|
expect(await db.workspace.findUnique({ where: { id: workspace.id } })).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collects an owner whose trial lapsed without Stripe ever setting an end date', async () => {
|
||||||
|
const owner = await createUser({
|
||||||
|
subscriptionStatus: BillingSubscriptionStatus.FREE,
|
||||||
|
trialEndsAt: new Date(Date.now() - 30 * DAY_MS),
|
||||||
|
billingAccessEndedAt: null,
|
||||||
|
});
|
||||||
|
const { workspace } = await seedProject({ ownerUser: owner });
|
||||||
|
|
||||||
|
const result = await cleanupExpiredBillingWorkspaces();
|
||||||
|
|
||||||
|
expect(result).toEqual({ owners: 1, scanned: 1, deleted: 1 });
|
||||||
|
expect(await db.workspace.findUnique({ where: { id: workspace.id } })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports what it would delete without touching anything on a dry run', async () => {
|
||||||
|
const { workspace } = await seedCanceledOwner(30);
|
||||||
|
|
||||||
|
const result = await cleanupExpiredBillingWorkspaces({ dryRun: true });
|
||||||
|
|
||||||
|
expect(result).toEqual({ owners: 1, scanned: 1, deleted: 0 });
|
||||||
|
expect(await db.workspace.findUnique({ where: { id: workspace.id } })).not.toBeNull();
|
||||||
|
expect(bunnyDeletes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The guard is `{ id: { in: [] } }`, and an empty IN list is only safe if Prisma renders it
|
||||||
|
// as a contradiction rather than dropping the filter. It renders `WHERE 1=0`, but a
|
||||||
|
// regression there would delete every workspace on a self-hosted deployment, so it is worth
|
||||||
|
// a test rather than trust.
|
||||||
|
it('deletes nothing when Stripe is disabled, since nothing can expire without billing', async () => {
|
||||||
|
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
|
||||||
|
const { workspace } = await seedCanceledOwner(30);
|
||||||
|
|
||||||
|
const result = await cleanupExpiredBillingWorkspaces();
|
||||||
|
|
||||||
|
expect(result).toEqual({ owners: 0, scanned: 0, deleted: 0 });
|
||||||
|
expect(await db.workspace.findUnique({ where: { id: workspace.id } })).not.toBeNull();
|
||||||
|
expect(bunnyDeletes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts an expired owner who owns no workspace, so an empty scan is not mistaken for an empty user table', async () => {
|
||||||
|
await createUser({
|
||||||
|
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
|
||||||
|
trialEndsAt: null,
|
||||||
|
stripeCurrentPeriodEnd: null,
|
||||||
|
billingAccessEndedAt: new Date(Date.now() - 30 * DAY_MS),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await cleanupExpiredBillingWorkspaces()).toEqual({
|
||||||
|
owners: 1,
|
||||||
|
scanned: 0,
|
||||||
|
deleted: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -332,20 +332,14 @@ describe('buildBillingAccessWhereInput', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('buildExpiredBillingWhereInput', () => {
|
describe('buildExpiredBillingWhereInput', () => {
|
||||||
it('negates the access filter and requires the fifteen day grace to have elapsed', () => {
|
it('states the lack of access positively and requires the fifteen day grace to have elapsed', () => {
|
||||||
const cutoff = new Date('2025-12-31T00:00:00.000Z');
|
const cutoff = new Date('2025-12-31T00:00:00.000Z');
|
||||||
|
|
||||||
expect(buildExpiredBillingWhereInput(NOW)).toEqual({
|
expect(buildExpiredBillingWhereInput(NOW)).toEqual({
|
||||||
AND: [
|
AND: [
|
||||||
{
|
{ subscriptionStatus: { notIn: ['ACTIVE', 'TRIALING'] } },
|
||||||
NOT: {
|
{ OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: NOW } }] },
|
||||||
OR: [
|
{ OR: [{ stripeCurrentPeriodEnd: null }, { stripeCurrentPeriodEnd: { lte: NOW } }] },
|
||||||
{ subscriptionStatus: { in: ['ACTIVE', 'TRIALING'] } },
|
|
||||||
{ trialEndsAt: { gt: NOW } },
|
|
||||||
{ stripeCurrentPeriodEnd: { gt: NOW } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
OR: [
|
OR: [
|
||||||
{ billingAccessEndedAt: { lte: cutoff } },
|
{ billingAccessEndedAt: { lte: cutoff } },
|
||||||
@@ -356,10 +350,21 @@ describe('buildExpiredBillingWhereInput', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the grace clause when Stripe is disabled even though NOT {} matches nobody', () => {
|
// The NOT form this replaced could not express "no access" for a row whose date columns are
|
||||||
|
// empty, because SQL turns a comparison against NULL into unknown rather than false. Every
|
||||||
|
// branch has to name NULL explicitly instead. tests/api/expired-billing-cleanup.test.ts
|
||||||
|
// proves it against a real database; this only guards the shape.
|
||||||
|
it('admits a null trial and a null period end as expired rather than skipping the row', () => {
|
||||||
|
const where = buildExpiredBillingWhereInput(NOW) as {
|
||||||
|
AND: Array<{ OR?: Array<Record<string, unknown>> }>;
|
||||||
|
};
|
||||||
|
expect(where.AND[1].OR).toContainEqual({ trialEndsAt: null });
|
||||||
|
expect(where.AND[2].OR).toContainEqual({ stripeCurrentPeriodEnd: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches nobody when Stripe is disabled, because nothing can expire without billing', () => {
|
||||||
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
|
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
|
||||||
const where = buildExpiredBillingWhereInput(NOW) as { AND: Array<{ NOT?: object }> };
|
expect(buildExpiredBillingWhereInput(NOW)).toEqual({ id: { in: [] } });
|
||||||
expect(where.AND[0].NOT).toEqual({});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user