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:
+145
-1
@@ -77,6 +77,34 @@ export interface PaidAccountRow {
|
||||
selfReported: AcquisitionChannel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long an account gets to convert before its cohort is scored.
|
||||
*
|
||||
* Fixed rather than "since signup" so the two cohorts are compared over equal
|
||||
* time. Without it the newer cohort is measured over a shorter life than the
|
||||
* older one and always looks worse, whatever the change did.
|
||||
*/
|
||||
export const COHORT_OBSERVATION_DAYS = 30;
|
||||
|
||||
export type TrialCohort = 'CARD_FIRST' | 'CARDLESS';
|
||||
|
||||
export interface CohortRow {
|
||||
cohort: TrialCohort;
|
||||
windowStart: Date;
|
||||
windowEnd: Date;
|
||||
signups: number;
|
||||
trials: number;
|
||||
paid: number;
|
||||
}
|
||||
|
||||
export interface CohortComparison {
|
||||
cutover: Date;
|
||||
observationDays: number;
|
||||
/** Length of each side's window. Equal by construction; reported so it can be judged. */
|
||||
windowDays: number;
|
||||
rows: CohortRow[];
|
||||
}
|
||||
|
||||
export interface Scoreboard {
|
||||
weeks: WeeklyRow[];
|
||||
channels: ChannelRow[];
|
||||
@@ -89,6 +117,8 @@ export interface Scoreboard {
|
||||
currentActivePaid: number | null;
|
||||
currentMrrCents: number | null;
|
||||
currency: string;
|
||||
/** Null until OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT names the switchover date. */
|
||||
cohorts: CohortComparison | null;
|
||||
}
|
||||
|
||||
interface WeeklyQueryRow {
|
||||
@@ -195,6 +225,118 @@ export function conversionRates(row: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The day the cardless trial replaced the card-first one, if it has been set.
|
||||
*
|
||||
* Kept in the environment rather than in code because it is a fact about a
|
||||
* deployment, not about the product: a self-hosted instance never switched over
|
||||
* at all, and the hosted one only knows the date once it has shipped.
|
||||
*/
|
||||
export function getCardlessTrialCutover(): Date | null {
|
||||
const raw = process.env.OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT?.trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const parsed = new Date(raw);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two equal-length windows either side of the cutover.
|
||||
*
|
||||
* The `after` window stops `COHORT_OBSERVATION_DAYS` short of now, because an
|
||||
* account that signed up yesterday has not had its chance to convert yet and
|
||||
* counting it would drag the new cohort's rate down for a month. The `before`
|
||||
* window is then cut to the same length, ending at the cutover.
|
||||
*/
|
||||
export function cohortWindows(
|
||||
cutover: Date,
|
||||
now: Date,
|
||||
observationDays: number = COHORT_OBSERVATION_DAYS
|
||||
) {
|
||||
const msPerDay = 24 * 60 * 60 * 1000;
|
||||
const afterStart = cutover;
|
||||
const afterEnd = new Date(now.getTime() - observationDays * msPerDay);
|
||||
const spanMs = Math.max(0, afterEnd.getTime() - afterStart.getTime());
|
||||
|
||||
return {
|
||||
afterStart,
|
||||
afterEnd: new Date(afterStart.getTime() + spanMs),
|
||||
beforeStart: new Date(cutover.getTime() - spanMs),
|
||||
beforeEnd: cutover,
|
||||
windowDays: Math.floor(spanMs / msPerDay),
|
||||
};
|
||||
}
|
||||
|
||||
interface CohortQueryRow {
|
||||
cohort: string;
|
||||
signups: number;
|
||||
trials: number;
|
||||
paid: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card-first against cardless, on signup-to-paid rather than trial-to-paid.
|
||||
*
|
||||
* Trial-to-paid is the wrong ratio for this comparison and will mislead whoever
|
||||
* reads it: handing out trials without a card multiplies the denominator, so the
|
||||
* rate can halve while the number of paying customers goes up. Signups are the
|
||||
* honest denominator because they are the one thing the change does not move.
|
||||
*/
|
||||
export async function getCohortComparison(
|
||||
now: Date = new Date()
|
||||
): Promise<CohortComparison | null> {
|
||||
const cutover = getCardlessTrialCutover();
|
||||
if (!cutover) return null;
|
||||
|
||||
const { afterStart, afterEnd, beforeStart, beforeEnd, windowDays } = cohortWindows(cutover, now);
|
||||
const observationInterval = `${COHORT_OBSERVATION_DAYS} days`;
|
||||
|
||||
const rows = await db.$queryRaw<CohortQueryRow[]>`
|
||||
SELECT CASE WHEN u."createdAt" >= ${cutover} THEN 'CARDLESS' ELSE 'CARD_FIRST' END AS cohort,
|
||||
COUNT(*)::int AS signups,
|
||||
COUNT(*) FILTER (WHERE t.started_at IS NOT NULL)::int AS trials,
|
||||
COUNT(*) FILTER (WHERE p.paid_at IS NOT NULL)::int AS paid
|
||||
FROM users u
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(e.occurred_at) AS started_at
|
||||
FROM analytics_events e
|
||||
WHERE e.user_id = u.id
|
||||
AND e.name::text = 'TRIAL_STARTED'
|
||||
AND e.occurred_at <= u."createdAt" + ${observationInterval}::interval
|
||||
) t ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(e.occurred_at) AS paid_at
|
||||
FROM analytics_events e
|
||||
WHERE e.user_id = u.id
|
||||
AND e.name::text = 'SUBSCRIPTION_STARTED'
|
||||
AND e.occurred_at <= u."createdAt" + ${observationInterval}::interval
|
||||
) p ON TRUE
|
||||
WHERE (u."createdAt" >= ${beforeStart} AND u."createdAt" < ${beforeEnd})
|
||||
OR (u."createdAt" >= ${afterStart} AND u."createdAt" < ${afterEnd})
|
||||
GROUP BY 1
|
||||
`;
|
||||
|
||||
const byCohort = new Map(rows.map((row) => [row.cohort, row]));
|
||||
const build = (cohort: TrialCohort, windowStart: Date, windowEnd: Date): CohortRow => {
|
||||
const row = byCohort.get(cohort);
|
||||
return {
|
||||
cohort,
|
||||
windowStart,
|
||||
windowEnd,
|
||||
signups: row?.signups ?? 0,
|
||||
trials: row?.trials ?? 0,
|
||||
paid: row?.paid ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
cutover,
|
||||
observationDays: COHORT_OBSERVATION_DAYS,
|
||||
windowDays,
|
||||
rows: [build('CARD_FIRST', beforeStart, beforeEnd), build('CARDLESS', afterStart, afterEnd)],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getScoreboard(options?: { weeks?: number }): Promise<Scoreboard> {
|
||||
const weeks = Math.min(Math.max(options?.weeks ?? DEFAULT_WEEKS, 1), 52);
|
||||
const now = new Date();
|
||||
@@ -204,7 +346,7 @@ export async function getScoreboard(options?: { weeks?: number }): Promise<Score
|
||||
const channelWindowStart = new Date(now);
|
||||
channelWindowStart.setUTCDate(channelWindowStart.getUTCDate() - CHANNEL_WINDOW_DAYS);
|
||||
|
||||
const [weekRows, channelRows, priorPaid, paidAccounts, stripeStats] = await Promise.all([
|
||||
const [weekRows, channelRows, priorPaid, paidAccounts, stripeStats, cohorts] = await Promise.all([
|
||||
// COUNT(DISTINCT COALESCE(anonymous_id, id)) rather than COUNT(*): a landing
|
||||
// view is deduped per visitor per day, so a visitor who came back on three
|
||||
// days would otherwise be three weekly visitors. Rows with no anonymous id
|
||||
@@ -256,6 +398,7 @@ export async function getScoreboard(options?: { weeks?: number }): Promise<Score
|
||||
LIMIT ${PAID_ACCOUNT_LIMIT + 1}
|
||||
`,
|
||||
getCachedStripeStats(),
|
||||
getCohortComparison(now),
|
||||
]);
|
||||
|
||||
const byWeek = new Map<number, WeeklyRow>();
|
||||
@@ -337,5 +480,6 @@ export async function getScoreboard(options?: { weeks?: number }): Promise<Score
|
||||
currentActivePaid: stripeStats?.activeSubscribers ?? null,
|
||||
currentMrrCents: stripeStats?.mrrCents ?? null,
|
||||
currency: stripeStats?.currency ?? 'usd',
|
||||
cohorts,
|
||||
};
|
||||
}
|
||||
|
||||
+197
-14
@@ -5,6 +5,8 @@ import { db } from '@/lib/db';
|
||||
import { getStripe, getStripePriceId } from '@/lib/stripe';
|
||||
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||
import { recordSubscriptionTransition } from '@/lib/analytics/billing-events';
|
||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||
import { TRIAL_WORKSPACE_LIMIT } from '@/lib/trial-limits';
|
||||
|
||||
const ACTIVE_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
|
||||
BillingSubscriptionStatus.ACTIVE,
|
||||
@@ -43,6 +45,22 @@ export function hasActiveTrial(trialEndsAt: Date | null | undefined, now: Date =
|
||||
return Boolean(trialEndsAt && trialEndsAt.getTime() > now.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* The trial end date to keep when a Stripe sync has none of its own.
|
||||
*
|
||||
* An unexpired trial is an entitlement the account already holds, so billing
|
||||
* state may add access but must never take a trial back before it has run out.
|
||||
* Without this, a trial user who starts a checkout and abandons the card step
|
||||
* lands on an `incomplete` subscription carrying no `trial_end`, and the sync
|
||||
* would write `trialEndsAt: null` over their remaining days and lock them out of
|
||||
* a product they were still entitled to. Nothing can be farmed this way either:
|
||||
* `billingTrialConsumedAt` is what makes the trial once-per-account, and it is
|
||||
* never cleared.
|
||||
*/
|
||||
export function keepUnexpiredTrial(trialEndsAt: Date | null | undefined, now: Date = new Date()) {
|
||||
return hasActiveTrial(trialEndsAt, now) ? (trialEndsAt ?? null) : null;
|
||||
}
|
||||
|
||||
export function hasActiveSubscription(status: BillingSubscriptionStatus | null | undefined) {
|
||||
if (!status) return false;
|
||||
return ACTIVE_SUBSCRIPTION_STATUSES.has(status);
|
||||
@@ -56,6 +74,33 @@ export function hasRecoverableSubscription(status: BillingSubscriptionStatus | n
|
||||
return RECOVERABLE_SUBSCRIPTION_STATUSES.has(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account is a paying customer, as opposed to one that merely has
|
||||
* access right now.
|
||||
*
|
||||
* The cardless trial makes these two different questions for the first time: a
|
||||
* trial account passes `hasBillingAccess` with no card and no Stripe customer
|
||||
* behind it. Every ceiling that exists to bound what an unpaid account can cost
|
||||
* us (storage, upload size, workspace count) hangs off this, not off access.
|
||||
* A legacy Stripe trial counts as paid because a card was handed over for it.
|
||||
*/
|
||||
export function isPaidTier(
|
||||
subject: Pick<BillingAccessSubject, 'subscriptionStatus' | 'stripeCurrentPeriodEnd'>,
|
||||
now: Date = new Date()
|
||||
) {
|
||||
if (!isStripeFeatureEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasActiveSubscription(subject.subscriptionStatus)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime()
|
||||
);
|
||||
}
|
||||
|
||||
export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new Date()) {
|
||||
if (!isStripeFeatureEnabled()) {
|
||||
return true;
|
||||
@@ -195,12 +240,52 @@ export function getBillingStatusLabel(status: BillingSubscriptionStatus) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants the cardless trial, once per account, and reports whether this call is
|
||||
* the one that granted it.
|
||||
*
|
||||
* Called where the email address is proven rather than where the account is
|
||||
* created: an unverifiable address gets no trial, which is the cheapest abuse
|
||||
* control available and the reason the two writes below can stay this simple.
|
||||
*
|
||||
* `billingTrialConsumedAt` is written here rather than only by the Stripe sync.
|
||||
* It is the once-per-account marker, so a re-issued verification link, a second
|
||||
* device or a replayed request all land on the `WHERE` clause and change nothing.
|
||||
*/
|
||||
export async function startCardlessTrial(userId: string, now: Date = new Date()) {
|
||||
// Without billing nothing is gated, so a trial would be a date nobody reads.
|
||||
// Writing one anyway would consume the trial of a self-hosted instance that
|
||||
// later switches billing on.
|
||||
if (!isStripeFeatureEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { count } = await db.user.updateMany({
|
||||
where: { id: userId, trialEndsAt: null, billingTrialConsumedAt: null },
|
||||
data: {
|
||||
trialEndsAt: getDefaultTrialEndsAt(now),
|
||||
billingTrialConsumedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await recordEvent({
|
||||
name: 'TRIAL_STARTED',
|
||||
dedupeKey: eventKey('TRIAL_STARTED', userId),
|
||||
userId,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getStripeCheckoutState(userId: string) {
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
subscriptionStatus: true,
|
||||
billingTrialConsumedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -211,7 +296,6 @@ export async function getStripeCheckoutState(userId: string) {
|
||||
return {
|
||||
hasActiveSubscription: hasActiveSubscription(user.subscriptionStatus),
|
||||
hasRecoverableSubscription: hasRecoverableSubscription(user.subscriptionStatus),
|
||||
isTrialEligible: !user.billingTrialConsumedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -268,15 +352,22 @@ export async function getWorkspaceCreationEligibility(userId: string) {
|
||||
}
|
||||
|
||||
const billingAccess = hasBillingAccess(user);
|
||||
const isPaid = isPaidTier(user);
|
||||
const collaborationCount = invitedWorkspaceCount + projectOnlyCollaborationCount;
|
||||
|
||||
// A paying account creates as many workspaces as it wants. Everyone else gets
|
||||
// one, which covers both the cardless trial and the pre-trial state where an
|
||||
// account may set a workspace up before it can open it.
|
||||
const canCreateWorkspace =
|
||||
!isStripeFeatureEnabled() ||
|
||||
billingAccess ||
|
||||
(ownedWorkspaceCount === 0 && collaborationCount === 0);
|
||||
isPaid ||
|
||||
((billingAccess || collaborationCount === 0) && ownedWorkspaceCount < TRIAL_WORKSPACE_LIMIT);
|
||||
|
||||
let reason: string | null = null;
|
||||
if (!canCreateWorkspace && isStripeFeatureEnabled()) {
|
||||
if (collaborationCount > 0 && ownedWorkspaceCount === 0) {
|
||||
if (billingAccess && ownedWorkspaceCount >= TRIAL_WORKSPACE_LIMIT) {
|
||||
reason = 'Your free trial includes one workspace. Subscribe to create more.';
|
||||
} else if (collaborationCount > 0 && ownedWorkspaceCount === 0) {
|
||||
reason =
|
||||
'You are currently collaborating in someone else’s workspace or project. Start a subscription to create a workspace of your own.';
|
||||
} else {
|
||||
@@ -297,7 +388,7 @@ export async function getWorkspaceCreationEligibility(userId: string) {
|
||||
hasRecoverableSubscription: hasRecoverableSubscription(user.subscriptionStatus),
|
||||
hasActiveTrial: hasActiveTrial(user.trialEndsAt),
|
||||
hasBillingAccess: billingAccess,
|
||||
isTrialEligible: !user.billingTrialConsumedAt,
|
||||
isPaid,
|
||||
stripeCustomerId: user.stripeCustomerId,
|
||||
stripeSubscriptionId: user.stripeSubscriptionId,
|
||||
stripePriceId: user.stripePriceId,
|
||||
@@ -325,6 +416,74 @@ export async function getBillingOverview(userId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/** How long before the trial runs out the countdown starts being shown. */
|
||||
export const TRIAL_ENDING_NOTICE_DAYS = 3;
|
||||
|
||||
export interface TrialNotice {
|
||||
/** `ending` while access is still live, `ended` once it has lapsed. */
|
||||
kind: 'ending' | 'ended';
|
||||
endsAt: Date;
|
||||
/** When the cleanup job becomes eligible to delete this account's media. */
|
||||
contentKeptUntil: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line trial status worth interrupting somebody with, or null.
|
||||
*
|
||||
* Both halves of the deadline are in one place because the useful message is the
|
||||
* pair: an account is told when the trial runs out and, separately, that running
|
||||
* out is not the moment its work disappears. The gap between those two dates is
|
||||
* the fifteen-day cleanup grace period, and until now nothing in the product said
|
||||
* it out loud, which made the end of a trial read as a deletion notice.
|
||||
*/
|
||||
export async function getTrialNotice(
|
||||
userId: string,
|
||||
now: Date = new Date()
|
||||
): Promise<TrialNotice | null> {
|
||||
if (!isStripeFeatureEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
subscriptionStatus: true,
|
||||
trialEndsAt: true,
|
||||
stripeCurrentPeriodEnd: true,
|
||||
billingAccessEndedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// A paying account has a billing period, not a trial, and gets told about it
|
||||
// in settings rather than in a banner on every page.
|
||||
if (!user || isPaidTier(user, now)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentKeptUntil = getStorageCleanupEligibleAt(user);
|
||||
|
||||
if (hasActiveTrial(user.trialEndsAt, now) && user.trialEndsAt) {
|
||||
const daysLeft = (user.trialEndsAt.getTime() - now.getTime()) / (24 * 60 * 60 * 1000);
|
||||
if (daysLeft > TRIAL_ENDING_NOTICE_DAYS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { kind: 'ending', endsAt: user.trialEndsAt, contentKeptUntil };
|
||||
}
|
||||
|
||||
const endsAt = getBillingAccessEndDate(user);
|
||||
if (!endsAt || hasBillingAccess(user, now)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Past the cleanup date there is nothing left to reassure anybody about.
|
||||
if (contentKeptUntil && contentKeptUntil.getTime() <= now.getTime()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { kind: 'ended', endsAt, contentKeptUntil };
|
||||
}
|
||||
|
||||
export async function getOrCreateStripeCustomerId(userId: string) {
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -395,6 +554,8 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
|
||||
select: {
|
||||
id: true,
|
||||
billingTrialConsumedAt: true,
|
||||
// Read so a cardless trial that has not run out survives this sync.
|
||||
trialEndsAt: true,
|
||||
// Read for the funnel: the transition is what gets recorded, so the state
|
||||
// being overwritten has to be captured before the update below.
|
||||
subscriptionStatus: true,
|
||||
@@ -430,6 +591,11 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
|
||||
const effectiveCurrentPeriodEnd =
|
||||
hasEntitledPrice && currentPeriodEnd ? new Date(currentPeriodEnd * 1000) : null;
|
||||
const effectiveTrialEnd = hasEntitledPrice && trialEnd ? new Date(trialEnd * 1000) : null;
|
||||
// Stripe grants no trials any more, so `effectiveTrialEnd` is null for every
|
||||
// subscription created after the cardless trial shipped, and this fallback is
|
||||
// what stops an abandoned or failed checkout from erasing the days the account
|
||||
// still had. Legacy card-backed trials keep arriving through the branch above.
|
||||
const preservedTrialEnd = effectiveTrialEnd ?? keepUnexpiredTrial(user.trialEndsAt);
|
||||
const hasAccess =
|
||||
hasEntitledPrice &&
|
||||
(hasActiveSubscription(mappedStatus) ||
|
||||
@@ -444,14 +610,23 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
|
||||
stripeCancelAtPeriodEnd: cancelAtPeriodEnd,
|
||||
stripeCancelAt: cancelAt ? new Date(cancelAt * 1000) : null,
|
||||
subscriptionStatus: mappedStatus,
|
||||
trialEndsAt: effectiveTrialEnd,
|
||||
trialEndsAt: preservedTrialEnd,
|
||||
billingTrialConsumedAt:
|
||||
hasEntitledPrice && trialEnd
|
||||
? (user.billingTrialConsumedAt ?? new Date())
|
||||
: user.billingTrialConsumedAt,
|
||||
billingAccessEndedAt: hasAccess
|
||||
? null
|
||||
: getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null),
|
||||
// A live trial means access has not ended, whatever the subscription says.
|
||||
// Stamping an end date here while the trial runs would date the storage
|
||||
// cleanup from today and tell the user their work dies before their trial
|
||||
// does. `hasActiveTrial`, not merely a non-null date: a legacy Stripe trial
|
||||
// that has already elapsed is a reason to stamp the end date, not to skip it.
|
||||
billingAccessEndedAt:
|
||||
hasAccess || hasActiveTrial(preservedTrialEnd)
|
||||
? null
|
||||
: getInactiveBillingAccessEndedAt(
|
||||
subscription,
|
||||
hasEntitledPrice ? currentPeriodEnd : null
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -466,7 +641,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
|
||||
after: {
|
||||
status: mappedStatus,
|
||||
cancelAtPeriodEnd,
|
||||
trialEndsAt: effectiveTrialEnd,
|
||||
trialEndsAt: preservedTrialEnd,
|
||||
currentPeriodEnd: effectiveCurrentPeriodEnd,
|
||||
},
|
||||
});
|
||||
@@ -554,6 +729,7 @@ export async function markSubscriptionCanceledByCustomerId(
|
||||
stripeCancelAtPeriodEnd: true,
|
||||
stripeCurrentPeriodEnd: true,
|
||||
billingTrialConsumedAt: true,
|
||||
trialEndsAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -561,17 +737,24 @@ export async function markSubscriptionCanceledByCustomerId(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Losing the subscription does not retract a trial that has not run out. The
|
||||
// account keeps the days it was given and lands back on the trial's own end
|
||||
// date, which is also what the cancellation copy in settings promises.
|
||||
const preservedTrialEnd = keepUnexpiredTrial(user.trialEndsAt);
|
||||
|
||||
const updated = await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
|
||||
trialEndsAt: null,
|
||||
trialEndsAt: preservedTrialEnd,
|
||||
stripeSubscriptionId: null,
|
||||
stripePriceId: null,
|
||||
stripeCurrentPeriodEnd: options?.currentPeriodEnd ?? null,
|
||||
stripeCancelAtPeriodEnd: false,
|
||||
stripeCancelAt: null,
|
||||
billingAccessEndedAt: options?.endedAt ?? options?.currentPeriodEnd ?? new Date(),
|
||||
billingAccessEndedAt: preservedTrialEnd
|
||||
? null
|
||||
: (options?.endedAt ?? options?.currentPeriodEnd ?? new Date()),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -590,7 +773,7 @@ export async function markSubscriptionCanceledByCustomerId(
|
||||
after: {
|
||||
status: BillingSubscriptionStatus.CANCELED,
|
||||
cancelAtPeriodEnd: false,
|
||||
trialEndsAt: null,
|
||||
trialEndsAt: preservedTrialEnd,
|
||||
currentPeriodEnd: options?.currentPeriodEnd ?? user.stripeCurrentPeriodEnd ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -26,3 +26,73 @@ export function isValidEmailAddress(email: string): boolean {
|
||||
|
||||
return labels.every((label) => label.length > 0 && label.length <= MAX_EMAIL_DOMAIN_LABEL_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throwaway mailbox providers, refused at signup.
|
||||
*
|
||||
* The free trial is granted to any address somebody can read a link at, so a
|
||||
* mailbox that costs nothing and expires in ten minutes is the cheapest way to
|
||||
* take the trial repeatedly. This list is deliberately short and specific: it
|
||||
* holds services whose entire purpose is a disposable inbox, and none of the
|
||||
* forwarding or aliasing services (SimpleLogin, AnonAddy, Apple's Hide My Email,
|
||||
* Fastmail masked addresses) that real paying customers use every day. A list
|
||||
* that catches a genuine buyer costs far more than one that misses a scraper.
|
||||
*/
|
||||
const DISPOSABLE_EMAIL_DOMAINS = new Set([
|
||||
'10minutemail.com',
|
||||
'discard.email',
|
||||
'dispostable.com',
|
||||
'emailondeck.com',
|
||||
'fakeinbox.com',
|
||||
'getnada.com',
|
||||
'grr.la',
|
||||
'guerrillamail.com',
|
||||
'guerrillamail.net',
|
||||
'guerrillamail.org',
|
||||
'harakirimail.com',
|
||||
'inboxkitten.com',
|
||||
'mailcatch.com',
|
||||
'maildrop.cc',
|
||||
'mailinator.com',
|
||||
'mailnesia.com',
|
||||
'mintemail.com',
|
||||
'moakt.com',
|
||||
'mohmal.com',
|
||||
'nada.email',
|
||||
'sharklasers.com',
|
||||
'spam4.me',
|
||||
'spamgourmet.com',
|
||||
'temp-mail.org',
|
||||
'tempinbox.com',
|
||||
'tempmail.com',
|
||||
'tempr.email',
|
||||
'throwawaymail.com',
|
||||
'tmpmail.org',
|
||||
'trashmail.com',
|
||||
'yopmail.com',
|
||||
'yopmail.fr',
|
||||
'yopmail.net',
|
||||
]);
|
||||
|
||||
/**
|
||||
* True when the address belongs to a known disposable mailbox provider.
|
||||
*
|
||||
* Parent domains are checked too, because several of these hand out per-visit
|
||||
* subdomains (`anything.mailinator.com`) that would otherwise walk straight past
|
||||
* an exact-match lookup.
|
||||
*/
|
||||
export function isDisposableEmailDomain(email: string): boolean {
|
||||
const atIndex = email.lastIndexOf('@');
|
||||
if (atIndex < 0) return false;
|
||||
|
||||
const domain = normalizeEmail(email.slice(atIndex + 1));
|
||||
const labels = domain.split('.');
|
||||
|
||||
for (let index = 0; index < labels.length - 1; index += 1) {
|
||||
if (DISPOSABLE_EMAIL_DOMAINS.has(labels.slice(index).join('.'))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
+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;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { competitorProfiles, openFrameProfile } from '@/lib/marketing/comparison
|
||||
|
||||
const commonOpenFrameWins = [
|
||||
'$10/month flat hosted pricing — no per-member or per-guest fees',
|
||||
'7-day free trial, then unlimited collaborators on one plan',
|
||||
'7-day free trial with no credit card, then unlimited collaborators on one plan',
|
||||
'Self-host for free with Docker when you need full data control',
|
||||
'Voice notes and drawn annotations on the timeline',
|
||||
'Formal approval requests with per-reviewer status',
|
||||
@@ -148,7 +148,7 @@ function competitorPricingRows(competitorId: string): PricingRow[] {
|
||||
},
|
||||
{
|
||||
label: 'Trial',
|
||||
openframe: '7-day free trial on hosted',
|
||||
openframe: '7-day free trial on hosted, no card required',
|
||||
competitor: profile.pricingNotes[0] ?? 'See vendor site',
|
||||
},
|
||||
];
|
||||
@@ -402,7 +402,8 @@ export const comparisonPages: ComparisonPageDefinition[] = [
|
||||
},
|
||||
{
|
||||
question: 'Can I start free?',
|
||||
answer: 'Yes. Use the 7-day hosted trial or self-host for free with Docker.',
|
||||
answer:
|
||||
'Yes. The 7-day hosted trial does not ask for a card, and self-hosting with Docker is free.',
|
||||
},
|
||||
{
|
||||
question: 'Is OpenFrame only for video?',
|
||||
@@ -662,7 +663,7 @@ export const comparisonPages: ComparisonPageDefinition[] = [
|
||||
},
|
||||
{
|
||||
label: 'Trial',
|
||||
openframe: '7-day free trial on hosted',
|
||||
openframe: '7-day free trial on hosted, no card required',
|
||||
competitor: 'Free plan — no card required',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -81,7 +81,8 @@ export function buildComparisonJsonLd({
|
||||
'@type': 'Offer',
|
||||
price: '10',
|
||||
priceCurrency: 'USD',
|
||||
description: '7-day free trial, then $10/month hosted plan. Self-hosted option is free.',
|
||||
description:
|
||||
'7-day free trial with no credit card, then $10/month hosted plan. Self-hosted option is free.',
|
||||
},
|
||||
url: seoConfig.url,
|
||||
},
|
||||
|
||||
+36
-8
@@ -3,10 +3,29 @@ import { db } from '@/lib/db';
|
||||
import { apiErrors } from '@/lib/api-response';
|
||||
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
|
||||
import { isPaidTier } from '@/lib/billing';
|
||||
import { getStorageLimitBytes } from '@/lib/trial-limits';
|
||||
|
||||
// 200 GB expressed in bytes
|
||||
export const PLAN_STORAGE_LIMIT_BYTES = BigInt(200) * BigInt(1024) * BigInt(1024) * BigInt(1024);
|
||||
|
||||
/**
|
||||
* The ceiling this particular account is held to.
|
||||
*
|
||||
* A cardless trial gets a much smaller one: it is the only thing standing between
|
||||
* a throwaway signup and 200 GB of our storage. Reads the two billing columns
|
||||
* directly rather than taking a flag from the caller, so no upload route can
|
||||
* forget to pass it.
|
||||
*/
|
||||
async function getStorageLimitForUser(userId: string): Promise<bigint> {
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
|
||||
});
|
||||
|
||||
return getStorageLimitBytes(user ? isPaidTier(user) : false, PLAN_STORAGE_LIMIT_BYTES);
|
||||
}
|
||||
|
||||
// TTL for upload reservations: 30 minutes is enough for R2 image/audio uploads
|
||||
const RESERVATION_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
@@ -61,8 +80,10 @@ export async function getUserStorageInfo(userId: string): Promise<{
|
||||
limitBytes: bigint;
|
||||
percentage: number;
|
||||
}> {
|
||||
const usedBytes = await getUserTotalStorageBytes(userId);
|
||||
const limitBytes = PLAN_STORAGE_LIMIT_BYTES;
|
||||
const [usedBytes, limitBytes] = await Promise.all([
|
||||
getUserTotalStorageBytes(userId),
|
||||
getStorageLimitForUser(userId),
|
||||
]);
|
||||
const percentage =
|
||||
limitBytes > BigInt(0)
|
||||
? Math.min(100, Number((usedBytes * BigInt(10000)) / limitBytes) / 100)
|
||||
@@ -88,9 +109,12 @@ export async function enforceStorageQuota(
|
||||
return null;
|
||||
}
|
||||
|
||||
const usedBytes = await getUserTotalStorageBytes(userId);
|
||||
const [usedBytes, limitBytes] = await Promise.all([
|
||||
getUserTotalStorageBytes(userId),
|
||||
getStorageLimitForUser(userId),
|
||||
]);
|
||||
|
||||
if (usedBytes + incomingSizeBytes >= PLAN_STORAGE_LIMIT_BYTES) {
|
||||
if (usedBytes + incomingSizeBytes >= limitBytes) {
|
||||
return apiErrors.storageExceeded() as NextResponse;
|
||||
}
|
||||
|
||||
@@ -121,9 +145,13 @@ export async function reserveStorageQuota(
|
||||
|
||||
const expiresAt = new Date(Date.now() + reservationTtlMs);
|
||||
|
||||
// Fetch Bunny storage BEFORE entering the transaction to avoid holding the
|
||||
// advisory lock during a potentially slow/failing HTTP call on cache miss.
|
||||
const bunnyData = await getCachedUserBunnyStorage();
|
||||
// Fetch Bunny storage and the account's ceiling BEFORE entering the transaction,
|
||||
// to avoid holding the advisory lock during a potentially slow/failing HTTP call
|
||||
// on cache miss or an extra round trip to Postgres.
|
||||
const [bunnyData, limitBytes] = await Promise.all([
|
||||
getCachedUserBunnyStorage(),
|
||||
getStorageLimitForUser(userId),
|
||||
]);
|
||||
const bunnyBytes = BigInt(bunnyData[userId] ?? 0);
|
||||
|
||||
try {
|
||||
@@ -166,7 +194,7 @@ export async function reserveStorageQuota(
|
||||
const reservedBytes = resRow?.total ?? BigInt(0);
|
||||
|
||||
const totalUsed = r2Bytes + reservedBytes + bunnyBytes;
|
||||
if (totalUsed + incomingSizeBytes >= PLAN_STORAGE_LIMIT_BYTES) {
|
||||
if (totalUsed + incomingSizeBytes >= limitBytes) {
|
||||
throw new QuotaExceededError();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// What a cardless trial is allowed to consume.
|
||||
//
|
||||
// The trial exists to let somebody run one real review cycle before paying: upload
|
||||
// a cut, share it, collect feedback, upload the revision. Everything that costs us
|
||||
// nothing (YouTube and Vimeo embeds, share links, guests, comments, approvals) is
|
||||
// therefore unlimited, and the caps sit only on the two things that do cost money
|
||||
// or invite abuse: how much we store, and how many workspaces one unpaid account
|
||||
// can hold open.
|
||||
//
|
||||
// These take a plain `isPaid` boolean rather than a user row so this module stays
|
||||
// free of imports from `lib/billing.ts`, which imports the limits back.
|
||||
|
||||
/** One workspace, so an unpaid account cannot park a whole agency here. */
|
||||
export const TRIAL_WORKSPACE_LIMIT = 1;
|
||||
|
||||
/**
|
||||
* One project at a time. There is no archive flag on Project, so "active" means
|
||||
* "exists": deleting a project frees the slot.
|
||||
*/
|
||||
export const TRIAL_PROJECT_LIMIT = 1;
|
||||
|
||||
/**
|
||||
* 3 GiB of direct uploads. Enough for a first cut plus two revisions at a real
|
||||
* bitrate, small enough that a farm of throwaway accounts is not worth running.
|
||||
* Anyone who hits it can still work through YouTube imports, which cost nothing.
|
||||
*/
|
||||
export const TRIAL_STORAGE_LIMIT_BYTES = BigInt(3) * BigInt(1024) * BigInt(1024) * BigInt(1024);
|
||||
|
||||
// There is deliberately no separate per-file ceiling for trials. The default
|
||||
// per-file limit is 5 GiB and the trial's total is 3 GiB, so the quota check
|
||||
// already refuses anything bigger, and a second limit would only add a second
|
||||
// way to be told no.
|
||||
|
||||
export function getStorageLimitBytes(isPaid: boolean, planLimitBytes: bigint): bigint {
|
||||
if (isPaid) return planLimitBytes;
|
||||
return planLimitBytes < TRIAL_STORAGE_LIMIT_BYTES ? planLimitBytes : TRIAL_STORAGE_LIMIT_BYTES;
|
||||
}
|
||||
Reference in New Issue
Block a user