diff --git a/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts b/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts index a19383a936..645da462f8 100644 --- a/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts +++ b/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts @@ -1043,3 +1043,44 @@ test('should refresh user subscriptions (empty / revenuecat / stripe-only)', asy t.is(subs.length, 1, 'case3: only stripe subscription returned'); } }); + +test('user subscriptions ignore active rows after their current period ended', async t => { + const { db, subResolver } = t.context; + + await db.subscription.createMany({ + data: [ + { + targetId: user.id, + plan: 'ai', + provider: 'stripe', + status: 'active', + recurring: 'yearly', + start: new Date('2025-01-01T00:00:00.000Z'), + end: new Date('2025-01-08T00:00:00.000Z'), + stripeSubscriptionId: 'sub_expired_ai', + }, + { + targetId: user.id, + plan: 'pro', + provider: 'stripe', + status: 'active', + recurring: 'yearly', + start: new Date('2025-01-01T00:00:00.000Z'), + end: new Date('2099-01-01T00:00:00.000Z'), + stripeSubscriptionId: 'sub_current_pro', + }, + ], + }); + + const subscriptions = await subResolver.subscriptions(user, user); + t.deepEqual(subscriptions.map(subscription => subscription.plan).sort(), [ + 'pro', + ]); + + const manager = t.context.module.get(UserSubscriptionManager); + const activeAI = await manager.getActiveSubscription({ + userId: user.id, + plan: SubscriptionPlan.AI, + }); + t.is(activeAI, null); +}); diff --git a/packages/backend/server/src/__tests__/payment/service.spec.ts b/packages/backend/server/src/__tests__/payment/service.spec.ts index be873ba31a..7fea956f2f 100644 --- a/packages/backend/server/src/__tests__/payment/service.spec.ts +++ b/packages/backend/server/src/__tests__/payment/service.spec.ts @@ -420,7 +420,7 @@ test('should throw if user has subscription already', async t => { recurring: SubscriptionRecurring.Monthly, status: SubscriptionStatus.Active, start: new Date(), - end: new Date(), + end: new Date(Date.now() + 100000), }, }); @@ -848,7 +848,7 @@ test('should be able to cancel subscription', async t => { recurring: SubscriptionRecurring.Yearly, status: SubscriptionStatus.Active, start: new Date(), - end: new Date(), + end: new Date(Date.now() + 100000), }, }); @@ -1368,7 +1368,7 @@ test('should be able to subscribe to lifetime recurring with old subscription', recurring: SubscriptionRecurring.Monthly, status: SubscriptionStatus.Active, start: new Date(), - end: new Date(), + end: new Date(Date.now() + 100000), }, }); @@ -1402,7 +1402,7 @@ test('should not be able to cancel lifetime subscription', async t => { recurring: SubscriptionRecurring.Lifetime, status: SubscriptionStatus.Active, start: new Date(), - end: new Date(), + end: null, }, }); @@ -1426,7 +1426,7 @@ test('should not be able to update lifetime recurring', async t => { recurring: SubscriptionRecurring.Lifetime, status: SubscriptionStatus.Active, start: new Date(), - end: new Date(), + end: null, }, }); @@ -1481,7 +1481,7 @@ test('should be able to checkout onetime payment if previous subscription is one variant: SubscriptionVariant.Onetime, status: SubscriptionStatus.Active, start: new Date(), - end: new Date(), + end: new Date(Date.now() + 100000), }, }); @@ -1518,7 +1518,7 @@ test('should not be able to checkout out onetime payment if previous subscriptio recurring: SubscriptionRecurring.Monthly, status: SubscriptionStatus.Active, start: new Date(), - end: new Date(), + end: new Date(Date.now() + 100000), }, }); @@ -1698,7 +1698,7 @@ test('should not be able to checkout for workspace if subscribed', async t => { recurring: SubscriptionRecurring.Monthly, status: SubscriptionStatus.Active, start: new Date(), - end: new Date(), + end: new Date(Date.now() + 100000), quantity: 1, }, }); diff --git a/packages/backend/server/src/plugins/payment/manager/common.ts b/packages/backend/server/src/plugins/payment/manager/common.ts index 4683a2e64f..dd3dcc785d 100644 --- a/packages/backend/server/src/plugins/payment/manager/common.ts +++ b/packages/backend/server/src/plugins/payment/manager/common.ts @@ -1,4 +1,4 @@ -import { PrismaClient, UserStripeCustomer } from '@prisma/client'; +import { type Prisma, PrismaClient, UserStripeCustomer } from '@prisma/client'; import Stripe from 'stripe'; import { z } from 'zod'; @@ -13,9 +13,40 @@ import { LookupKey, SubscriptionPlan, SubscriptionRecurring, + SubscriptionStatus, SubscriptionVariant, } from '../types'; +export function validSubscriptionPeriodWhere( + now = new Date() +): Prisma.SubscriptionWhereInput { + return { OR: [{ end: null }, { end: { gt: now } }] }; +} + +export function activeSubscriptionWhere( + now = new Date() +): Prisma.SubscriptionWhereInput { + return { + status: { in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing] }, + ...validSubscriptionPeriodWhere(now), + }; +} + +export function visibleSubscriptionWhere( + now = new Date() +): Prisma.SubscriptionWhereInput { + return { + status: { + in: [ + SubscriptionStatus.Active, + SubscriptionStatus.Trialing, + SubscriptionStatus.PastDue, + ], + }, + ...validSubscriptionPeriodWhere(now), + }; +} + export interface Subscription { stripeSubscriptionId: string | null; stripeScheduleId: string | null; diff --git a/packages/backend/server/src/plugins/payment/manager/selfhost.ts b/packages/backend/server/src/plugins/payment/manager/selfhost.ts index bd4b7324d4..caab29d560 100644 --- a/packages/backend/server/src/plugins/payment/manager/selfhost.ts +++ b/packages/backend/server/src/plugins/payment/manager/selfhost.ts @@ -15,9 +15,9 @@ import { LookupKey, SubscriptionPlan, SubscriptionRecurring, - SubscriptionStatus, } from '../types'; import { + activeSubscriptionWhere, CheckoutParams, Invoice, Subscription, @@ -199,9 +199,7 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager { where: { targetId: identity.key, plan: identity.plan, - status: { - in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing], - }, + ...activeSubscriptionWhere(), }, }); } diff --git a/packages/backend/server/src/plugins/payment/manager/user.ts b/packages/backend/server/src/plugins/payment/manager/user.ts index 38bf257613..0ce760dcbc 100644 --- a/packages/backend/server/src/plugins/payment/manager/user.ts +++ b/packages/backend/server/src/plugins/payment/manager/user.ts @@ -34,7 +34,12 @@ import { SubscriptionStatus, SubscriptionVariant, } from '../types'; -import { CheckoutParams, Subscription, SubscriptionManager } from './common'; +import { + activeSubscriptionWhere, + CheckoutParams, + Subscription, + SubscriptionManager, +} from './common'; interface PriceStrategyStatus { proEarlyAccess: boolean; @@ -224,9 +229,7 @@ export class UserSubscriptionManager extends SubscriptionManager { where: { targetId: args.userId, plan: args.plan, - status: { - in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing], - }, + ...activeSubscriptionWhere(), }, }); } diff --git a/packages/backend/server/src/plugins/payment/manager/workspace.ts b/packages/backend/server/src/plugins/payment/manager/workspace.ts index 30ad1320e0..3e928d19db 100644 --- a/packages/backend/server/src/plugins/payment/manager/workspace.ts +++ b/packages/backend/server/src/plugins/payment/manager/workspace.ts @@ -24,6 +24,7 @@ import { SubscriptionStatus, } from '../types'; import { + activeSubscriptionWhere, CheckoutParams, Invoice, Subscription, @@ -225,9 +226,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager { return this.db.subscription.findFirst({ where: { targetId: identity.workspaceId, - status: { - in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing], - }, + ...activeSubscriptionWhere(), }, }); } diff --git a/packages/backend/server/src/plugins/payment/resolver.ts b/packages/backend/server/src/plugins/payment/resolver.ts index b09ef50f57..acf0c0ab0e 100644 --- a/packages/backend/server/src/plugins/payment/resolver.ts +++ b/packages/backend/server/src/plugins/payment/resolver.ts @@ -30,7 +30,12 @@ import { CurrentUser, Public } from '../../core/auth'; import { PermissionAccess } from '../../core/permission'; import { UserType } from '../../core/user'; import { WorkspaceType } from '../../core/workspaces'; -import { Invoice, Subscription, WorkspaceSubscriptionManager } from './manager'; +import { + Invoice, + Subscription, + visibleSubscriptionWhere, + WorkspaceSubscriptionManager, +} from './manager'; import { RevenueCatWebhookHandler } from './revenuecat'; import { CheckoutParams, SubscriptionService } from './service'; import { @@ -493,13 +498,7 @@ export class UserSubscriptionResolver { const subscriptions = await this.db.subscription.findMany({ where: { targetId: user.id, - status: { - in: [ - SubscriptionStatus.Active, - SubscriptionStatus.Trialing, - SubscriptionStatus.PastDue, - ], - }, + ...visibleSubscriptionWhere(), }, }); @@ -577,13 +576,7 @@ export class UserSubscriptionResolver { current = await this.db.subscription.findMany({ where: { targetId: user.id, - status: { - in: [ - SubscriptionStatus.Active, - SubscriptionStatus.Trialing, - SubscriptionStatus.PastDue, - ], - }, + ...visibleSubscriptionWhere(), }, }); // ignore errors @@ -608,13 +601,7 @@ export class UserSubscriptionResolver { let current = await this.db.subscription.findMany({ where: { targetId: user.id, - status: { - in: [ - SubscriptionStatus.Active, - SubscriptionStatus.Trialing, - SubscriptionStatus.PastDue, - ], - }, + ...visibleSubscriptionWhere(), }, }); @@ -641,13 +628,7 @@ export class UserSubscriptionResolver { current = await this.db.subscription.findMany({ where: { targetId: user.id, - status: { - in: [ - SubscriptionStatus.Active, - SubscriptionStatus.Trialing, - SubscriptionStatus.PastDue, - ], - }, + ...visibleSubscriptionWhere(), }, }); // ignore errors