mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-19 02:56:23 +08:00
feat(server): clean up dirty data from legacy version (#15078)
#### PR Dependency Tree * **PR #15078** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Persist and replay incoming payment webhooks for reliability. * Track provider-level subscriptions, payment events, and per-target trial usage across providers. * Nightly replay job to reprocess stuck payment events. * Shadow backfill mode and emit-suppression options to control projection/backfill side effects. * Subscriptions now derived from entitlements + provider facts. * **Bug Fixes** * Improved error propagation, retry tracking, and safer owner-grant projection handling. * **Tests** * Added webhook failure/replay, provider integration, entitlement projection, and trial/checkout tests. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import type { RawBodyRequest } from '@nestjs/common';
|
||||
import { Controller, Logger, Post, Req } from '@nestjs/common';
|
||||
import { Prisma, PrismaClient, Provider } from '@prisma/client';
|
||||
import type { Request } from 'express';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { Config, EventBus, InternalServerError } from '../../base';
|
||||
import { Public } from '../../core/auth';
|
||||
@@ -12,6 +14,7 @@ export class StripeWebhookController {
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly db: PrismaClient,
|
||||
private readonly stripeProvider: StripeFactory,
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
@@ -33,14 +36,98 @@ export class StripeWebhookController {
|
||||
`[${event.id}] Stripe Webhook {${event.type}} received.`
|
||||
);
|
||||
|
||||
// Stripe requires responseing webhook immediately and handle event asynchronously.
|
||||
const existingPaymentEvent = await this.db.paymentEvent.findUnique({
|
||||
where: {
|
||||
provider_externalEventId: {
|
||||
provider: Provider.stripe,
|
||||
externalEventId: event.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existingPaymentEvent?.processingStatus === 'processed') {
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentEvent = existingPaymentEvent
|
||||
? await this.db.paymentEvent.update({
|
||||
where: { id: existingPaymentEvent.id },
|
||||
data: {
|
||||
eventType: event.type,
|
||||
lastError: null,
|
||||
metadata: event as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
: await this.db.paymentEvent.create({
|
||||
data: {
|
||||
provider: Provider.stripe,
|
||||
eventType: event.type,
|
||||
externalEventId: event.id,
|
||||
occurredAt: new Date(event.created * 1000),
|
||||
metadata: event as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
if (paymentEvent.processingStatus === 'processing') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stripe requires responding to webhooks immediately and handling events asynchronously.
|
||||
setImmediate(() => {
|
||||
this.event.emitAsync(`stripe.${event.type}` as any, event).catch(e => {
|
||||
this.logger.error('Failed to handle Stripe Webhook event.', e);
|
||||
this.processEvent(paymentEvent.id, event).catch(e => {
|
||||
this.logger.error('Failed to persist Stripe Webhook failure.', e);
|
||||
});
|
||||
});
|
||||
} catch (err: any) {
|
||||
throw new InternalServerError(err.message);
|
||||
} catch (err: unknown) {
|
||||
throw new InternalServerError(
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async processEvent(id: string, event: Stripe.Event) {
|
||||
const stuckBefore = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const locked = await this.db.paymentEvent.updateMany({
|
||||
where: {
|
||||
id,
|
||||
OR: [
|
||||
{ processingStatus: { in: ['pending', 'failed'] } },
|
||||
{
|
||||
processingStatus: 'processing',
|
||||
updatedAt: { lt: stuckBefore },
|
||||
},
|
||||
],
|
||||
},
|
||||
data: {
|
||||
processingStatus: 'processing',
|
||||
processingAttempts: { increment: 1 },
|
||||
},
|
||||
});
|
||||
if (locked.count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.event.emitAsync(
|
||||
`stripe.${event.type}` as keyof Events,
|
||||
event as never
|
||||
);
|
||||
await this.db.paymentEvent.update({
|
||||
where: { id },
|
||||
data: {
|
||||
processingStatus: 'processed',
|
||||
processedAt: new Date(),
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
await this.db.paymentEvent.update({
|
||||
where: { id },
|
||||
data: {
|
||||
processingStatus: 'failed',
|
||||
lastError: e instanceof Error ? e.message : String(e),
|
||||
},
|
||||
});
|
||||
this.logger.error('Failed to handle Stripe Webhook event.', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ declare global {
|
||||
'nightly.reconcileRevenueCatSubscriptions': {};
|
||||
'nightly.reconcileStripeSubscriptions': {};
|
||||
'nightly.reconcileStripeRefunds': {};
|
||||
'nightly.replayStripeWebhookEvents': {};
|
||||
'nightly.revenuecat.syncUser': { userId: string };
|
||||
}
|
||||
}
|
||||
@@ -78,6 +79,12 @@ export class SubscriptionCronJobs {
|
||||
{ jobId: 'nightly-payment-reconcile-stripe-refunds' }
|
||||
);
|
||||
|
||||
await this.queue.add(
|
||||
'nightly.replayStripeWebhookEvents',
|
||||
{},
|
||||
{ jobId: 'nightly-payment-replay-stripe-webhook-events' }
|
||||
);
|
||||
|
||||
// FIXME(@forehalo): the strategy is totally wrong, for monthly plan. redesign required
|
||||
// await this.queue.add(
|
||||
// 'nightly.notifyAboutToExpireWorkspaceSubscriptions',
|
||||
@@ -219,6 +226,64 @@ export class SubscriptionCronJobs {
|
||||
await this.rcHandler.syncAppUser(payload.userId);
|
||||
}
|
||||
|
||||
@OnJob('nightly.replayStripeWebhookEvents')
|
||||
async replayStripeWebhookEvents() {
|
||||
const stuckBefore = new Date(Date.now() - OneHour);
|
||||
const events = await this.db.paymentEvent.findMany({
|
||||
where: {
|
||||
provider: Provider.stripe,
|
||||
OR: [
|
||||
{ processingStatus: { in: ['pending', 'failed'] } },
|
||||
{ processingStatus: 'processing', updatedAt: { lt: stuckBefore } },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
for (const event of events) {
|
||||
const locked = await this.db.paymentEvent.updateMany({
|
||||
where: {
|
||||
id: event.id,
|
||||
OR: [
|
||||
{ processingStatus: { in: ['pending', 'failed'] } },
|
||||
{ processingStatus: 'processing', updatedAt: { lt: stuckBefore } },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
processingStatus: 'processing',
|
||||
processingAttempts: { increment: 1 },
|
||||
},
|
||||
});
|
||||
if (locked.count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.event.emitAsync(
|
||||
`stripe.${event.eventType}` as keyof Events,
|
||||
event.metadata as never
|
||||
);
|
||||
await this.db.paymentEvent.update({
|
||||
where: { id: event.id },
|
||||
data: {
|
||||
processingStatus: 'processed',
|
||||
processedAt: new Date(),
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
await this.db.paymentEvent.update({
|
||||
where: { id: event.id },
|
||||
data: {
|
||||
processingStatus: 'failed',
|
||||
lastError: e instanceof Error ? e.message : String(e),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OnJob('nightly.reconcileStripeSubscriptions')
|
||||
async reconcileStripeSubscriptions() {
|
||||
const stripe = this.stripeFactory.stripe;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
LookupKey,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../types';
|
||||
import {
|
||||
activeSubscriptionWhere,
|
||||
@@ -129,8 +130,9 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
|
||||
|
||||
if (!existingSubscription) {
|
||||
const key = randomUUID();
|
||||
const [subscription] = await this.db.$transaction([
|
||||
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,
|
||||
@@ -148,9 +150,16 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
|
||||
props: { license: key },
|
||||
});
|
||||
|
||||
return subscription;
|
||||
await this.upsertStripeProviderSubscription(
|
||||
key,
|
||||
subscription,
|
||||
subscriptionData
|
||||
);
|
||||
|
||||
return saved;
|
||||
} else {
|
||||
return this.db.subscription.update({
|
||||
const saved = await this.db.subscription.update({
|
||||
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
|
||||
where: {
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
@@ -162,12 +171,30 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
|
||||
'end',
|
||||
]),
|
||||
});
|
||||
await this.upsertStripeProviderSubscription(
|
||||
saved.targetId,
|
||||
subscription,
|
||||
subscriptionData
|
||||
);
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteStripeSubscription({
|
||||
stripeSubscription,
|
||||
}: KnownStripeSubscription) {
|
||||
await this.db.providerSubscription.updateMany({
|
||||
where: {
|
||||
provider: Provider.stripe,
|
||||
externalSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
data: {
|
||||
status: SubscriptionStatus.Canceled,
|
||||
canceledAt: new Date(),
|
||||
periodEnd: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const subscription = await this.db.subscription.findFirst({
|
||||
where: { stripeSubscriptionId: stripeSubscription.id },
|
||||
});
|
||||
@@ -248,4 +275,74 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
|
||||
|
||||
return invoiceData;
|
||||
}
|
||||
|
||||
private async upsertStripeProviderSubscription(
|
||||
targetId: string,
|
||||
known: KnownStripeSubscription,
|
||||
subscriptionData: Subscription
|
||||
) {
|
||||
const { lookupKey, stripeSubscription } = known;
|
||||
const price = stripeSubscription.items.data[0]?.price;
|
||||
|
||||
await this.db.providerSubscription.upsert({
|
||||
where: {
|
||||
provider_externalSubscriptionId: {
|
||||
provider: Provider.stripe,
|
||||
externalSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
targetType: 'instance',
|
||||
targetId,
|
||||
plan: lookupKey.plan,
|
||||
recurring: lookupKey.recurring,
|
||||
status: stripeSubscription.status,
|
||||
externalCustomerId:
|
||||
typeof stripeSubscription.customer === 'string'
|
||||
? stripeSubscription.customer
|
||||
: stripeSubscription.customer.id,
|
||||
externalProductId:
|
||||
typeof price?.product === 'string'
|
||||
? price.product
|
||||
: price?.product?.id,
|
||||
externalPriceId: price?.id,
|
||||
currency: price?.currency,
|
||||
amount: price?.unit_amount ?? null,
|
||||
quantity: known.quantity,
|
||||
periodStart: subscriptionData.start,
|
||||
periodEnd: subscriptionData.end,
|
||||
trialStart: subscriptionData.trialStart,
|
||||
trialEnd: subscriptionData.trialEnd,
|
||||
canceledAt: subscriptionData.canceledAt,
|
||||
metadata: known.metadata,
|
||||
},
|
||||
create: {
|
||||
provider: Provider.stripe,
|
||||
targetType: 'instance',
|
||||
targetId,
|
||||
plan: lookupKey.plan,
|
||||
recurring: lookupKey.recurring,
|
||||
status: stripeSubscription.status,
|
||||
externalCustomerId:
|
||||
typeof stripeSubscription.customer === 'string'
|
||||
? stripeSubscription.customer
|
||||
: stripeSubscription.customer.id,
|
||||
externalSubscriptionId: stripeSubscription.id,
|
||||
externalProductId:
|
||||
typeof price?.product === 'string'
|
||||
? price.product
|
||||
: price?.product?.id,
|
||||
externalPriceId: price?.id,
|
||||
currency: price?.currency,
|
||||
amount: price?.unit_amount ?? null,
|
||||
quantity: known.quantity,
|
||||
periodStart: subscriptionData.start,
|
||||
periodEnd: subscriptionData.end,
|
||||
trialStart: subscriptionData.trialStart,
|
||||
trialEnd: subscriptionData.trialEnd,
|
||||
canceledAt: subscriptionData.canceledAt,
|
||||
metadata: known.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient, Provider, UserStripeCustomer } from '@prisma/client';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
Prisma,
|
||||
PrismaClient,
|
||||
Provider,
|
||||
UserStripeCustomer,
|
||||
} from '@prisma/client';
|
||||
import { omit, pick } from 'lodash-es';
|
||||
import Stripe from 'stripe';
|
||||
import { z } from 'zod';
|
||||
@@ -17,6 +22,7 @@ import {
|
||||
URLHelper,
|
||||
} from '../../../base';
|
||||
import { EntitlementService } from '../../../core/entitlement';
|
||||
import { resolveProductMapping, RevenueCatService } from '../revenuecat';
|
||||
import { StripeFactory } from '../stripe';
|
||||
import {
|
||||
KnownStripeInvoice,
|
||||
@@ -33,13 +39,9 @@ import {
|
||||
CheckoutParams,
|
||||
Subscription,
|
||||
SubscriptionManager,
|
||||
visibleSubscriptionWhere,
|
||||
} from './common';
|
||||
|
||||
interface PriceStrategyStatus {
|
||||
proSubscribed: boolean;
|
||||
aiSubscribed: boolean;
|
||||
}
|
||||
|
||||
export const UserSubscriptionIdentity = z.object({
|
||||
plan: z.enum([SubscriptionPlan.Pro, SubscriptionPlan.AI]),
|
||||
userId: z.string(),
|
||||
@@ -54,6 +56,8 @@ export const UserSubscriptionCheckoutArgs = z.object({
|
||||
|
||||
@Injectable()
|
||||
export class UserSubscriptionManager extends SubscriptionManager {
|
||||
private readonly logger = new Logger(UserSubscriptionManager.name);
|
||||
|
||||
constructor(
|
||||
stripeProvider: StripeFactory,
|
||||
db: PrismaClient,
|
||||
@@ -61,7 +65,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
private readonly event: EventBus,
|
||||
private readonly url: URLHelper,
|
||||
private readonly mutex: Mutex,
|
||||
private readonly entitlement: EntitlementService
|
||||
private readonly entitlement: EntitlementService,
|
||||
private readonly revenueCat: RevenueCatService
|
||||
) {
|
||||
super(stripeProvider, db);
|
||||
}
|
||||
@@ -94,27 +99,29 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
throw new InvalidCheckoutParameters();
|
||||
}
|
||||
|
||||
const active = await this.getActiveSubscription({
|
||||
const active = await this.getVisibleSubscription({
|
||||
plan: lookupKey.plan,
|
||||
userId: user.id,
|
||||
});
|
||||
await this.assertNoActiveLocalEntitlement(user.id, lookupKey);
|
||||
if (active?.provider === 'revenuecat') {
|
||||
throw new ManagedByAppStoreOrPlay();
|
||||
}
|
||||
|
||||
if (
|
||||
active &&
|
||||
// do not allow to re-subscribe unless
|
||||
!(
|
||||
active.recurring !== SubscriptionRecurring.Lifetime &&
|
||||
lookupKey.recurring === SubscriptionRecurring.Lifetime
|
||||
)
|
||||
!this.canCheckoutWithExistingSubscription(active.recurring, lookupKey)
|
||||
) {
|
||||
throw new SubscriptionAlreadyExists({ plan: lookupKey.plan });
|
||||
}
|
||||
|
||||
const customer = await this.getOrCreateCustomer(user.id);
|
||||
const strategy = await this.strategyStatus(customer);
|
||||
const stripeSubscriptions = await this.stripe.subscriptions.list({
|
||||
customer: customer.stripeCustomerId,
|
||||
status: 'all',
|
||||
});
|
||||
this.assertNoActiveStripeSubscription(stripeSubscriptions.data, lookupKey);
|
||||
await this.assertNoActiveRevenueCatSubscription(user.id, lookupKey);
|
||||
const price = await this.getPrice(lookupKey);
|
||||
|
||||
if (!price || !(await this.isPriceAvailable(price))) {
|
||||
@@ -138,8 +145,11 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
return { allow_promotion_codes: true };
|
||||
})();
|
||||
|
||||
const trials = (() => {
|
||||
if (lookupKey.plan === SubscriptionPlan.AI && !strategy.aiSubscribed) {
|
||||
const subscriptionData = await (async () => {
|
||||
if (
|
||||
lookupKey.plan === SubscriptionPlan.AI &&
|
||||
!(await this.hasUsedTrial(user.id, lookupKey.plan))
|
||||
) {
|
||||
return {
|
||||
trial_period_days: 7,
|
||||
} as Stripe.Checkout.SessionCreateParams.SubscriptionData;
|
||||
@@ -158,12 +168,10 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
}
|
||||
: {
|
||||
mode: 'subscription' as const,
|
||||
subscription_data: {
|
||||
...trials,
|
||||
},
|
||||
subscription_data: subscriptionData,
|
||||
};
|
||||
|
||||
return this.stripe.checkout.sessions.create({
|
||||
const session = await this.stripe.checkout.sessions.create({
|
||||
customer: customer.stripeCustomerId,
|
||||
line_items: [
|
||||
{
|
||||
@@ -175,6 +183,17 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
...discounts,
|
||||
success_url: this.url.safeLink(params.successCallbackLink || '/'),
|
||||
});
|
||||
|
||||
if (subscriptionData?.trial_period_days) {
|
||||
await this.recordTrialUsage({
|
||||
userId: user.id,
|
||||
provider: Provider.stripe,
|
||||
externalRef: session.id,
|
||||
metadata: { source: 'checkout_session' },
|
||||
});
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async getSubscription(args: z.infer<typeof UserSubscriptionIdentity>) {
|
||||
@@ -196,6 +215,16 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
});
|
||||
}
|
||||
|
||||
async getVisibleSubscription(args: z.infer<typeof UserSubscriptionIdentity>) {
|
||||
return this.db.subscription.findFirst({
|
||||
where: {
|
||||
targetId: args.userId,
|
||||
plan: args.plan,
|
||||
...visibleSubscriptionWhere(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async saveStripeSubscription(subscription: KnownStripeSubscription) {
|
||||
const { userId, lookupKey, stripeSubscription } = subscription;
|
||||
this.assertUserIdExists(userId);
|
||||
@@ -220,23 +249,54 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
}
|
||||
|
||||
const subscriptionData = this.transformSubscription(subscription);
|
||||
await this.upsertStripeProviderSubscription(subscription, subscriptionData);
|
||||
|
||||
const saved = await this.db.subscription.upsert({
|
||||
where: {
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
update: pick(subscriptionData, [
|
||||
'status',
|
||||
'stripeScheduleId',
|
||||
'nextBillAt',
|
||||
'canceledAt',
|
||||
'end',
|
||||
]),
|
||||
create: {
|
||||
targetId: userId,
|
||||
...omit(subscriptionData, ['provider', 'iapStore']),
|
||||
},
|
||||
if (
|
||||
lookupKey.plan === SubscriptionPlan.AI &&
|
||||
(stripeSubscription.status === SubscriptionStatus.Trialing ||
|
||||
stripeSubscription.trial_start ||
|
||||
stripeSubscription.trial_end)
|
||||
) {
|
||||
await this.recordTrialUsage({
|
||||
userId,
|
||||
provider: Provider.stripe,
|
||||
externalRef: stripeSubscription.id,
|
||||
metadata: { source: 'stripe_subscription' },
|
||||
});
|
||||
}
|
||||
|
||||
const existingByStripeId = await this.db.subscription.findUnique({
|
||||
where: { stripeSubscriptionId: stripeSubscription.id },
|
||||
});
|
||||
|
||||
const saved = existingByStripeId
|
||||
? await this.db.subscription.update({
|
||||
where: { id: existingByStripeId.id },
|
||||
data: pick(subscriptionData, [
|
||||
'status',
|
||||
'stripeScheduleId',
|
||||
'nextBillAt',
|
||||
'canceledAt',
|
||||
'end',
|
||||
]),
|
||||
})
|
||||
: 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;
|
||||
}
|
||||
@@ -247,6 +307,17 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
stripeSubscription,
|
||||
}: KnownStripeSubscription) {
|
||||
this.assertUserIdExists(userId);
|
||||
await this.db.providerSubscription.updateMany({
|
||||
where: {
|
||||
provider: Provider.stripe,
|
||||
externalSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
data: {
|
||||
status: SubscriptionStatus.Canceled,
|
||||
canceledAt: new Date(),
|
||||
periodEnd: new Date(),
|
||||
},
|
||||
});
|
||||
const result = await this.db.subscription.deleteMany({
|
||||
where: {
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
@@ -311,6 +382,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
this.assertUserIdExists(userId);
|
||||
|
||||
const invoiceData = await this.transformInvoice(knownInvoice);
|
||||
await this.upsertStripePaymentEvent(knownInvoice, invoiceData);
|
||||
|
||||
const invoice = await this.db.invoice.upsert({
|
||||
where: {
|
||||
@@ -357,6 +429,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
|
||||
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,
|
||||
@@ -382,6 +455,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
|
||||
const subscription = await this.db.subscription.create({
|
||||
data: {
|
||||
targetId: knownInvoice.userId,
|
||||
@@ -420,6 +494,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
|
||||
await this.db.subscription.update({
|
||||
where: {
|
||||
id: subscription.id,
|
||||
@@ -463,6 +538,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
: 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: {
|
||||
@@ -475,6 +551,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
});
|
||||
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,
|
||||
@@ -530,33 +607,282 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
return lookupKey.variant === null;
|
||||
}
|
||||
|
||||
private async strategyStatus(
|
||||
customer: UserStripeCustomer
|
||||
): Promise<PriceStrategyStatus> {
|
||||
let proSubscribed = false;
|
||||
let aiSubscribed = false;
|
||||
|
||||
const subscriptions = await this.stripe.subscriptions.list({
|
||||
customer: customer.stripeCustomerId,
|
||||
status: 'all',
|
||||
private async assertNoActiveLocalEntitlement(
|
||||
userId: string,
|
||||
lookupKey: LookupKey
|
||||
) {
|
||||
const entitlements = await this.entitlement.getActiveEntitlements(
|
||||
'user',
|
||||
userId
|
||||
);
|
||||
const existing = entitlements.find(entitlement => {
|
||||
if (lookupKey.plan === SubscriptionPlan.Pro) {
|
||||
return (
|
||||
entitlement.plan === 'pro' || entitlement.plan === 'lifetime_pro'
|
||||
);
|
||||
}
|
||||
if (lookupKey.plan === SubscriptionPlan.AI) {
|
||||
return entitlement.plan === 'ai';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const sub of subscriptions.data) {
|
||||
const lookupKey = retriveLookupKeyFromStripeSubscription(sub);
|
||||
if (!lookupKey) {
|
||||
const metadata = existing.metadata as { provider?: string | null };
|
||||
if (metadata.provider === Provider.revenuecat) {
|
||||
throw new ManagedByAppStoreOrPlay();
|
||||
}
|
||||
if (
|
||||
!this.canCheckoutWithExistingSubscription(
|
||||
(existing.metadata as { recurring?: string | null }).recurring ??
|
||||
SubscriptionRecurring.Monthly,
|
||||
lookupKey
|
||||
)
|
||||
) {
|
||||
throw new SubscriptionAlreadyExists({ plan: lookupKey.plan });
|
||||
}
|
||||
}
|
||||
|
||||
private async hasUsedTrial(userId: string, plan: SubscriptionPlan) {
|
||||
return !!(await this.db.subscriptionTrialUsage.findUnique({
|
||||
where: {
|
||||
targetType_targetId_plan: {
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
plan,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
}));
|
||||
}
|
||||
|
||||
private async recordTrialUsage(input: {
|
||||
userId: string;
|
||||
provider: Provider;
|
||||
externalRef: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
}) {
|
||||
await this.db.subscriptionTrialUsage.upsert({
|
||||
where: {
|
||||
targetType_targetId_plan: {
|
||||
targetType: 'user',
|
||||
targetId: input.userId,
|
||||
plan: SubscriptionPlan.AI,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
provider: input.provider,
|
||||
externalRef: input.externalRef,
|
||||
metadata: input.metadata as Prisma.InputJsonObject,
|
||||
},
|
||||
create: {
|
||||
targetType: 'user',
|
||||
targetId: input.userId,
|
||||
plan: SubscriptionPlan.AI,
|
||||
provider: input.provider,
|
||||
externalRef: input.externalRef,
|
||||
metadata: input.metadata as Prisma.InputJsonObject,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async upsertStripeProviderSubscription(
|
||||
known: KnownStripeSubscription,
|
||||
subscriptionData: Subscription
|
||||
) {
|
||||
const { userId, lookupKey, stripeSubscription } = known;
|
||||
this.assertUserIdExists(userId);
|
||||
const price = stripeSubscription.items.data[0]?.price;
|
||||
|
||||
await this.db.providerSubscription.upsert({
|
||||
where: {
|
||||
provider_externalSubscriptionId: {
|
||||
provider: Provider.stripe,
|
||||
externalSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
plan: lookupKey.plan,
|
||||
recurring: lookupKey.recurring,
|
||||
status: stripeSubscription.status,
|
||||
externalCustomerId:
|
||||
typeof stripeSubscription.customer === 'string'
|
||||
? stripeSubscription.customer
|
||||
: stripeSubscription.customer.id,
|
||||
externalProductId:
|
||||
typeof price?.product === 'string'
|
||||
? price.product
|
||||
: price?.product?.id,
|
||||
externalPriceId: price?.id,
|
||||
currency: price?.currency,
|
||||
amount: price?.unit_amount ?? null,
|
||||
quantity: known.quantity,
|
||||
periodStart: subscriptionData.start,
|
||||
periodEnd: subscriptionData.end,
|
||||
trialStart: subscriptionData.trialStart,
|
||||
trialEnd: subscriptionData.trialEnd,
|
||||
canceledAt: subscriptionData.canceledAt,
|
||||
metadata: known.metadata,
|
||||
},
|
||||
create: {
|
||||
provider: Provider.stripe,
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
plan: lookupKey.plan,
|
||||
recurring: lookupKey.recurring,
|
||||
status: stripeSubscription.status,
|
||||
externalCustomerId:
|
||||
typeof stripeSubscription.customer === 'string'
|
||||
? stripeSubscription.customer
|
||||
: stripeSubscription.customer.id,
|
||||
externalSubscriptionId: stripeSubscription.id,
|
||||
externalProductId:
|
||||
typeof price?.product === 'string'
|
||||
? price.product
|
||||
: price?.product?.id,
|
||||
externalPriceId: price?.id,
|
||||
currency: price?.currency,
|
||||
amount: price?.unit_amount ?? null,
|
||||
quantity: known.quantity,
|
||||
periodStart: subscriptionData.start,
|
||||
periodEnd: subscriptionData.end,
|
||||
trialStart: subscriptionData.trialStart,
|
||||
trialEnd: subscriptionData.trialEnd,
|
||||
canceledAt: subscriptionData.canceledAt,
|
||||
metadata: known.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async upsertStripePaymentEvent(
|
||||
known: KnownStripeInvoice,
|
||||
invoiceData: Awaited<
|
||||
ReturnType<UserSubscriptionManager['transformInvoice']>
|
||||
>
|
||||
) {
|
||||
const { userId, lookupKey, stripeInvoice } = known;
|
||||
this.assertUserIdExists(userId);
|
||||
|
||||
await this.db.paymentEvent.upsert({
|
||||
where: {
|
||||
provider_externalEventId: {
|
||||
provider: Provider.stripe,
|
||||
externalEventId: `stripe_invoice:${stripeInvoice.id}`,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
eventType: `invoice.${invoiceData.status}`,
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
externalInvoiceId: stripeInvoice.id,
|
||||
plan: lookupKey.plan,
|
||||
amount: invoiceData.amount,
|
||||
currency: invoiceData.currency,
|
||||
occurredAt:
|
||||
typeof stripeInvoice.created === 'number'
|
||||
? new Date(stripeInvoice.created * 1000)
|
||||
: undefined,
|
||||
processingStatus: 'processed',
|
||||
processedAt: new Date(),
|
||||
metadata: known.metadata,
|
||||
},
|
||||
create: {
|
||||
provider: Provider.stripe,
|
||||
eventType: `invoice.${invoiceData.status}`,
|
||||
externalEventId: `stripe_invoice:${stripeInvoice.id}`,
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
externalInvoiceId: stripeInvoice.id,
|
||||
plan: lookupKey.plan,
|
||||
amount: invoiceData.amount,
|
||||
currency: invoiceData.currency,
|
||||
occurredAt:
|
||||
typeof stripeInvoice.created === 'number'
|
||||
? new Date(stripeInvoice.created * 1000)
|
||||
: undefined,
|
||||
processingStatus: 'processed',
|
||||
processedAt: new Date(),
|
||||
metadata: known.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private isCurrentStripeSubscription(subscription: Stripe.Subscription) {
|
||||
return [
|
||||
SubscriptionStatus.Active,
|
||||
SubscriptionStatus.Trialing,
|
||||
SubscriptionStatus.PastDue,
|
||||
].includes(subscription.status as SubscriptionStatus);
|
||||
}
|
||||
|
||||
private canCheckoutWithExistingSubscription(
|
||||
existingRecurring: string,
|
||||
lookupKey: LookupKey
|
||||
) {
|
||||
return (
|
||||
existingRecurring !== SubscriptionRecurring.Lifetime &&
|
||||
lookupKey.recurring === SubscriptionRecurring.Lifetime
|
||||
);
|
||||
}
|
||||
|
||||
private assertNoActiveStripeSubscription(
|
||||
subscriptions: Stripe.Subscription[],
|
||||
lookupKey: LookupKey
|
||||
) {
|
||||
for (const subscription of subscriptions) {
|
||||
if (!this.isCurrentStripeSubscription(subscription)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lookupKey.plan === SubscriptionPlan.Pro) {
|
||||
proSubscribed = true;
|
||||
}
|
||||
|
||||
if (lookupKey.plan === SubscriptionPlan.AI) {
|
||||
aiSubscribed = true;
|
||||
const activeLookupKey =
|
||||
retriveLookupKeyFromStripeSubscription(subscription);
|
||||
if (
|
||||
activeLookupKey?.plan === lookupKey.plan &&
|
||||
!this.canCheckoutWithExistingSubscription(
|
||||
activeLookupKey.recurring,
|
||||
lookupKey
|
||||
)
|
||||
) {
|
||||
throw new SubscriptionAlreadyExists({ plan: lookupKey.plan });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { proSubscribed, aiSubscribed };
|
||||
private async assertNoActiveRevenueCatSubscription(
|
||||
userId: string,
|
||||
lookupKey: LookupKey
|
||||
) {
|
||||
if (!this.config.payment.revenuecat?.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let subscriptions: Awaited<
|
||||
ReturnType<RevenueCatService['getSubscriptions']>
|
||||
>;
|
||||
try {
|
||||
subscriptions = await this.revenueCat.getSubscriptions(userId);
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`Failed to fetch RevenueCat subscriptions for ${userId}`,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const productMap = this.config.payment.revenuecat?.productMap;
|
||||
if (
|
||||
subscriptions?.some(subscription => {
|
||||
if (!subscription.isActive) return false;
|
||||
const mapping = resolveProductMapping(subscription, productMap);
|
||||
return mapping?.plan === lookupKey.plan;
|
||||
})
|
||||
) {
|
||||
throw new ManagedByAppStoreOrPlay();
|
||||
}
|
||||
}
|
||||
|
||||
private assertUserIdExists(
|
||||
|
||||
@@ -139,6 +139,11 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
}
|
||||
|
||||
const subscriptionData = this.transformSubscription(subscription);
|
||||
await this.upsertStripeProviderSubscription(
|
||||
workspaceId,
|
||||
subscription,
|
||||
subscriptionData
|
||||
);
|
||||
|
||||
if (
|
||||
stripeSubscription.status === SubscriptionStatus.Active ||
|
||||
@@ -159,6 +164,8 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
}
|
||||
|
||||
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: {
|
||||
provider: Provider.stripe,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
@@ -194,6 +201,17 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
);
|
||||
}
|
||||
|
||||
await this.db.providerSubscription.updateMany({
|
||||
where: {
|
||||
provider: Provider.stripe,
|
||||
externalSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
data: {
|
||||
status: SubscriptionStatus.Canceled,
|
||||
canceledAt: new Date(),
|
||||
periodEnd: new Date(),
|
||||
},
|
||||
});
|
||||
const result = await this.db.subscription.deleteMany({
|
||||
where: { stripeSubscriptionId: stripeSubscription.id },
|
||||
});
|
||||
@@ -337,4 +355,74 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
await schedule.updateQuantity(count);
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertStripeProviderSubscription(
|
||||
workspaceId: string,
|
||||
known: KnownStripeSubscription,
|
||||
subscriptionData: Subscription
|
||||
) {
|
||||
const { lookupKey, stripeSubscription } = known;
|
||||
const price = stripeSubscription.items.data[0]?.price;
|
||||
|
||||
await this.db.providerSubscription.upsert({
|
||||
where: {
|
||||
provider_externalSubscriptionId: {
|
||||
provider: Provider.stripe,
|
||||
externalSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
targetType: 'workspace',
|
||||
targetId: workspaceId,
|
||||
plan: lookupKey.plan,
|
||||
recurring: lookupKey.recurring,
|
||||
status: stripeSubscription.status,
|
||||
externalCustomerId:
|
||||
typeof stripeSubscription.customer === 'string'
|
||||
? stripeSubscription.customer
|
||||
: stripeSubscription.customer.id,
|
||||
externalProductId:
|
||||
typeof price?.product === 'string'
|
||||
? price.product
|
||||
: price?.product?.id,
|
||||
externalPriceId: price?.id,
|
||||
currency: price?.currency,
|
||||
amount: price?.unit_amount ?? null,
|
||||
quantity: known.quantity,
|
||||
periodStart: subscriptionData.start,
|
||||
periodEnd: subscriptionData.end,
|
||||
trialStart: subscriptionData.trialStart,
|
||||
trialEnd: subscriptionData.trialEnd,
|
||||
canceledAt: subscriptionData.canceledAt,
|
||||
metadata: known.metadata,
|
||||
},
|
||||
create: {
|
||||
provider: Provider.stripe,
|
||||
targetType: 'workspace',
|
||||
targetId: workspaceId,
|
||||
plan: lookupKey.plan,
|
||||
recurring: lookupKey.recurring,
|
||||
status: stripeSubscription.status,
|
||||
externalCustomerId:
|
||||
typeof stripeSubscription.customer === 'string'
|
||||
? stripeSubscription.customer
|
||||
: stripeSubscription.customer.id,
|
||||
externalSubscriptionId: stripeSubscription.id,
|
||||
externalProductId:
|
||||
typeof price?.product === 'string'
|
||||
? price.product
|
||||
: price?.product?.id,
|
||||
externalPriceId: price?.id,
|
||||
currency: price?.currency,
|
||||
amount: price?.unit_amount ?? null,
|
||||
quantity: known.quantity,
|
||||
periodStart: subscriptionData.start,
|
||||
periodEnd: subscriptionData.end,
|
||||
trialStart: subscriptionData.trialStart,
|
||||
trialEnd: subscriptionData.trialEnd,
|
||||
canceledAt: subscriptionData.canceledAt,
|
||||
metadata: known.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { PrismaClient, Provider, type User } from '@prisma/client';
|
||||
import type { Entitlement, User } from '@prisma/client';
|
||||
import { PrismaClient, Provider } from '@prisma/client';
|
||||
import { GraphQLJSONObject } from 'graphql-scalars';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import Stripe from 'stripe';
|
||||
@@ -27,15 +28,11 @@ import {
|
||||
WorkspaceIdRequiredToUpdateTeamSubscription,
|
||||
} from '../../base';
|
||||
import { CurrentUser, Public } from '../../core/auth';
|
||||
import { EntitlementService } from '../../core/entitlement';
|
||||
import { PermissionAccess } from '../../core/permission';
|
||||
import { UserType } from '../../core/user';
|
||||
import { WorkspaceType } from '../../core/workspaces';
|
||||
import {
|
||||
Invoice,
|
||||
Subscription,
|
||||
visibleSubscriptionWhere,
|
||||
WorkspaceSubscriptionManager,
|
||||
} from './manager';
|
||||
import { Invoice, Subscription, visibleSubscriptionWhere } from './manager';
|
||||
import { RevenueCatWebhookHandler } from './revenuecat';
|
||||
import { CheckoutParams, SubscriptionService } from './service';
|
||||
import {
|
||||
@@ -463,6 +460,7 @@ export class SubscriptionResolver {
|
||||
export class UserSubscriptionResolver {
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly entitlement: EntitlementService,
|
||||
private readonly rcHandler: RevenueCatWebhookHandler
|
||||
) {}
|
||||
|
||||
@@ -473,6 +471,90 @@ export class UserSubscriptionResolver {
|
||||
return s;
|
||||
}
|
||||
|
||||
private async currentUserSubscriptions(userId: string) {
|
||||
const entitlements = (
|
||||
await this.entitlement.getActiveEntitlements('user', userId)
|
||||
).filter(
|
||||
entitlement =>
|
||||
entitlement.source === 'cloud_subscription' &&
|
||||
['pro', 'lifetime_pro', 'ai'].includes(entitlement.plan)
|
||||
);
|
||||
const providerFacts = await this.db.providerSubscription.findMany({
|
||||
where: {
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
plan: {
|
||||
in: entitlements.map(entitlement =>
|
||||
this.subscriptionPlan(entitlement.plan)
|
||||
),
|
||||
},
|
||||
status: {
|
||||
in: [
|
||||
SubscriptionStatus.Active,
|
||||
SubscriptionStatus.Trialing,
|
||||
SubscriptionStatus.PastDue,
|
||||
],
|
||||
},
|
||||
OR: [{ periodEnd: null }, { periodEnd: { gt: new Date() } }],
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
return entitlements.map(entitlement => {
|
||||
const plan = this.subscriptionPlan(entitlement.plan);
|
||||
const providerFact = providerFacts.find(
|
||||
fact => fact.targetId === userId && fact.plan === plan
|
||||
);
|
||||
const metadata = entitlement.metadata as {
|
||||
provider?: string | null;
|
||||
recurring?: string | null;
|
||||
variant?: string | null;
|
||||
stripeSubscriptionId?: string | null;
|
||||
};
|
||||
|
||||
return this.normalizeSubscription({
|
||||
stripeSubscriptionId:
|
||||
providerFact?.externalSubscriptionId ??
|
||||
metadata.stripeSubscriptionId ??
|
||||
null,
|
||||
stripeScheduleId: null,
|
||||
status: providerFact?.status ?? this.subscriptionStatus(entitlement),
|
||||
plan,
|
||||
recurring:
|
||||
providerFact?.recurring ??
|
||||
metadata.recurring ??
|
||||
(entitlement.plan === 'lifetime_pro'
|
||||
? SubscriptionRecurring.Lifetime
|
||||
: SubscriptionRecurring.Monthly),
|
||||
variant:
|
||||
metadata.variant ??
|
||||
(entitlement.plan === 'lifetime_pro'
|
||||
? SubscriptionVariant.Onetime
|
||||
: null),
|
||||
quantity: entitlement.quantity ?? 1,
|
||||
start: entitlement.startsAt ?? entitlement.createdAt,
|
||||
end: entitlement.expiresAt,
|
||||
trialStart: providerFact?.trialStart ?? null,
|
||||
trialEnd: providerFact?.trialEnd ?? entitlement.graceUntil,
|
||||
nextBillAt: providerFact?.periodEnd ?? entitlement.expiresAt,
|
||||
canceledAt: providerFact?.canceledAt ?? null,
|
||||
provider: providerFact?.provider ?? metadata.provider ?? null,
|
||||
iapStore: providerFact?.iapStore ?? null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private subscriptionPlan(plan: string) {
|
||||
return plan === 'lifetime_pro' ? SubscriptionPlan.Pro : plan;
|
||||
}
|
||||
|
||||
private subscriptionStatus(entitlement: Entitlement) {
|
||||
if (entitlement.status === 'grace') {
|
||||
return SubscriptionStatus.PastDue;
|
||||
}
|
||||
return SubscriptionStatus.Active;
|
||||
}
|
||||
|
||||
@ResolveField(() => [SubscriptionType])
|
||||
async subscriptions(
|
||||
@CurrentUser() me: User,
|
||||
@@ -482,18 +564,7 @@ export class UserSubscriptionResolver {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
|
||||
const subscriptions = await this.db.subscription.findMany({
|
||||
where: {
|
||||
targetId: user.id,
|
||||
...visibleSubscriptionWhere(),
|
||||
},
|
||||
});
|
||||
|
||||
subscriptions.forEach(subscription =>
|
||||
this.normalizeSubscription(subscription)
|
||||
);
|
||||
|
||||
return subscriptions;
|
||||
return this.currentUserSubscriptions(user.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => Int, {
|
||||
@@ -560,17 +631,10 @@ export class UserSubscriptionResolver {
|
||||
|
||||
try {
|
||||
await this.rcHandler.syncAppUserWithExternalRef(user.id, transactionId);
|
||||
current = await this.db.subscription.findMany({
|
||||
where: {
|
||||
targetId: user.id,
|
||||
...visibleSubscriptionWhere(),
|
||||
},
|
||||
});
|
||||
current = await this.currentUserSubscriptions(user.id);
|
||||
// ignore errors
|
||||
} catch {}
|
||||
|
||||
current.forEach(subscription => this.normalizeSubscription(subscription));
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
@@ -612,39 +676,93 @@ export class UserSubscriptionResolver {
|
||||
if (shouldSync) {
|
||||
try {
|
||||
await this.rcHandler.syncAppUser(user.id);
|
||||
current = await this.db.subscription.findMany({
|
||||
where: {
|
||||
targetId: user.id,
|
||||
...visibleSubscriptionWhere(),
|
||||
},
|
||||
});
|
||||
// ignore errors
|
||||
} catch {}
|
||||
}
|
||||
|
||||
current.forEach(subscription => this.normalizeSubscription(subscription));
|
||||
|
||||
return current;
|
||||
return this.currentUserSubscriptions(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class WorkspaceSubscriptionResolver {
|
||||
constructor(
|
||||
private readonly service: WorkspaceSubscriptionManager,
|
||||
private readonly db: PrismaClient,
|
||||
private readonly entitlement: EntitlementService,
|
||||
private readonly ac: PermissionAccess
|
||||
) {}
|
||||
|
||||
private async currentWorkspaceSubscription(workspaceId: string) {
|
||||
const entitlement = await this.entitlement.getBestEntitlement(
|
||||
'workspace',
|
||||
workspaceId
|
||||
);
|
||||
if (
|
||||
!entitlement ||
|
||||
entitlement.source !== 'cloud_subscription' ||
|
||||
entitlement.plan !== 'team'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerFact = await this.db.providerSubscription.findFirst({
|
||||
where: {
|
||||
targetType: 'workspace',
|
||||
targetId: workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
status: {
|
||||
in: [
|
||||
SubscriptionStatus.Active,
|
||||
SubscriptionStatus.Trialing,
|
||||
SubscriptionStatus.PastDue,
|
||||
],
|
||||
},
|
||||
OR: [{ periodEnd: null }, { periodEnd: { gt: new Date() } }],
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
const metadata = entitlement.metadata as {
|
||||
provider?: string | null;
|
||||
recurring?: string | null;
|
||||
variant?: string | null;
|
||||
stripeSubscriptionId?: string | null;
|
||||
};
|
||||
|
||||
return {
|
||||
stripeSubscriptionId:
|
||||
providerFact?.externalSubscriptionId ??
|
||||
metadata.stripeSubscriptionId ??
|
||||
null,
|
||||
stripeScheduleId: null,
|
||||
status:
|
||||
providerFact?.status ??
|
||||
(entitlement.status === 'grace'
|
||||
? SubscriptionStatus.PastDue
|
||||
: SubscriptionStatus.Active),
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring:
|
||||
providerFact?.recurring ??
|
||||
metadata.recurring ??
|
||||
SubscriptionRecurring.Monthly,
|
||||
variant: metadata.variant ?? null,
|
||||
quantity: entitlement.quantity ?? 1,
|
||||
start: entitlement.startsAt ?? entitlement.createdAt,
|
||||
end: entitlement.expiresAt,
|
||||
trialStart: providerFact?.trialStart ?? null,
|
||||
trialEnd: providerFact?.trialEnd ?? entitlement.graceUntil,
|
||||
nextBillAt: providerFact?.periodEnd ?? entitlement.expiresAt,
|
||||
canceledAt: providerFact?.canceledAt ?? null,
|
||||
provider: providerFact?.provider ?? metadata.provider ?? null,
|
||||
iapStore: providerFact?.iapStore ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@ResolveField(() => SubscriptionType, {
|
||||
nullable: true,
|
||||
description: 'The team subscription of the workspace, if exists.',
|
||||
})
|
||||
async subscription(@Parent() workspace: WorkspaceType) {
|
||||
return this.service.getActiveSubscription({
|
||||
plan: SubscriptionPlan.Team,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
return this.currentWorkspaceSubscription(workspace.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => Int, {
|
||||
|
||||
@@ -165,6 +165,85 @@ export class RevenueCatWebhookHandler {
|
||||
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,
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (mapping.plan === SubscriptionPlan.AI && sub.isTrial) {
|
||||
await this.db.subscriptionTrialUsage.upsert({
|
||||
where: {
|
||||
targetType_targetId_plan: {
|
||||
targetType: 'user',
|
||||
targetId: appUserId,
|
||||
plan: SubscriptionPlan.AI,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
targetType: 'user',
|
||||
targetId: appUserId,
|
||||
plan: SubscriptionPlan.AI,
|
||||
provider: Provider.revenuecat,
|
||||
externalRef: rcExternalRef,
|
||||
firstUsedAt: start,
|
||||
metadata: {
|
||||
entitlement: sub.identifier,
|
||||
productId: sub.productId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Mutual exclusion: skip if Stripe already active for the same plan
|
||||
const conflict = await this.db.subscription.findFirst({
|
||||
where: {
|
||||
@@ -214,6 +293,8 @@ export class RevenueCatWebhookHandler {
|
||||
}
|
||||
|
||||
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 },
|
||||
},
|
||||
|
||||
@@ -629,6 +629,7 @@ export class SubscriptionService {
|
||||
`Failed to handle ${reason} for invoice ${invoiceId}`,
|
||||
e
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user