mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-20 03:26:47 +08:00
feat(server): cleanup legacy compatibility (#15239)
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user