mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(billing): let people try the product before handing over a card
The trial now starts inside the product, at email verification, and Stripe grants none at all: checkout creates a subscription that bills immediately. Verifying an address is what buys the seven days, which is also the cheapest abuse control there is. An unexpired trial is treated as an entitlement the account already holds, so a Stripe sync can add access but never retracts a trial that has not run out. That matters most for the abandoned checkout: the resulting incomplete subscription carries no trial_end, and writing it through would have erased the days the account still had and locked it out. Unpaid accounts are bounded by what they can cost us rather than by what they can do: one workspace, one project, 3 GiB of direct uploads. YouTube imports, share links, guests, comments and approvals stay unlimited, because those are the parts worth trying and they cost nothing. isPaidTier() is the new seam; hasBillingAccess() answers a different question now that access no longer implies a card. Signup CTAs, the pricing card, the comparison pages, the terms and the refund policy all said the trial converts to a paid plan by itself. It no longer does, so they say what happens instead. Settings and a banner name both dates that matter: when the trial ends, and the fifteen days after that during which nothing is deleted. /admin/growth compares the two funnels on signup to paid within a fixed 30 day window, not trial to paid. Dropping the card requirement multiplies trials, so the old ratio can fall while more people actually pay, and reading it that way would retire the change for the wrong reason.
This commit is contained in:
+37
-10
@@ -10,7 +10,8 @@ import {
|
||||
} from '@/lib/email-brand';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
||||
import { isProductAnalyticsEnabled, isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||
import { startCardlessTrial } from '@/lib/billing';
|
||||
|
||||
// Reduce window to 2 hours — shorter exposure in access logs and backups.
|
||||
const TOKEN_EXPIRY_HOURS = 2;
|
||||
@@ -29,6 +30,28 @@ export function isEmailVerificationEnabled(): boolean {
|
||||
return !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASSWORD);
|
||||
}
|
||||
|
||||
let warnedAboutUnverifiedTrials = false;
|
||||
|
||||
/**
|
||||
* Says so, once, when an instance is handing out free trials to addresses nobody
|
||||
* has proved.
|
||||
*
|
||||
* Billing switched on means the trial is worth something, and no SMTP means there
|
||||
* is no verification step to hang it on, so every signup form submission mints
|
||||
* seven days of storage. That combination is a deployment mistake rather than a
|
||||
* choice, and it is invisible until the storage bill arrives.
|
||||
*/
|
||||
export function warnIfTrialsSkipVerification(): void {
|
||||
if (warnedAboutUnverifiedTrials) return;
|
||||
if (isEmailVerificationEnabled() || !isStripeFeatureEnabled()) return;
|
||||
|
||||
warnedAboutUnverifiedTrials = true;
|
||||
logError(
|
||||
'Free trials are being granted without email verification because SMTP is not configured while billing is enabled. Configure SMTP_HOST, SMTP_USER and SMTP_PASSWORD.',
|
||||
new Error('Unverified trial signups')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure random verification token, persist only its SHA-256 digest,
|
||||
* and return the raw token (sent to the user via email).
|
||||
@@ -79,21 +102,25 @@ export async function consumeVerificationToken(token: string): Promise<string |
|
||||
// Return null so a replayed/stale token never produces a misleading success redirect.
|
||||
if (user.count === 0) return null;
|
||||
|
||||
// Behind the flag so the extra lookup does not happen at all on a deployment
|
||||
// that is not measuring. count > 0 above already means this is the one call
|
||||
// that flipped the account, so a replayed link cannot reach here.
|
||||
if (isProductAnalyticsEnabled()) {
|
||||
const verified = await db.user.findUnique({
|
||||
where: { email: record.identifier },
|
||||
select: { id: true },
|
||||
});
|
||||
if (verified) {
|
||||
// This is where the free trial begins: a proven address, before any card and
|
||||
// before Stripe is involved at all. count > 0 above means this call is the one
|
||||
// that flipped the account, so a replayed link cannot reach here, and
|
||||
// `startCardlessTrial` refuses a second trial regardless.
|
||||
const verified = await db.user.findUnique({
|
||||
where: { email: record.identifier },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (verified) {
|
||||
if (isProductAnalyticsEnabled()) {
|
||||
await recordEvent({
|
||||
name: 'EMAIL_VERIFIED',
|
||||
dedupeKey: eventKey('EMAIL_VERIFIED', verified.id),
|
||||
userId: verified.id,
|
||||
});
|
||||
}
|
||||
|
||||
await startCardlessTrial(verified.id);
|
||||
}
|
||||
|
||||
return record.identifier;
|
||||
|
||||
Reference in New Issue
Block a user