feat(server): cleanup legacy compatibility (#15239)

This commit is contained in:
DarkSky
2026-07-15 03:01:51 +08:00
committed by GitHub
parent 00d4ab10a1
commit e145d87d56
104 changed files with 3184 additions and 8883 deletions
@@ -5,6 +5,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import {
BadRequest,
Cache,
Config,
CryptoHelper,
metrics,
safeFetch,
@@ -115,11 +116,12 @@ export class ByokService {
private readonly models: Models,
private readonly crypto: CryptoHelper,
private readonly cache: Cache,
private readonly entitlement: ByokEntitlementPolicy
private readonly entitlement: ByokEntitlementPolicy,
private readonly config: Config
) {}
get customEndpointSupported() {
return env.selfhosted;
return env.selfhosted && this.config.copilot.byok.allowCustomEndpoint;
}
async getSettings(
@@ -188,10 +188,7 @@ export class IndexerResolver {
docIdColumn: Prisma.raw('candidate_docs.doc_id'),
} as const;
const predicate = this.permission.docReadableSqlPredicate(input);
const fallbackPredicate =
this.permission.fallbackDocReadableSqlPredicate(input);
const query = (predicate: Prisma.Sql) =>
this.db.$queryRaw<{ docId: string }[]>`
const rows = await this.db.$queryRaw<{ docId: string }[]>`
WITH candidate_docs AS (
SELECT "workspace_pages"."page_id" AS doc_id
FROM "workspace_pages"
@@ -205,12 +202,6 @@ export class IndexerResolver {
FROM candidate_docs
WHERE ${predicate}
`;
const rows = await query(predicate).catch(error => {
if (!fallbackPredicate) {
throw error;
}
return query(fallbackPredicate);
});
return rows.map(row => row.docId);
}
}
@@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Transactional } from '@nestjs-cls/transactional';
import { InstalledLicense, PrismaClient } from '@prisma/client';
import {
@@ -104,6 +105,7 @@ export class LicenseService {
});
}
@Transactional()
async installLicense(workspaceId: string, license: Buffer) {
const resolved = this.resolveWorkspaceTeamLicense(workspaceId, license);
if (!resolved.valid) {
@@ -111,13 +113,37 @@ export class LicenseService {
}
const validatedAt = new Date();
await this.event.emitAsync('workspace.subscription.activated', {
workspaceId,
plan: SubscriptionPlan.SelfHostedTeam,
recurring: this.licenseRecurring(resolved),
quantity: this.licenseQuantity(resolved),
const previous = await this.db.installedLicense.findUnique({
where: { workspaceId },
});
const installed = await this.db.installedLicense.upsert({
where: { workspaceId },
update: {
key: this.licenseSubjectId(resolved),
quantity: this.licenseQuantity(resolved),
recurring: this.licenseRecurring(resolved),
variant: SubscriptionVariant.Onetime,
validateKey: '',
validatedAt,
expiredAt: this.licenseExpiresAt(resolved),
license,
},
create: {
workspaceId,
key: this.licenseSubjectId(resolved),
quantity: this.licenseQuantity(resolved),
recurring: this.licenseRecurring(resolved),
variant: SubscriptionVariant.Onetime,
validateKey: '',
validatedAt,
expiredAt: this.licenseExpiresAt(resolved),
license,
},
});
if (previous && previous.key !== installed.key) {
await this.entitlement.revokeBySubject('selfhost_license', previous.key);
}
await this.entitlement.upsertFromSelfhostLicense({
workspaceId,
recurring: this.licenseRecurring(resolved),
@@ -127,10 +153,14 @@ export class LicenseService {
variant: SubscriptionVariant.Onetime,
license,
});
return this.db.installedLicense.findUniqueOrThrow({
where: { workspaceId },
await this.event.emitAsync('workspace.subscription.activated', {
workspaceId,
plan: SubscriptionPlan.SelfHostedTeam,
recurring: this.licenseRecurring(resolved),
quantity: this.licenseQuantity(resolved),
});
return installed;
}
previewLicense(license: Buffer): LicensePreview {
@@ -157,6 +187,7 @@ export class LicenseService {
};
}
@Transactional()
async activateTeamLicense(workspaceId: string, licenseKey: string) {
const installedLicense = await this.getLicense(workspaceId);
@@ -177,35 +208,8 @@ export class LicenseService {
const validatedAt = new Date();
const expiresAt = new Date(data.expiresAt);
this.event.emit('workspace.subscription.activated', {
workspaceId,
plan: data.plan as SubscriptionPlan,
recurring: data.recurring as SubscriptionRecurring,
quantity: data.quantity,
});
await this.entitlement.upsertFromValidatedSelfhostLicense({
workspaceId,
licenseKey,
recurring: data.recurring as SubscriptionRecurring,
quantity: data.quantity,
expiresAt,
validatedAt,
validateKey: data.validateKey,
});
return this.db.installedLicense.upsert({
where: { workspaceId },
update: {
key: licenseKey,
quantity: data.quantity,
recurring: data.recurring as SubscriptionRecurring,
variant: null,
validateKey: data.validateKey,
validatedAt,
expiredAt: expiresAt,
license: null,
},
create: {
const installed = await this.db.installedLicense.create({
data: {
workspaceId,
key: licenseKey,
quantity: data.quantity,
@@ -217,6 +221,22 @@ export class LicenseService {
license: null,
},
});
await this.entitlement.upsertFromValidatedSelfhostLicense({
workspaceId,
licenseKey,
recurring: data.recurring as SubscriptionRecurring,
quantity: data.quantity,
expiresAt,
validatedAt,
validateKey: data.validateKey,
});
this.event.emit('workspace.subscription.activated', {
workspaceId,
plan: data.plan as SubscriptionPlan,
recurring: data.recurring as SubscriptionRecurring,
quantity: data.quantity,
});
return installed;
}
async removeTeamLicense(workspaceId: string) {
@@ -487,18 +507,33 @@ export class LicenseService {
if (!resolved.valid) {
valid = false;
} else {
const recurring = this.licenseRecurring(resolved);
const quantity = this.licenseQuantity(resolved);
const expiresAt = this.licenseExpiresAt(resolved);
const validatedAt = new Date();
await this.db.installedLicense.update({
where: { key: license.key },
data: {
recurring,
quantity,
expiredAt: expiresAt,
validatedAt,
},
});
this.event.emit('workspace.subscription.activated', {
workspaceId: license.workspaceId,
plan: SubscriptionPlan.SelfHostedTeam,
recurring: this.licenseRecurring(resolved),
quantity: this.licenseQuantity(resolved),
recurring,
quantity,
});
await this.entitlement.upsertFromSelfhostLicense({
workspaceId: license.workspaceId,
recurring: this.licenseRecurring(resolved),
quantity: this.licenseQuantity(resolved),
expiresAt: this.licenseExpiresAt(resolved),
validatedAt: new Date(),
recurring,
quantity,
expiresAt,
validatedAt,
variant: SubscriptionVariant.Onetime,
license: Buffer.from(buf),
});
}
@@ -105,21 +105,22 @@ export class SubscriptionCronJobs {
const { start: before180DaysStart, end: before180DaysEnd } =
this.getDateRange(-180);
const subscriptions = await this.db.subscription.findMany({
const subscriptions = await this.db.providerSubscription.findMany({
where: {
targetType: 'workspace',
plan: SubscriptionPlan.Team,
OR: [
{
// subscription will cancel after 30 days
status: 'active',
canceledAt: { not: null },
end: { gte: after30DayStart, lte: after30DayEnd },
periodEnd: { gte: after30DayStart, lte: after30DayEnd },
},
{
// subscription will cancel today
status: 'active',
canceledAt: { not: null },
end: { gte: todayStart, lte: todayEnd },
periodEnd: { gte: todayStart, lte: todayEnd },
},
{
// subscription has been canceled for 150 days
@@ -138,13 +139,13 @@ export class SubscriptionCronJobs {
});
for (const subscription of subscriptions) {
const end = subscription.end;
const end = subscription.periodEnd;
if (!end) {
// should not reach here
continue;
}
if (!subscription.nextBillAt) {
if (subscription.canceledAt) {
this.event.emit('workspace.subscription.notify', {
workspaceId: subscription.targetId,
expirationDate: end,
@@ -156,10 +157,14 @@ export class SubscriptionCronJobs {
@OnJob('nightly.cleanExpiredOnetimeSubscriptions')
async cleanExpiredOnetimeSubscriptions() {
const subscriptions = await this.db.subscription.findMany({
const subscriptions = await this.db.providerSubscription.findMany({
where: {
variant: SubscriptionVariant.Onetime,
end: {
targetType: 'user',
metadata: {
path: ['variant'],
equals: SubscriptionVariant.Onetime,
},
periodEnd: {
lte: new Date(),
},
},
@@ -170,21 +175,16 @@ export class SubscriptionCronJobs {
targetId: subscription.targetId,
plan: subscription.plan,
subscriptionId: subscription.id,
stripeSubscriptionId: subscription.stripeSubscriptionId,
stripeSubscriptionId: subscription.externalSubscriptionId,
});
await this.db.subscription.delete({
where: {
targetId_plan: {
targetId: subscription.targetId,
plan: subscription.plan,
},
},
await this.db.providerSubscription.delete({
where: { id: subscription.id },
});
this.event.emit('user.subscription.canceled', {
userId: subscription.targetId,
plan: subscription.plan as SubscriptionPlan,
recurring: subscription.variant as SubscriptionRecurring,
recurring: subscription.recurring as SubscriptionRecurring,
});
}
}
@@ -192,9 +192,10 @@ export class SubscriptionCronJobs {
@OnJob('nightly.reconcileRevenueCatSubscriptions')
async reconcileRevenueCatSubscriptions() {
// Find active/trialing/past_due RC subscriptions and resync via RC REST
const subs = await this.db.subscription.findMany({
const subs = await this.db.providerSubscription.findMany({
where: {
provider: Provider.revenuecat,
targetType: 'user',
status: {
in: [
SubscriptionStatus.Active,
@@ -287,10 +288,11 @@ export class SubscriptionCronJobs {
@OnJob('nightly.reconcileStripeSubscriptions')
async reconcileStripeSubscriptions() {
const stripe = this.stripeFactory.stripe;
const subs = await this.db.subscription.findMany({
const subs = await this.db.providerSubscription.findMany({
where: {
provider: Provider.stripe,
stripeSubscriptionId: { not: null },
externalSubscriptionId: { not: null },
recurring: { not: SubscriptionRecurring.Lifetime },
status: {
in: [
SubscriptionStatus.Active,
@@ -299,13 +301,13 @@ export class SubscriptionCronJobs {
],
},
},
select: { stripeSubscriptionId: true },
select: { externalSubscriptionId: true },
});
const subscriptionIds = Array.from(
new Set(
subs
.map(sub => sub.stripeSubscriptionId)
.map(sub => sub.externalSubscriptionId)
.filter((id): id is string => !!id)
)
);
@@ -11,7 +11,7 @@ import {
Post,
Res,
} from '@nestjs/common';
import { PrismaClient, Subscription } from '@prisma/client';
import { PrismaClient, Provider } from '@prisma/client';
import type { Response } from 'express';
import Stripe from 'stripe';
import { z } from 'zod';
@@ -24,6 +24,7 @@ import {
Mutex,
} from '../../../base';
import { Public } from '../../../core/auth';
import type { Subscription } from '../manager';
import { SelfhostTeamSubscriptionManager } from '../manager/selfhost';
import { SubscriptionService } from '../service';
import { StripeFactory } from '../stripe';
@@ -237,19 +238,23 @@ export class LicenseController {
@Post('/:license/create-customer-portal')
async createCustomerPortal(@Param('license') key: string) {
const subscription = await this.db.subscription.findFirst({
const subscription = await this.db.providerSubscription.findFirst({
where: {
provider: Provider.stripe,
targetType: 'instance',
targetId: key,
plan: SubscriptionPlan.SelfHostedTeam,
},
orderBy: { updatedAt: 'desc' },
});
if (!subscription || !subscription.stripeSubscriptionId) {
if (!subscription?.externalSubscriptionId) {
throw new LicenseNotFound();
}
const subscriptionData =
await this.stripeProvider.stripe.subscriptions.retrieve(
subscription.stripeSubscriptionId,
subscription.externalSubscriptionId,
{
expand: ['customer'],
}
@@ -1,4 +1,9 @@
import { type Prisma, PrismaClient, UserStripeCustomer } from '@prisma/client';
import {
type Prisma,
PrismaClient,
type ProviderSubscription,
UserStripeCustomer,
} from '@prisma/client';
import Stripe from 'stripe';
import { z } from 'zod';
@@ -19,13 +24,13 @@ import {
export function validSubscriptionPeriodWhere(
now = new Date()
): Prisma.SubscriptionWhereInput {
return { OR: [{ end: null }, { end: { gt: now } }] };
): Prisma.ProviderSubscriptionWhereInput {
return { OR: [{ periodEnd: null }, { periodEnd: { gt: now } }] };
}
export function activeSubscriptionWhere(
now = new Date()
): Prisma.SubscriptionWhereInput {
): Prisma.ProviderSubscriptionWhereInput {
return {
status: { in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing] },
...validSubscriptionPeriodWhere(now),
@@ -34,7 +39,7 @@ export function activeSubscriptionWhere(
export function visibleSubscriptionWhere(
now = new Date()
): Prisma.SubscriptionWhereInput {
): Prisma.ProviderSubscriptionWhereInput {
return {
status: {
in: [
@@ -98,10 +103,30 @@ export abstract class SubscriptionManager {
this.scheduleManager = new ScheduleManager(this.stripeProvider);
}
protected async patchProviderSubscriptionMetadata(
id: string,
patch: Prisma.InputJsonObject
) {
await this.db.$executeRaw`
UPDATE provider_subscriptions
SET metadata = metadata || ${JSON.stringify(patch)}::jsonb,
updated_at = CURRENT_TIMESTAMP
WHERE id = ${id}
`;
return this.db.providerSubscription.findUniqueOrThrow({ where: { id } });
}
get stripe() {
return this.stripeProvider.stripe;
}
protected requireStripeSubscriptionId(id: string | null) {
if (!id) {
throw new Error('Stripe subscription identity is missing.');
}
return id;
}
abstract filterPrices(
prices: KnownStripePrice[],
customer?: UserStripeCustomer
@@ -171,6 +196,42 @@ export abstract class SubscriptionManager {
};
}
transformProviderSubscription(
subscription: ProviderSubscription
): Subscription {
const metadata = subscription.metadata as Record<string, unknown>;
const nextBillAt = metadata.nextBillAt;
return {
stripeSubscriptionId:
subscription.provider === 'stripe' &&
subscription.recurring !== SubscriptionRecurring.Lifetime
? subscription.externalSubscriptionId
: null,
stripeScheduleId:
typeof metadata.stripeScheduleId === 'string'
? metadata.stripeScheduleId
: null,
status: subscription.status,
plan: subscription.plan,
recurring: subscription.recurring ?? SubscriptionRecurring.Monthly,
variant: typeof metadata.variant === 'string' ? metadata.variant : null,
quantity: subscription.quantity ?? 1,
start: subscription.periodStart ?? subscription.createdAt,
end: subscription.periodEnd,
trialStart: subscription.trialStart,
trialEnd: subscription.trialEnd,
nextBillAt: subscription.canceledAt
? null
: typeof nextBillAt === 'string'
? new Date(nextBillAt)
: subscription.periodEnd,
canceledAt: subscription.canceledAt,
provider: subscription.provider,
iapStore: subscription.iapStore,
};
}
async transformInvoice({
stripeInvoice,
}: KnownStripeInvoice): Promise<Invoice> {
@@ -1,8 +1,12 @@
import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { PrismaClient, Provider, UserStripeCustomer } from '@prisma/client';
import { omit } from 'lodash-es';
import {
Prisma,
PrismaClient,
Provider,
UserStripeCustomer,
} from '@prisma/client';
import { z } from 'zod';
import { SubscriptionPlanNotFound, URLHelper } from '../../../base';
@@ -119,70 +123,44 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
async saveStripeSubscription(subscription: KnownStripeSubscription) {
const { stripeSubscription, userEmail } = subscription;
const subscriptionData = this.transformSubscription(subscription);
const existingSubscription = await this.db.subscription.findFirst({
const existingSubscription = await this.db.providerSubscription.findUnique({
where: {
stripeSubscriptionId: stripeSubscription.id,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: stripeSubscription.id,
},
},
});
const key = existingSubscription?.targetId ?? randomUUID();
const saved = await this.db.$transaction(async db => {
const saved = await this.upsertStripeProviderSubscription(
key,
subscription,
subscriptionData,
db
);
await db.license.upsert({
where: { key: saved.targetId },
update: {},
create: { key: saved.targetId },
});
return saved;
});
if (!existingSubscription) {
const key = randomUUID();
const [saved] = await this.db.$transaction([
this.db.subscription.create({
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
data: {
provider: Provider.stripe,
targetId: key,
...omit(subscriptionData, 'provider', 'iapStore'),
},
}),
this.db.license.create({
data: { key },
}),
]);
await this.mailer.send({
name: 'TeamLicense',
to: userEmail,
props: { license: key },
props: { license: saved.targetId },
metadata: {
dedupeKey: `selfhost-license:${key}`,
dedupeKey: `selfhost-license:${saved.targetId}`,
source: { trusted: false },
},
});
await this.upsertStripeProviderSubscription(
key,
subscription,
subscriptionData
);
return saved;
} else {
const saved = await this.db.subscription.update({
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
where: {
stripeSubscriptionId: stripeSubscription.id,
},
data: {
...omit(subscriptionData, ['provider', 'iapStore']),
provider: Provider.stripe,
iapStore: null,
rcEntitlement: null,
rcProductId: null,
rcExternalRef: null,
},
});
await this.upsertStripeProviderSubscription(
saved.targetId,
subscription,
subscriptionData
);
return saved;
}
return this.transformProviderSubscription(saved);
}
async deleteStripeSubscription({
@@ -199,80 +177,111 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
periodEnd: new Date(),
},
});
const subscription = await this.db.subscription.findFirst({
where: { stripeSubscriptionId: stripeSubscription.id },
});
if (!subscription) {
return;
}
await this.db.$transaction([
this.db.subscription.deleteMany({
where: { stripeSubscriptionId: stripeSubscription.id },
}),
this.db.license.deleteMany({
where: { key: subscription.targetId },
}),
]);
}
getSubscription(identity: z.infer<typeof SelfhostTeamSubscriptionIdentity>) {
return this.db.subscription.findFirst({
where: { targetId: identity.key },
async getSubscription(
identity: z.infer<typeof SelfhostTeamSubscriptionIdentity>
) {
const subscription = await this.db.providerSubscription.findFirst({
where: {
provider: Provider.stripe,
targetType: 'instance',
targetId: identity.key,
plan: identity.plan,
},
orderBy: { updatedAt: 'desc' },
});
return subscription
? this.transformProviderSubscription(subscription)
: null;
}
getActiveSubscription(
identity: z.infer<typeof SelfhostTeamSubscriptionIdentity>
) {
return this.db.subscription.findFirst({
where: {
targetId: identity.key,
plan: identity.plan,
...activeSubscriptionWhere(),
},
});
return this.db.providerSubscription
.findFirst({
where: {
provider: Provider.stripe,
targetType: 'instance',
targetId: identity.key,
plan: identity.plan,
...activeSubscriptionWhere(),
},
orderBy: { updatedAt: 'desc' },
})
.then(subscription =>
subscription ? this.transformProviderSubscription(subscription) : null
);
}
async cancelSubscription(subscription: Subscription) {
return await this.db.subscription.update({
const current = await this.db.providerSubscription.findUniqueOrThrow({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
});
await this.db.providerSubscription.update({
where: { id: current.id },
data: {
canceledAt: new Date(),
nextBillAt: null,
},
});
const saved = await this.patchProviderSubscriptionMetadata(current.id, {
variant: subscription.variant,
stripeScheduleId: subscription.stripeScheduleId,
nextBillAt: null,
});
return this.transformProviderSubscription(saved);
}
resumeSubscription(subscription: Subscription): Promise<Subscription> {
return this.db.subscription.update({
async resumeSubscription(subscription: Subscription) {
const current = await this.db.providerSubscription.findUniqueOrThrow({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
},
data: {
canceledAt: null,
nextBillAt: subscription.end,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
});
await this.db.providerSubscription.update({
where: { id: current.id },
data: {
canceledAt: null,
},
});
const saved = await this.patchProviderSubscriptionMetadata(current.id, {
variant: subscription.variant,
stripeScheduleId: subscription.stripeScheduleId,
nextBillAt: subscription.end?.toISOString() ?? null,
});
return this.transformProviderSubscription(saved);
}
updateSubscriptionRecurring(
subscription: Subscription,
recurring: SubscriptionRecurring
): Promise<Subscription> {
return this.db.subscription.update({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
},
data: { recurring },
});
) {
return this.db.providerSubscription
.update({
where: {
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
data: { recurring },
})
.then(subscription => this.transformProviderSubscription(subscription));
}
async saveInvoice(knownInvoice: KnownStripeInvoice): Promise<Invoice> {
@@ -284,12 +293,13 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
private async upsertStripeProviderSubscription(
targetId: string,
known: KnownStripeSubscription,
subscriptionData: Subscription
subscriptionData: Subscription,
db: Prisma.TransactionClient = this.db
) {
const { lookupKey, stripeSubscription } = known;
const price = stripeSubscription.items.data[0]?.price;
await this.db.providerSubscription.upsert({
return db.providerSubscription.upsert({
where: {
provider_externalSubscriptionId: {
provider: Provider.stripe,
@@ -297,8 +307,6 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
},
},
update: {
targetType: 'instance',
targetId,
plan: lookupKey.plan,
recurring: lookupKey.recurring,
status: stripeSubscription.status,
@@ -319,7 +327,12 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
trialStart: subscriptionData.trialStart,
trialEnd: subscriptionData.trialEnd,
canceledAt: subscriptionData.canceledAt,
metadata: known.metadata,
metadata: {
...known.metadata,
variant: subscriptionData.variant,
stripeScheduleId: subscriptionData.stripeScheduleId,
nextBillAt: subscriptionData.nextBillAt?.toISOString() ?? null,
},
},
create: {
provider: Provider.stripe,
@@ -346,7 +359,12 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
trialStart: subscriptionData.trialStart,
trialEnd: subscriptionData.trialEnd,
canceledAt: subscriptionData.canceledAt,
metadata: known.metadata,
metadata: {
...known.metadata,
variant: subscriptionData.variant,
stripeScheduleId: subscriptionData.stripeScheduleId,
nextBillAt: subscriptionData.nextBillAt?.toISOString() ?? null,
},
},
});
}
@@ -197,32 +197,47 @@ export class UserSubscriptionManager extends SubscriptionManager {
}
async getSubscription(args: z.infer<typeof UserSubscriptionIdentity>) {
return this.db.subscription.findFirst({
const subscription = await this.db.providerSubscription.findFirst({
where: {
targetType: 'user',
targetId: args.userId,
plan: args.plan,
},
orderBy: { updatedAt: 'desc' },
});
return subscription
? this.transformProviderSubscription(subscription)
: null;
}
async getActiveSubscription(args: z.infer<typeof UserSubscriptionIdentity>) {
return this.db.subscription.findFirst({
const subscription = await this.db.providerSubscription.findFirst({
where: {
targetType: 'user',
targetId: args.userId,
plan: args.plan,
...activeSubscriptionWhere(),
},
orderBy: { updatedAt: 'desc' },
});
return subscription
? this.transformProviderSubscription(subscription)
: null;
}
async getVisibleSubscription(args: z.infer<typeof UserSubscriptionIdentity>) {
return this.db.subscription.findFirst({
const subscription = await this.db.providerSubscription.findFirst({
where: {
targetType: 'user',
targetId: args.userId,
plan: args.plan,
...visibleSubscriptionWhere(),
},
orderBy: { updatedAt: 'desc' },
});
return subscription
? this.transformProviderSubscription(subscription)
: null;
}
async saveStripeSubscription(subscription: KnownStripeSubscription) {
@@ -249,7 +264,10 @@ export class UserSubscriptionManager extends SubscriptionManager {
}
const subscriptionData = this.transformSubscription(subscription);
await this.upsertStripeProviderSubscription(subscription, subscriptionData);
const saved = await this.upsertStripeProviderSubscription(
subscription,
subscriptionData
);
if (
lookupKey.plan === SubscriptionPlan.AI &&
@@ -265,41 +283,13 @@ export class UserSubscriptionManager extends SubscriptionManager {
});
}
const existingByStripeId = await this.db.subscription.findUnique({
where: { stripeSubscriptionId: stripeSubscription.id },
const result = this.transformProviderSubscription(saved);
await this.entitlement.upsertFromCloudSubscription({
...result,
targetId: saved.targetId,
subscriptionId: saved.id,
});
const saved = existingByStripeId
? await this.db.subscription.update({
where: { id: existingByStripeId.id },
data: {
...omit(subscriptionData, ['provider', 'iapStore']),
provider: Provider.stripe,
iapStore: null,
rcEntitlement: null,
rcProductId: null,
rcExternalRef: null,
},
})
: await this.db.subscription.upsert({
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
// TODO(stable-upgrade): remove reliance on target_id_plan unique slot after contract cleanup.
where: { targetId_plan: { targetId: userId, plan: lookupKey.plan } },
update: {
...omit(subscriptionData, ['provider', 'iapStore']),
provider: Provider.stripe,
iapStore: null,
rcEntitlement: null,
rcProductId: null,
rcExternalRef: null,
},
create: {
targetId: userId,
...omit(subscriptionData, ['provider', 'iapStore']),
},
});
await this.entitlement.upsertFromCloudSubscription(saved);
return saved;
return result;
}
async deleteStripeSubscription({
@@ -308,7 +298,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
stripeSubscription,
}: KnownStripeSubscription) {
this.assertUserIdExists(userId);
await this.db.providerSubscription.updateMany({
const result = await this.db.providerSubscription.updateMany({
where: {
provider: Provider.stripe,
externalSubscriptionId: stripeSubscription.id,
@@ -319,11 +309,6 @@ export class UserSubscriptionManager extends SubscriptionManager {
periodEnd: new Date(),
},
});
const result = await this.db.subscription.deleteMany({
where: {
stripeSubscriptionId: stripeSubscription.id,
},
});
if (result.count > 0) {
await this.entitlement.revokeCloudSubscription({
@@ -340,42 +325,85 @@ export class UserSubscriptionManager extends SubscriptionManager {
}
async cancelSubscription(subscription: Subscription) {
return this.db.subscription.update({
const current = await this.db.providerSubscription.findUniqueOrThrow({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
});
await this.db.providerSubscription.update({
where: {
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
data: {
canceledAt: new Date(),
nextBillAt: null,
},
});
const saved = await this.patchProviderSubscriptionMetadata(current.id, {
variant: subscription.variant,
stripeScheduleId: subscription.stripeScheduleId,
nextBillAt: null,
});
return this.transformProviderSubscription(saved);
}
async resumeSubscription(subscription: Subscription) {
return this.db.subscription.update({
const current = await this.db.providerSubscription.findUniqueOrThrow({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
});
await this.db.providerSubscription.update({
where: {
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
data: {
canceledAt: null,
nextBillAt: subscription.end,
},
});
const saved = await this.patchProviderSubscriptionMetadata(current.id, {
variant: subscription.variant,
stripeScheduleId: subscription.stripeScheduleId,
nextBillAt: subscription.end?.toISOString() ?? null,
});
return this.transformProviderSubscription(saved);
}
async updateSubscriptionRecurring(
subscription: Subscription,
recurring: SubscriptionRecurring
) {
return this.db.subscription.update({
const saved = await this.db.providerSubscription.update({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
data: { recurring },
});
return this.transformProviderSubscription(saved);
}
async saveInvoice(knownInvoice: KnownStripeInvoice) {
@@ -418,60 +446,54 @@ export class UserSubscriptionManager extends SubscriptionManager {
async saveLifetimeSubscription(knownInvoice: KnownStripeInvoice) {
this.assertUserIdExists(knownInvoice.userId);
// cancel previous non-lifetime subscription
const prevSubscription = await this.db.subscription.findUnique({
const prevSubscription = await this.db.providerSubscription.findFirst({
where: {
targetId_plan: {
targetId: knownInvoice.userId,
plan: SubscriptionPlan.Pro,
},
provider: Provider.stripe,
targetType: 'user',
targetId: knownInvoice.userId,
plan: SubscriptionPlan.Pro,
recurring: { not: SubscriptionRecurring.Lifetime },
...activeSubscriptionWhere(),
},
orderBy: { updatedAt: 'desc' },
});
if (prevSubscription) {
if (prevSubscription.stripeSubscriptionId) {
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
const subscription = await this.db.subscription.update({
where: {
id: prevSubscription.id,
},
data: {
stripeScheduleId: null,
stripeSubscriptionId: null,
plan: knownInvoice.lookupKey.plan,
recurring: SubscriptionRecurring.Lifetime,
start: new Date(),
end: null,
status: SubscriptionStatus.Active,
nextBillAt: null,
},
});
await this.entitlement.upsertFromCloudSubscription(subscription);
await this.stripe.subscriptions.cancel(
prevSubscription.stripeSubscriptionId,
{
prorate: true,
}
);
}
} else {
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
const subscription = await this.db.subscription.create({
if (prevSubscription?.externalSubscriptionId) {
await this.stripe.subscriptions.cancel(
prevSubscription.externalSubscriptionId,
{ prorate: true },
{
idempotencyKey: `lifetime:${knownInvoice.stripeInvoice.id}:cancel:${prevSubscription.id}`,
}
);
const now = new Date();
await this.db.providerSubscription.update({
where: { id: prevSubscription.id },
data: {
targetId: knownInvoice.userId,
stripeSubscriptionId: null,
plan: knownInvoice.lookupKey.plan,
recurring: SubscriptionRecurring.Lifetime,
start: new Date(),
end: null,
status: SubscriptionStatus.Active,
nextBillAt: null,
status: SubscriptionStatus.Canceled,
canceledAt: now,
periodEnd: now,
},
});
await this.entitlement.upsertFromCloudSubscription(subscription);
await this.patchProviderSubscriptionMetadata(prevSubscription.id, {
nextBillAt: null,
});
await this.entitlement.revokeCloudSubscription({
targetId: knownInvoice.userId,
plan: prevSubscription.plan,
subscriptionId: prevSubscription.id,
stripeSubscriptionId: prevSubscription.externalSubscriptionId,
});
}
const saved = await this.upsertLifetimeProviderSubscription(knownInvoice);
await this.entitlement.upsertFromCloudSubscription({
...this.transformProviderSubscription(saved),
targetId: saved.targetId,
subscriptionId: saved.id,
stripeSubscriptionId: saved.externalSubscriptionId,
});
this.event.emit('user.subscription.activated', {
userId: knownInvoice.userId,
plan: knownInvoice.lookupKey.plan,
@@ -483,11 +505,12 @@ export class UserSubscriptionManager extends SubscriptionManager {
this.assertUserIdExists(knownInvoice.userId);
const { userId, lookupKey } = knownInvoice;
const subscription = await this.db.subscription.findFirst({
const subscription = await this.db.providerSubscription.findUnique({
where: {
targetId: userId,
plan: lookupKey.plan,
provider: Provider.stripe,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.lifetimeExternalId(knownInvoice),
},
},
});
@@ -495,22 +518,24 @@ export class UserSubscriptionManager extends SubscriptionManager {
return;
}
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
await this.db.subscription.update({
await this.db.providerSubscription.update({
where: {
id: subscription.id,
},
data: {
status: SubscriptionStatus.Canceled,
nextBillAt: null,
canceledAt: new Date(),
},
});
const saved = await this.patchProviderSubscriptionMetadata(
subscription.id,
{ nextBillAt: null }
);
await this.entitlement.revokeCloudSubscription({
targetId: userId,
plan: lookupKey.plan,
subscriptionId: subscription.id,
stripeSubscriptionId: subscription.stripeSubscriptionId,
subscriptionId: saved.id,
stripeSubscriptionId: saved.externalSubscriptionId,
});
this.event.emit('user.subscription.canceled', {
@@ -524,48 +549,22 @@ export class UserSubscriptionManager extends SubscriptionManager {
this.assertUserIdExists(knownInvoice.userId);
const { userId, lookupKey, stripeInvoice } = knownInvoice;
const subscription = await this.db.subscription.findFirst({
where: {
targetId: userId,
plan: lookupKey.plan,
provider: Provider.stripe,
},
});
const start =
stripeInvoice.lines.data[0]?.period?.start ??
(typeof stripeInvoice.created === 'number'
? stripeInvoice.created
: Date.now() / 1000);
if (subscription) {
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
const saved = await this.db.subscription.update({
where: { id: subscription.id },
data: {
status: SubscriptionStatus.Active,
canceledAt: null,
nextBillAt: null,
start: subscription.start ?? new Date(start * 1000),
end: null,
},
});
await this.entitlement.upsertFromCloudSubscription(saved);
} else {
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
const saved = await this.db.subscription.create({
data: {
targetId: userId,
stripeSubscriptionId: null,
...lookupKey,
start: new Date(start * 1000),
end: null,
status: SubscriptionStatus.Active,
nextBillAt: null,
},
});
await this.entitlement.upsertFromCloudSubscription(saved);
}
const saved = await this.upsertLifetimeProviderSubscription(
knownInvoice,
new Date(start * 1000)
);
await this.entitlement.upsertFromCloudSubscription({
...this.transformProviderSubscription(saved),
targetId: saved.targetId,
subscriptionId: saved.id,
stripeSubscriptionId: saved.externalSubscriptionId,
});
this.event.emit('user.subscription.activated', {
userId,
@@ -696,8 +695,14 @@ export class UserSubscriptionManager extends SubscriptionManager {
const { userId, lookupKey, stripeSubscription } = known;
this.assertUserIdExists(userId);
const price = stripeSubscription.items.data[0]?.price;
const metadata = {
...known.metadata,
variant: lookupKey.variant,
stripeScheduleId: subscriptionData.stripeScheduleId,
nextBillAt: subscriptionData.nextBillAt?.toISOString() ?? null,
};
await this.db.providerSubscription.upsert({
return this.db.providerSubscription.upsert({
where: {
provider_externalSubscriptionId: {
provider: Provider.stripe,
@@ -727,7 +732,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
trialStart: subscriptionData.trialStart,
trialEnd: subscriptionData.trialEnd,
canceledAt: subscriptionData.canceledAt,
metadata: known.metadata,
metadata,
},
create: {
provider: Provider.stripe,
@@ -754,7 +759,61 @@ export class UserSubscriptionManager extends SubscriptionManager {
trialStart: subscriptionData.trialStart,
trialEnd: subscriptionData.trialEnd,
canceledAt: subscriptionData.canceledAt,
metadata: known.metadata,
metadata,
},
});
}
private lifetimeExternalId(knownInvoice: KnownStripeInvoice) {
return `stripe_invoice:${knownInvoice.stripeInvoice.id}`;
}
private upsertLifetimeProviderSubscription(
knownInvoice: KnownStripeInvoice,
start = new Date()
) {
const { userId, lookupKey } = knownInvoice;
this.assertUserIdExists(userId);
const externalSubscriptionId = this.lifetimeExternalId(knownInvoice);
const metadata = {
...knownInvoice.metadata,
variant: lookupKey.variant,
stripeScheduleId: null,
nextBillAt: null,
lifetimeInvoiceId: knownInvoice.stripeInvoice.id,
};
return this.db.providerSubscription.upsert({
where: {
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId,
},
},
update: {
targetType: 'user',
targetId: userId,
plan: lookupKey.plan,
recurring: SubscriptionRecurring.Lifetime,
status: SubscriptionStatus.Active,
quantity: 1,
periodStart: start,
periodEnd: null,
canceledAt: null,
metadata,
},
create: {
provider: Provider.stripe,
targetType: 'user',
targetId: userId,
plan: lookupKey.plan,
recurring: SubscriptionRecurring.Lifetime,
status: SubscriptionStatus.Active,
externalSubscriptionId,
quantity: 1,
periodStart: start,
periodEnd: null,
metadata,
},
});
}
@@ -896,14 +955,26 @@ export class UserSubscriptionManager extends SubscriptionManager {
@OnEvent('user.deleted')
async onUserDeleted({ id }: Events['user.deleted']) {
const subscription = await this.db.subscription.findFirst({
const subscriptions = await this.db.providerSubscription.findMany({
where: {
provider: Provider.stripe,
targetType: 'user',
targetId: id,
recurring: { not: SubscriptionRecurring.Lifetime },
externalSubscriptionId: { not: null },
...activeSubscriptionWhere(),
},
});
if (subscription?.stripeSubscriptionId) {
await this.stripe.subscriptions.cancel(subscription.stripeSubscriptionId);
}
await Promise.all(
subscriptions.map(subscription =>
this.stripe.subscriptions.cancel(
this.requireStripeSubscriptionId(subscription.externalSubscriptionId)
)
)
);
await this.db.providerSubscription.deleteMany({
where: { targetType: 'user', targetId: id },
});
}
}
@@ -139,7 +139,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
}
const subscriptionData = this.transformSubscription(subscription);
await this.upsertStripeProviderSubscription(
const saved = await this.upsertStripeProviderSubscription(
workspaceId,
subscription,
subscriptionData
@@ -163,46 +163,13 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
});
}
const existingByStripeId = await this.db.subscription.findUnique({
where: { stripeSubscriptionId: stripeSubscription.id },
const result = this.transformProviderSubscription(saved);
await this.entitlement.upsertFromCloudSubscription({
...result,
targetId: saved.targetId,
subscriptionId: saved.id,
});
const saved = existingByStripeId
? await this.db.subscription.update({
where: { id: existingByStripeId.id },
data: {
...omit(subscriptionData, ['provider', 'iapStore']),
provider: Provider.stripe,
iapStore: null,
rcEntitlement: null,
rcProductId: null,
rcExternalRef: null,
},
})
: await this.db.subscription.upsert({
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
// TODO(stable-upgrade): remove reliance on target_id_plan unique slot after contract cleanup.
where: {
targetId_plan: {
targetId: workspaceId,
plan: lookupKey.plan,
},
},
update: {
...omit(subscriptionData, ['provider', 'iapStore']),
provider: Provider.stripe,
iapStore: null,
rcEntitlement: null,
rcProductId: null,
rcExternalRef: null,
},
create: {
targetId: workspaceId,
...omit(subscriptionData, 'provider', 'iapStore'),
},
});
await this.entitlement.upsertFromCloudSubscription(saved);
return saved;
return result;
}
async deleteStripeSubscription({
@@ -217,7 +184,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
);
}
await this.db.providerSubscription.updateMany({
const result = await this.db.providerSubscription.updateMany({
where: {
provider: Provider.stripe,
externalSubscriptionId: stripeSubscription.id,
@@ -228,10 +195,6 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
periodEnd: new Date(),
},
});
const result = await this.db.subscription.deleteMany({
where: { stripeSubscriptionId: stripeSubscription.id },
});
if (result.count > 0) {
await this.entitlement.revokeCloudSubscription({
targetId: workspaceId,
@@ -247,61 +210,104 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
}
getSubscription(identity: z.infer<typeof WorkspaceSubscriptionIdentity>) {
return this.db.subscription.findFirst({
where: {
targetId: identity.workspaceId,
},
});
return this.db.providerSubscription
.findFirst({
where: {
targetType: 'workspace',
targetId: identity.workspaceId,
plan: identity.plan,
},
orderBy: { updatedAt: 'desc' },
})
.then(subscription =>
subscription ? this.transformProviderSubscription(subscription) : null
);
}
getActiveSubscription(
identity: z.infer<typeof WorkspaceSubscriptionIdentity>
) {
return this.db.subscription.findFirst({
where: {
targetId: identity.workspaceId,
...activeSubscriptionWhere(),
},
});
return this.db.providerSubscription
.findFirst({
where: {
targetType: 'workspace',
targetId: identity.workspaceId,
plan: identity.plan,
...activeSubscriptionWhere(),
},
orderBy: { updatedAt: 'desc' },
})
.then(subscription =>
subscription ? this.transformProviderSubscription(subscription) : null
);
}
async cancelSubscription(subscription: Subscription) {
return await this.db.subscription.update({
const current = await this.db.providerSubscription.findUniqueOrThrow({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
});
await this.db.providerSubscription.update({
where: { id: current.id },
data: {
canceledAt: new Date(),
nextBillAt: null,
},
});
const saved = await this.patchProviderSubscriptionMetadata(current.id, {
variant: subscription.variant,
stripeScheduleId: subscription.stripeScheduleId,
nextBillAt: null,
});
return this.transformProviderSubscription(saved);
}
resumeSubscription(subscription: Subscription): Promise<Subscription> {
return this.db.subscription.update({
async resumeSubscription(subscription: Subscription) {
const current = await this.db.providerSubscription.findUniqueOrThrow({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
});
await this.db.providerSubscription.update({
where: { id: current.id },
data: {
canceledAt: null,
nextBillAt: subscription.end,
},
});
const saved = await this.patchProviderSubscriptionMetadata(current.id, {
variant: subscription.variant,
stripeScheduleId: subscription.stripeScheduleId,
nextBillAt: subscription.end?.toISOString() ?? null,
});
return this.transformProviderSubscription(saved);
}
updateSubscriptionRecurring(
async updateSubscriptionRecurring(
subscription: Subscription,
recurring: SubscriptionRecurring
): Promise<Subscription> {
return this.db.subscription.update({
) {
const saved = await this.db.providerSubscription.update({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
data: { recurring },
});
return this.transformProviderSubscription(saved);
}
async saveInvoice(knownInvoice: KnownStripeInvoice): Promise<Invoice> {
@@ -379,8 +385,14 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
) {
const { lookupKey, stripeSubscription } = known;
const price = stripeSubscription.items.data[0]?.price;
const metadata = {
...known.metadata,
variant: lookupKey.variant,
stripeScheduleId: subscriptionData.stripeScheduleId,
nextBillAt: subscriptionData.nextBillAt?.toISOString() ?? null,
};
await this.db.providerSubscription.upsert({
return this.db.providerSubscription.upsert({
where: {
provider_externalSubscriptionId: {
provider: Provider.stripe,
@@ -410,7 +422,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
trialStart: subscriptionData.trialStart,
trialEnd: subscriptionData.trialEnd,
canceledAt: subscriptionData.canceledAt,
metadata: known.metadata,
metadata,
},
create: {
provider: Provider.stripe,
@@ -437,7 +449,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
trialStart: subscriptionData.trialStart,
trialEnd: subscriptionData.trialEnd,
canceledAt: subscriptionData.canceledAt,
metadata: known.metadata,
metadata,
},
});
}
@@ -511,13 +511,18 @@ export class UserSubscriptionResolver {
variant?: string | null;
stripeSubscriptionId?: string | null;
};
const providerMetadata = providerFact?.metadata as {
variant?: string | null;
stripeScheduleId?: string | null;
nextBillAt?: string | null;
} | null;
return this.normalizeSubscription({
stripeSubscriptionId:
providerFact?.externalSubscriptionId ??
metadata.stripeSubscriptionId ??
null,
stripeScheduleId: null,
stripeScheduleId: providerMetadata?.stripeScheduleId ?? null,
status: providerFact?.status ?? this.subscriptionStatus(entitlement),
plan,
recurring:
@@ -527,16 +532,24 @@ export class UserSubscriptionResolver {
? SubscriptionRecurring.Lifetime
: SubscriptionRecurring.Monthly),
variant:
providerMetadata?.variant ??
metadata.variant ??
(entitlement.plan === 'lifetime_pro'
? SubscriptionVariant.Onetime
: null),
quantity: entitlement.quantity ?? 1,
start: entitlement.startsAt ?? entitlement.createdAt,
end: entitlement.expiresAt,
quantity: providerFact?.quantity ?? entitlement.quantity ?? 1,
start:
providerFact?.periodStart ??
entitlement.startsAt ??
entitlement.createdAt,
end: providerFact?.periodEnd ?? entitlement.expiresAt,
trialStart: providerFact?.trialStart ?? null,
trialEnd: providerFact?.trialEnd ?? entitlement.graceUntil,
nextBillAt: providerFact?.periodEnd ?? entitlement.expiresAt,
nextBillAt: providerMetadata?.nextBillAt
? new Date(providerMetadata.nextBillAt)
: providerFact?.canceledAt
? null
: (providerFact?.periodEnd ?? entitlement.expiresAt),
canceledAt: providerFact?.canceledAt ?? null,
provider: providerFact?.provider ?? metadata.provider ?? null,
iapStore: providerFact?.iapStore ?? null,
@@ -613,8 +626,11 @@ export class UserSubscriptionResolver {
throw new AuthenticationRequired();
}
let existsSubscription = await this.db.subscription.findFirst({
where: { rcExternalRef: transactionId },
const existsSubscription = await this.db.providerSubscription.findFirst({
where: {
provider: Provider.revenuecat,
externalRef: transactionId,
},
});
// subscription with the transactionId already exists
@@ -622,8 +638,7 @@ export class UserSubscriptionResolver {
if (existsSubscription.targetId !== user.id) {
throw new InvalidSubscriptionParameters();
} else {
this.normalizeSubscription(existsSubscription);
return [existsSubscription];
return this.currentUserSubscriptions(user.id);
}
}
@@ -649,8 +664,9 @@ export class UserSubscriptionResolver {
throw new AuthenticationRequired();
}
let current = await this.db.subscription.findMany({
const current = await this.db.providerSubscription.findMany({
where: {
targetType: 'user',
targetId: user.id,
...visibleSubscriptionWhere(),
},
@@ -727,13 +743,18 @@ export class WorkspaceSubscriptionResolver {
variant?: string | null;
stripeSubscriptionId?: string | null;
};
const providerMetadata = providerFact?.metadata as {
variant?: string | null;
stripeScheduleId?: string | null;
nextBillAt?: string | null;
} | null;
return {
stripeSubscriptionId:
providerFact?.externalSubscriptionId ??
metadata.stripeSubscriptionId ??
null,
stripeScheduleId: null,
stripeScheduleId: providerMetadata?.stripeScheduleId ?? null,
status:
providerFact?.status ??
(entitlement.status === 'grace'
@@ -744,13 +765,20 @@ export class WorkspaceSubscriptionResolver {
providerFact?.recurring ??
metadata.recurring ??
SubscriptionRecurring.Monthly,
variant: metadata.variant ?? null,
quantity: entitlement.quantity ?? 1,
start: entitlement.startsAt ?? entitlement.createdAt,
end: entitlement.expiresAt,
variant: providerMetadata?.variant ?? metadata.variant ?? null,
quantity: providerFact?.quantity ?? entitlement.quantity ?? 1,
start:
providerFact?.periodStart ??
entitlement.startsAt ??
entitlement.createdAt,
end: providerFact?.periodEnd ?? entitlement.expiresAt,
trialStart: providerFact?.trialStart ?? null,
trialEnd: providerFact?.trialEnd ?? entitlement.graceUntil,
nextBillAt: providerFact?.periodEnd ?? entitlement.expiresAt,
nextBillAt: providerMetadata?.nextBillAt
? new Date(providerMetadata.nextBillAt)
: providerFact?.canceledAt
? null
: (providerFact?.periodEnd ?? entitlement.expiresAt),
canceledAt: providerFact?.canceledAt ?? null,
provider: providerFact?.provider ?? metadata.provider ?? null,
iapStore: providerFact?.iapStore ?? null,
@@ -100,6 +100,7 @@ const zRcV2RawCustomerAliasEnvelope = z
// v2 minimal, simplified structure exposed to callers
export const Subscription = z.object({
externalRef: z.string().optional(),
identifier: z.string(),
isTrial: z.boolean(),
isActive: z.boolean(),
@@ -351,6 +352,7 @@ export class RevenueCatService {
}
return {
externalRef: sub.id,
identifier: product.display_name,
isTrial: sub.status === 'trialing',
isActive:
@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { IapStore, PrismaClient, Provider } from '@prisma/client';
import {
@@ -18,12 +19,21 @@ import {
SubscriptionStatus,
} from '../types';
import { RcEvent } from './controller';
import { ProductMapping, resolveProductMapping } from './map';
import { resolveProductMapping } from './map';
import { RevenueCatService, Subscription } from './service';
const REFRESH_INTERVAL = 5 * 1000; // 5 seconds
const REFRESH_MAX_TIMES = 10 * OneMinute;
function isLegacyIdentityPlaceholder(metadata: Prisma.JsonValue) {
return (
typeof metadata === 'object' &&
metadata !== null &&
!Array.isArray(metadata) &&
metadata.legacyRevenueCatIdentityIncomplete === true
);
}
@Injectable()
export class RevenueCatWebhookHandler {
private readonly logger = new Logger(RevenueCatWebhookHandler.name);
@@ -114,20 +124,18 @@ export class RevenueCatWebhookHandler {
externalRef?: string,
overrideExpirationDate?: Date
): Promise<boolean> {
const cond = { targetId: appUserId, provider: Provider.revenuecat };
const toBeCleanup = await this.db.subscription.findMany({
where: cond,
const toBeCleanup = await this.db.providerSubscription.findMany({
where: {
provider: Provider.revenuecat,
targetType: 'user',
targetId: appUserId,
},
});
const productOverride = this.config.payment.revenuecat?.productMap;
const removeExists = (mapping: ProductMapping, sub: Subscription) => {
// Remove from cleanup list
const index = toBeCleanup.findIndex(s => {
return (
s.targetId === appUserId &&
s.rcProductId === sub.productId &&
s.plan === mapping.plan
);
});
const removeExists = (id: string) => {
const index = toBeCleanup.findIndex(
subscription => subscription.id === id
);
if (index >= 0) {
toBeCleanup.splice(index, 1);
}
@@ -159,65 +167,77 @@ export class RevenueCatWebhookHandler {
overrideExpirationDate
);
const rcExternalRef = externalRef || this.pickExternalRef(event);
// Upsert by unique (targetId, plan) for idempotency
const rcExternalRef =
externalRef || sub.externalRef || this.pickExternalRef(event);
if (!rcExternalRef || !iapStore) {
this.logger.warn('RevenueCat subscription missing external identity', {
subscription: sub,
});
continue;
}
const start = sub.latestPurchaseDate || new Date();
const end = overrideExpirationDate || sub.expirationDate || null;
const nextBillAt = end; // period end serves as next bill anchor for IAP
if (rcExternalRef && iapStore) {
await this.db.providerSubscription.upsert({
where: {
provider_iapStore_externalRef_externalProductId_externalCustomerId:
{
provider: Provider.revenuecat,
iapStore,
externalRef: rcExternalRef,
externalProductId: sub.productId,
externalCustomerId: sub.customerId,
},
},
update: {
targetType: 'user',
targetId: appUserId,
plan: mapping.plan,
recurring: mapping.recurring,
status,
externalCustomerId: sub.customerId,
externalProductId: sub.productId,
iapStore,
externalRef: rcExternalRef,
periodStart: start,
periodEnd: end,
canceledAt,
metadata: {
entitlement: sub.identifier,
isTrial: sub.isTrial,
willRenew: sub.willRenew,
const matched = await this.db.providerSubscription.findFirst({
where: {
provider: Provider.revenuecat,
iapStore,
externalRef: rcExternalRef,
externalProductId: sub.productId,
externalCustomerId: sub.customerId,
},
});
const existing =
matched ??
toBeCleanup.find(
subscription =>
subscription.plan === mapping.plan &&
isLegacyIdentityPlaceholder(subscription.metadata)
);
const data = {
targetType: 'user',
targetId: appUserId,
plan: mapping.plan,
recurring: mapping.recurring,
status,
quantity: 1,
externalCustomerId: sub.customerId,
externalProductId: sub.productId,
iapStore,
externalRef: rcExternalRef,
periodStart: start,
periodEnd: end,
trialStart: sub.isTrial ? start : null,
trialEnd: sub.isTrial ? end : null,
canceledAt: canceledAt ?? null,
metadata: {
entitlement: sub.identifier,
isTrial: sub.isTrial,
willRenew: sub.willRenew,
},
};
const saved = existing
? await this.db.providerSubscription.update({
where: { id: existing.id },
data,
})
: await this.db.providerSubscription.upsert({
where: {
provider_iapStore_externalRef_externalProductId_externalCustomerId:
{
provider: Provider.revenuecat,
iapStore,
externalRef: rcExternalRef,
externalProductId: sub.productId,
externalCustomerId: sub.customerId,
},
},
},
create: {
provider: Provider.revenuecat,
targetType: 'user',
targetId: appUserId,
plan: mapping.plan,
recurring: mapping.recurring,
status,
externalCustomerId: sub.customerId,
externalProductId: sub.productId,
iapStore,
externalRef: rcExternalRef,
periodStart: start,
periodEnd: end,
canceledAt,
metadata: {
entitlement: sub.identifier,
isTrial: sub.isTrial,
willRenew: sub.willRenew,
update: data,
create: {
provider: Provider.revenuecat,
...data,
},
},
});
}
});
removeExists(saved.id);
if (mapping.plan === SubscriptionPlan.AI && sub.isTrial) {
await this.db.subscriptionTrialUsage.upsert({
@@ -245,8 +265,10 @@ export class RevenueCatWebhookHandler {
}
// Mutual exclusion: skip if Stripe already active for the same plan
const conflict = await this.db.subscription.findFirst({
const conflicts = await this.db.providerSubscription.findMany({
where: {
id: { not: saved.id },
targetType: 'user',
targetId: appUserId,
plan: mapping.plan,
status: {
@@ -254,13 +276,21 @@ export class RevenueCatWebhookHandler {
},
},
});
const conflict = conflicts.find(
subscription => !isLegacyIdentityPlaceholder(subscription.metadata)
);
if (conflict) {
await this.entitlement.revokeCloudSubscription({
targetId: appUserId,
plan: mapping.plan,
subscriptionId: saved.id,
});
if (conflict.provider === Provider.stripe) {
this.logger.warn(
`Skip RC upsert: Stripe active exists. user=${appUserId} plan=${mapping.plan}`
);
continue;
} else if (conflict.end && end && conflict.end > end) {
} else if (conflict.periodEnd && end && conflict.periodEnd > end) {
this.logger.warn(
`Skip RC upsert: newer subscription exists. user=${appUserId} plan=${mapping.plan}`
);
@@ -269,89 +299,66 @@ export class RevenueCatWebhookHandler {
}
if (deleteInstead) {
// delete record and emit cancellation if any record removed
const result = await this.db.subscription.deleteMany({
where: {
targetId: appUserId,
plan: mapping.plan,
provider: Provider.revenuecat,
},
await this.entitlement.revokeCloudSubscription({
targetId: appUserId,
plan: mapping.plan,
subscriptionId: saved.id,
});
if (result.count > 0) {
await this.entitlement.revokeCloudSubscription({
targetId: appUserId,
plan: mapping.plan,
});
if (existing && existing.status !== SubscriptionStatus.Canceled) {
this.event.emit('user.subscription.canceled', {
userId: appUserId,
plan: mapping.plan,
recurring: mapping.recurring,
});
}
removeExists(mapping, sub);
continue;
}
const saved = await this.db.subscription.upsert({
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
// TODO(stable-upgrade): remove reliance on target_id_plan unique slot after contract cleanup.
where: {
targetId_plan: { targetId: appUserId, plan: mapping.plan },
},
update: {
recurring: mapping.recurring,
variant: null,
quantity: 1,
stripeSubscriptionId: null,
stripeScheduleId: null,
provider: Provider.revenuecat,
iapStore: iapStore,
rcEntitlement: sub.identifier ?? null,
rcProductId: sub.productId || null,
rcExternalRef: rcExternalRef,
status: status,
start,
end,
nextBillAt,
canceledAt: canceledAt ?? null,
trialStart: null,
trialEnd: null,
},
create: {
targetId: appUserId,
plan: mapping.plan,
recurring: mapping.recurring,
variant: null,
quantity: 1,
stripeSubscriptionId: null,
stripeScheduleId: null,
provider: Provider.revenuecat,
iapStore: iapStore,
rcEntitlement: sub.identifier ?? null,
rcProductId: sub.productId || null,
rcExternalRef: rcExternalRef,
status: status,
start,
end,
nextBillAt,
canceledAt: canceledAt ?? null,
trialStart: null,
trialEnd: null,
},
await this.entitlement.upsertFromCloudSubscription({
targetId: saved.targetId,
plan: saved.plan,
recurring: saved.recurring ?? mapping.recurring,
status: saved.status,
quantity: saved.quantity,
provider: saved.provider,
subscriptionId: saved.id,
start: saved.periodStart,
end: saved.periodEnd,
trialStart: saved.trialStart,
trialEnd: saved.trialEnd,
canceledAt: saved.canceledAt,
});
await this.entitlement.upsertFromCloudSubscription(saved);
if (existing && existing.targetId !== saved.targetId) {
this.event.emit('entitlement.changed', {
targetType: 'user',
targetId: existing.targetId,
});
this.event.emit('user.subscription.canceled', {
userId: existing.targetId,
plan: existing.plan as SubscriptionPlan,
recurring: existing.recurring as SubscriptionRecurring,
});
}
if (
status === SubscriptionStatus.Active ||
status === SubscriptionStatus.Trialing
) {
this.event.emit('user.subscription.activated', {
userId: appUserId,
plan: mapping.plan,
recurring: mapping.recurring,
});
if (
existing?.status !== SubscriptionStatus.Active &&
existing?.status !== SubscriptionStatus.Trialing
) {
this.event.emit('user.subscription.activated', {
userId: appUserId,
plan: mapping.plan,
recurring: mapping.recurring,
});
}
success += 1;
} else if (status !== SubscriptionStatus.PastDue) {
} else if (
status !== SubscriptionStatus.PastDue &&
existing?.status !== status
) {
// Do not emit canceled for PastDue (still within retry/grace window)
this.event.emit('user.subscription.canceled', {
userId: appUserId,
@@ -359,24 +366,23 @@ export class RevenueCatWebhookHandler {
recurring: mapping.recurring,
});
}
removeExists(mapping, sub);
}
if (toBeCleanup.length) {
for (const sub of toBeCleanup) {
await this.db.subscription.deleteMany({ where: { id: sub.id } });
await this.entitlement.revokeCloudSubscription({
targetId: appUserId,
plan: sub.plan as SubscriptionPlan,
subscriptionId: sub.id,
stripeSubscriptionId: sub.stripeSubscriptionId,
});
this.event.emit('user.subscription.canceled', {
userId: appUserId,
plan: sub.plan as SubscriptionPlan,
recurring: sub.recurring as SubscriptionRecurring,
});
await this.db.providerSubscription.delete({ where: { id: sub.id } });
if (!isLegacyIdentityPlaceholder(sub.metadata)) {
this.event.emit('user.subscription.canceled', {
userId: appUserId,
plan: sub.plan as SubscriptionPlan,
recurring: sub.recurring as SubscriptionRecurring,
});
}
}
this.logger.log(
`Cleanup ${toBeCleanup.length} subscriptions for ${appUserId}`,
@@ -385,7 +391,7 @@ export class RevenueCatWebhookHandler {
subscriptions: toBeCleanup.map(s => ({
plan: s.plan,
recurring: s.recurring,
end: s.end,
end: s.periodEnd,
})),
}
);
@@ -401,16 +401,30 @@ export class SubscriptionService {
throw new InvalidLicenseSessionId();
}
let subInDB = await this.db.subscription.findUnique({
let subInDB = await this.db.providerSubscription.findUnique({
where: {
stripeSubscriptionId: subscription.id,
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: subscription.id,
},
},
});
// subscription not found in db
if (!subInDB) {
subInDB =
await this.selfhostManager.saveStripeSubscription(knownSubscription);
await this.selfhostManager.saveStripeSubscription(knownSubscription);
subInDB = await this.db.providerSubscription.findUnique({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: subscription.id,
},
},
});
}
if (!subInDB) {
throw new InvalidLicenseSessionId();
}
const license = await this.db.license.findUnique({