mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
feat(server): refresh subscription (#13670)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added an on-demand mutation to refresh the current user's subscriptions, syncing with RevenueCat when applicable and handling Stripe-only cases. * Subscription variant normalization for clearer plan information and consistent results. * **Tests** * Added tests for refresh behavior: empty state, RevenueCat-backed multi-step sync, and Stripe-only scenarios. * **Client** * New client operation to invoke the refresh mutation and retrieve updated subscription fields. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { PrismaClient, User } from '@prisma/client';
|
||||
import { PrismaClient, type User } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import { omit } from 'lodash-es';
|
||||
import Sinon from 'sinon';
|
||||
@@ -14,6 +14,7 @@ import { Models } from '../../models';
|
||||
import { PaymentModule } from '../../plugins/payment';
|
||||
import { SubscriptionCronJobs } from '../../plugins/payment/cron';
|
||||
import { UserSubscriptionManager } from '../../plugins/payment/manager';
|
||||
import { UserSubscriptionResolver } from '../../plugins/payment/resolver';
|
||||
import {
|
||||
RcEvent,
|
||||
resolveProductMapping,
|
||||
@@ -39,6 +40,7 @@ type Ctx = {
|
||||
rc: RevenueCatService;
|
||||
webhook: RevenueCatWebhookHandler;
|
||||
controller: RevenueCatWebhookController;
|
||||
subResolver: UserSubscriptionResolver;
|
||||
|
||||
mockSub: (subs: Subscription[]) => Sinon.SinonStub;
|
||||
mockSubSeq: (sequences: Subscription[][]) => Sinon.SinonStub;
|
||||
@@ -85,6 +87,7 @@ test.beforeEach(async t => {
|
||||
const rc = app.get(RevenueCatService);
|
||||
const webhook = app.get(RevenueCatWebhookHandler);
|
||||
const controller = app.get(RevenueCatWebhookController);
|
||||
const subResolver = app.get(UserSubscriptionResolver);
|
||||
|
||||
t.context.module = app;
|
||||
t.context.db = db;
|
||||
@@ -95,6 +98,7 @@ test.beforeEach(async t => {
|
||||
t.context.rc = rc;
|
||||
t.context.webhook = webhook;
|
||||
t.context.controller = controller;
|
||||
t.context.subResolver = subResolver;
|
||||
|
||||
t.context.mockSub = subs => Sinon.stub(rc, 'getSubscriptions').resolves(subs);
|
||||
t.context.mockSubSeq = sequences => {
|
||||
@@ -927,3 +931,90 @@ test('should not dispatch webhook event when authorization header is missing or
|
||||
const after = event.emitAsync.getCalls()?.length || 0;
|
||||
t.is(after - before, 0, 'should not emit event');
|
||||
});
|
||||
|
||||
test('should refresh user subscriptions (empty / revenuecat / stripe-only)', async t => {
|
||||
const { subResolver, db, mockSubSeq } = t.context;
|
||||
|
||||
const currentUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
avatarUrl: '',
|
||||
name: '',
|
||||
disabled: false,
|
||||
hasPassword: true,
|
||||
emailVerified: true,
|
||||
};
|
||||
|
||||
// prepare mocks:
|
||||
// first call returns Pro subscription
|
||||
// second call returns AI subscription.
|
||||
const stub = mockSubSeq([
|
||||
[
|
||||
{
|
||||
identifier: 'Pro',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date('2025-09-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2026-09-01T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.Annual',
|
||||
store: 'app_store',
|
||||
willRenew: true,
|
||||
duration: null,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
identifier: 'AI',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date('2025-09-02T00:00:00.000Z'),
|
||||
expirationDate: new Date('2026-09-02T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.ai.Annual',
|
||||
store: 'play_store',
|
||||
willRenew: true,
|
||||
duration: null,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
// case1: empty -> should sync (first sequence)
|
||||
{
|
||||
const subs = await subResolver.refreshUserSubscriptions(currentUser);
|
||||
t.is(stub.callCount, 1, 'Scenario1: RC API called once');
|
||||
t.truthy(
|
||||
subs.find(s => s.plan === 'pro'),
|
||||
'case1: pro saved'
|
||||
);
|
||||
}
|
||||
|
||||
// case2: existing revenuecat -> should sync again (second sequence)
|
||||
{
|
||||
const subs = await subResolver.refreshUserSubscriptions(currentUser);
|
||||
t.is(stub.callCount, 2, 'Scenario2: RC API called second time');
|
||||
t.truthy(
|
||||
subs.find(s => s.plan === 'ai'),
|
||||
'case2: ai saved'
|
||||
);
|
||||
}
|
||||
|
||||
// case3: only stripe subscription -> should NOT sync (call count remains 2)
|
||||
{
|
||||
await db.subscription.deleteMany({
|
||||
where: { targetId: user.id, provider: 'revenuecat' },
|
||||
});
|
||||
await db.subscription.create({
|
||||
data: {
|
||||
targetId: user.id,
|
||||
plan: 'pro',
|
||||
provider: 'stripe',
|
||||
status: 'active',
|
||||
recurring: 'monthly',
|
||||
start: new Date('2025-01-01T00:00:00.000Z'),
|
||||
stripeSubscriptionId: 'sub_123',
|
||||
},
|
||||
});
|
||||
const subs = await subResolver.refreshUserSubscriptions(currentUser);
|
||||
t.is(stub.callCount, 2, 'case3: RC API not called again');
|
||||
t.is(subs.length, 1, 'case3: only stripe subscription returned');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import type { User } from '@prisma/client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient, Provider, type User } from '@prisma/client';
|
||||
import { GraphQLJSONObject } from 'graphql-scalars';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import Stripe from 'stripe';
|
||||
@@ -31,6 +30,7 @@ import { AccessController } from '../../core/permission';
|
||||
import { UserType } from '../../core/user';
|
||||
import { WorkspaceType } from '../../core/workspaces';
|
||||
import { Invoice, Subscription, WorkspaceSubscriptionManager } from './manager';
|
||||
import { RevenueCatWebhookHandler } from './revenuecat';
|
||||
import { CheckoutParams, SubscriptionService } from './service';
|
||||
import {
|
||||
InvoiceStatus,
|
||||
@@ -463,7 +463,22 @@ export class SubscriptionResolver {
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class UserSubscriptionResolver {
|
||||
constructor(private readonly db: PrismaClient) {}
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly rcHandler: RevenueCatWebhookHandler
|
||||
) {}
|
||||
|
||||
private normalizeSubscription(s: Subscription) {
|
||||
if (
|
||||
s.variant &&
|
||||
![SubscriptionVariant.EA, SubscriptionVariant.Onetime].includes(
|
||||
s.variant as SubscriptionVariant
|
||||
)
|
||||
) {
|
||||
s.variant = null;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
@ResolveField(() => [SubscriptionType])
|
||||
async subscriptions(
|
||||
@@ -487,16 +502,9 @@ export class UserSubscriptionResolver {
|
||||
},
|
||||
});
|
||||
|
||||
subscriptions.forEach(subscription => {
|
||||
if (
|
||||
subscription.variant &&
|
||||
![SubscriptionVariant.EA, SubscriptionVariant.Onetime].includes(
|
||||
subscription.variant as SubscriptionVariant
|
||||
)
|
||||
) {
|
||||
subscription.variant = null;
|
||||
}
|
||||
});
|
||||
subscriptions.forEach(subscription =>
|
||||
this.normalizeSubscription(subscription)
|
||||
);
|
||||
|
||||
return subscriptions;
|
||||
}
|
||||
@@ -534,6 +542,71 @@ export class UserSubscriptionResolver {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Throttle('strict')
|
||||
@Mutation(() => [SubscriptionType], {
|
||||
description: 'Refresh current user subscriptions and return latest.',
|
||||
})
|
||||
async refreshUserSubscriptions(
|
||||
@CurrentUser() user: CurrentUser
|
||||
): Promise<Subscription[]> {
|
||||
if (!user) {
|
||||
throw new AuthenticationRequired();
|
||||
}
|
||||
|
||||
let current = await this.db.subscription.findMany({
|
||||
where: {
|
||||
targetId: user.id,
|
||||
status: {
|
||||
in: [
|
||||
SubscriptionStatus.Active,
|
||||
SubscriptionStatus.Trialing,
|
||||
SubscriptionStatus.PastDue,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const existsPlans = Object.values(SubscriptionPlan);
|
||||
const subscriptions = current.reduce(
|
||||
(r, s) => {
|
||||
if (existsPlans.includes(s.plan as SubscriptionPlan)) {
|
||||
r[s.plan as SubscriptionPlan] = s.provider;
|
||||
}
|
||||
return r;
|
||||
},
|
||||
{} as Record<SubscriptionPlan, Provider>
|
||||
);
|
||||
|
||||
// has revenuecat subscription or no subscription at all
|
||||
const shouldSync =
|
||||
current.length === 0 ||
|
||||
subscriptions.pro === Provider.revenuecat ||
|
||||
subscriptions.ai === Provider.revenuecat;
|
||||
|
||||
if (shouldSync) {
|
||||
try {
|
||||
await this.rcHandler.syncAppUser(user.id);
|
||||
current = await this.db.subscription.findMany({
|
||||
where: {
|
||||
targetId: user.id,
|
||||
status: {
|
||||
in: [
|
||||
SubscriptionStatus.Active,
|
||||
SubscriptionStatus.Trialing,
|
||||
SubscriptionStatus.PastDue,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
// ignore errors
|
||||
} catch {}
|
||||
}
|
||||
|
||||
current.forEach(subscription => this.normalizeSubscription(subscription));
|
||||
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
@Resolver(() => WorkspaceType)
|
||||
|
||||
@@ -1299,6 +1299,9 @@ type Mutation {
|
||||
"""mark notification as read"""
|
||||
readNotification(id: String!): Boolean!
|
||||
recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime!
|
||||
|
||||
"""Refresh current user subscriptions and return latest."""
|
||||
refreshUserSubscriptions: [SubscriptionType!]!
|
||||
releaseDeletedBlobs(workspaceId: String!): Boolean!
|
||||
|
||||
"""Remove user avatar"""
|
||||
|
||||
Reference in New Issue
Block a user