Files
Yusuf İpek 7aeda83eb6 feat(billing): cancel in-app with a one-question reason
Add a "Cancel subscription" button beside "Manage Subscription" in Settings.
It opens a dialog with one optional question (five answers, no default, a
note box under the two that want detail), then schedules the Stripe
subscription to end at the close of the current period without a trip to
the portal. The answer is stored in a new subscription_cancellations table
and shown, with an all-time tally, on the admin dashboard; the category is
also mirrored onto Stripe's cancellation feedback, the free text stays local.

The cancel route claims the local cancel flag with a conditional update
before calling Stripe, so two racing requests cannot both write a reason
row, and hands the claim back when Stripe refuses. A subscription Stripe no
longer knows answers 409 with a pointer to the portal instead of a 500. The
route carries an account-keyed rate limit on top of the shared IP one.

Two fixes found on the way: the pinned Stripe API version reports
current_period_end on the subscription item rather than the subscription, so
the sync stored null for every period end; a shared helper now reads the item
first. And the RadioGroup styles targeted a data-checked attribute radix
never writes, so the checked state was invisible in the light theme.
2026-09-08 14:05:16 +03:00

252 lines
9.7 KiB
TypeScript

// Global setup for the `api` Vitest project. Runs once per run, in the main
// Vitest process, before any test file is loaded.
//
// Never import from 'vitest' here: globalSetup runs outside the test context.
//
// ---------------------------------------------------------------------------
// Why this uses `prisma db push` and not `prisma migrate deploy`
// ---------------------------------------------------------------------------
// TESTING.md section 5 specifies `prisma migrate deploy`. That does not work in
// this repo, and the reason is worth stating so nobody "fixes" it back:
//
// prisma/migrations holds fifteen incremental patches on top of a baseline
// that was never captured as a migration. The schema was originally created
// with `db push`. So the second migration in the sequence,
// 20260227000000_add_audio_asset_kind_and_provider, opens with
// `ALTER TYPE "VideoAssetKind" ADD VALUE 'AUDIO'` against a type that nothing
// in the migration history ever created. Against an empty database
// `migrate deploy` dies on it with P3018 / 42704
// (`type "VideoAssetKind" does not exist`).
//
// So the schema comes from `prisma db push`, and the parts of the hand-written
// SQL that schema.prisma cannot express are replayed afterwards in
// POST_PUSH_SQL. Those parts are load-bearing: `cleanup_rate_limits()` is
// called by lib/rate-limit.ts, and the three partial unique indexes are what
// stop an R2 object key being claimed by two video versions.
//
// REVIEWED_MIGRATIONS below is a drift guard. Add a migration and this setup
// fails until someone has looked at whether it contains SQL that `db push`
// cannot derive from schema.prisma, exactly like the route list in
// tests/api/auth-matrix.test.ts.
// MUST stay the first import: it loads .env.test, and everything below reads
// process.env.DATABASE_URL.
import { REPO_ROOT } from '../helpers/env';
import { execFile } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { promisify } from 'node:util';
import { Pool } from 'pg';
const execFileAsync = promisify(execFile);
const MIGRATIONS_DIR = path.join(REPO_ROOT, 'prisma', 'migrations');
/**
* Every migration directory that has been checked against POST_PUSH_SQL.
*
* Entries marked "replayed" contain SQL that `prisma db push` cannot produce
* from schema.prisma, so POST_PUSH_SQL carries an idempotent copy. Everything
* else is a plain table/column/enum addition that db push derives on its own.
*/
const REVIEWED_MIGRATIONS = [
'20260226110000_rate_limit_extras', // replayed: cleanup_rate_limits(), UNLOGGED
'20260227000000_add_audio_asset_kind_and_provider',
'20260227120000_add_onboarding',
'20260320110000_add_stripe_billing',
'20260320123000_add_billing_trials_and_cleanup_dates',
'20260321003000_add_subscription_cancel_state',
'20260321094500_add_billing_trial_consumed_at',
'20260414120000_add_size_bytes_to_video_assets',
'20260415120000_add_upload_reservations',
'20260527120000_add_size_bytes_to_video_versions',
'20260527154000_add_video_upload_sessions',
'20260527155000_add_r2_video_uniqueness_indexes', // replayed: 3 partial unique indexes
'20260613120000_add_r2_video_asset_provider',
'20260614160000_add_project_allow_downloads',
'20260627140000_add_video_upload_multipart_id',
'20260801120000_add_acquisition_analytics',
'20260818120000_add_upload_reservation_purpose',
'20260820120000_add_comment_images',
'20260822120000_add_video_subtitles',
'20260908120000_add_subscription_cancellations',
];
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
const REQUIRED_FUNCTIONS = ['cleanup_rate_limits'];
const REQUIRED_INDEXES = [
'video_versions_r2_videoid_unique',
'video_versions_r2_originalurl_unique',
'video_versions_r2_thumbnail_unique',
];
const POST_PUSH_SQL = `
-- Replayed from 20260226110000_rate_limit_extras. lib/rate-limit.ts calls
-- cleanup_rate_limits() on an interval and tests/api/rate-limit.test.ts asserts
-- on what it deletes.
CREATE OR REPLACE FUNCTION cleanup_rate_limits() RETURNS void AS $fn$
BEGIN
DELETE FROM rate_limits WHERE window_start < NOW() - INTERVAL '1 hour';
END;
$fn$ LANGUAGE plpgsql;
DO $do$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_class
WHERE relname = 'rate_limits'
AND relkind = 'r'
AND relpersistence <> 'u'
) THEN
ALTER TABLE rate_limits SET UNLOGGED;
END IF;
END $do$;
-- Replayed from 20260527155000_add_r2_video_uniqueness_indexes. Partial indexes
-- have no representation in schema.prisma, so db push never creates them, and
-- app/api/projects/[projectId]/videos/r2-complete/route.ts relies on them to
-- reject a second version claiming the same object key.
CREATE UNIQUE INDEX IF NOT EXISTS "video_versions_r2_videoid_unique"
ON "video_versions" ("videoId")
WHERE "providerId" = 'r2';
CREATE UNIQUE INDEX IF NOT EXISTS "video_versions_r2_originalurl_unique"
ON "video_versions" ("originalUrl")
WHERE "providerId" = 'r2' AND "originalUrl" LIKE '/api/upload/video/%';
CREATE UNIQUE INDEX IF NOT EXISTS "video_versions_r2_thumbnail_unique"
ON "video_versions" ("thumbnailUrl")
WHERE "providerId" = 'r2' AND "thumbnailUrl" LIKE '/api/upload/image/%';
`;
function assertMigrationsReviewed(): void {
const found = fs
.readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
const unreviewed = found.filter((name) => !REVIEWED_MIGRATIONS.includes(name));
const vanished = REVIEWED_MIGRATIONS.filter((name) => !found.includes(name));
if (unreviewed.length === 0 && vanished.length === 0) return;
throw new Error(
[
'prisma/migrations no longer matches REVIEWED_MIGRATIONS in tests/setup/db-global.ts.',
unreviewed.length > 0 ? ` new, unreviewed: ${unreviewed.join(', ')}` : null,
vanished.length > 0 ? ` listed but missing: ${vanished.join(', ')}` : null,
'',
'The api suite builds its schema with `prisma db push`, not `migrate deploy`',
'(see the comment at the top of this file). Open the new migration.sql and',
'decide: if it is a plain table/column/enum change that schema.prisma also',
'describes, just add its directory name to REVIEWED_MIGRATIONS. If it holds',
'SQL that db push cannot derive (a function, a trigger, a partial or',
'expression index, an UNLOGGED table), add an idempotent copy to',
'POST_PUSH_SQL as well, or the routes that depend on it will be tested',
'against a database that does not have it.',
]
.filter((line) => line !== null)
.join('\n')
);
}
async function waitForPostgres(pool: Pool): Promise<void> {
const deadline = Date.now() + 60_000;
let lastError: unknown = null;
while (Date.now() < deadline) {
try {
await pool.query('SELECT 1');
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
throw new Error(
`Test Postgres was not reachable within 60s at the DATABASE_URL from .env.test. ` +
`Start it with \`podman compose -f docker-compose.test.yml up -d\` and make sure the ` +
`test runner is attached to the openframe-test network. Last error: ${String(lastError)}`
);
}
async function pushSchema(): Promise<void> {
const prismaBin = path.join(REPO_ROOT, 'node_modules', '.bin', 'prisma');
if (!fs.existsSync(prismaBin)) {
throw new Error(`Prisma CLI not found at ${prismaBin}. Run \`bun install\` first.`);
}
try {
// Run through the current runtime (bun locally, node on CI) rather than the
// shebang, so this does not depend on `node` being on PATH.
await execFileAsync(process.execPath, [prismaBin, 'db', 'push', '--accept-data-loss'], {
cwd: REPO_ROOT,
env: process.env,
timeout: 180_000,
maxBuffer: 16 * 1024 * 1024,
});
} catch (error) {
const detail = error as { stdout?: string; stderr?: string; message?: string };
throw new Error(
`\`prisma db push\` failed against the test database.\n` +
`${detail.stdout ?? ''}\n${detail.stderr ?? detail.message ?? ''}`
);
}
}
async function assertPostPushObjects(pool: Pool): Promise<void> {
const functions = await pool.query<{ proname: string }>(
`SELECT proname FROM pg_proc WHERE proname = ANY($1::text[])`,
[REQUIRED_FUNCTIONS]
);
const missingFunctions = REQUIRED_FUNCTIONS.filter(
(name) => !functions.rows.some((row) => row.proname === name)
);
const indexes = await pool.query<{ indexname: string }>(
`SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND indexname = ANY($1::text[])`,
[REQUIRED_INDEXES]
);
const missingIndexes = REQUIRED_INDEXES.filter(
(name) => !indexes.rows.some((row) => row.indexname === name)
);
if (missingFunctions.length > 0 || missingIndexes.length > 0) {
throw new Error(
'POST_PUSH_SQL did not produce everything the routes depend on. Missing ' +
`functions: [${missingFunctions.join(', ')}], indexes: [${missingIndexes.join(', ')}].`
);
}
}
export async function setup(): Promise<void> {
assertMigrationsReviewed();
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 1,
connectionTimeoutMillis: 3_000,
});
// An idle client dropped by a restarting Postgres emits on the pool, not on a
// query promise, and an unhandled 'error' would take the whole run down.
pool.on('error', () => {});
try {
await waitForPostgres(pool);
await pushSchema();
await pool.query(POST_PUSH_SQL);
await assertPostPushObjects(pool);
} finally {
await pool.end();
}
}
export async function teardown(): Promise<void> {
// Nothing to do. The database is left up on purpose so the next run skips the
// push, and its data directory is a tmpfs that dies with the container.
}