mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-22 03:33:06 +08: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 ava, { TestFn } from 'ava';
|
||||||
import { omit } from 'lodash-es';
|
import { omit } from 'lodash-es';
|
||||||
import Sinon from 'sinon';
|
import Sinon from 'sinon';
|
||||||
@@ -14,6 +14,7 @@ import { Models } from '../../models';
|
|||||||
import { PaymentModule } from '../../plugins/payment';
|
import { PaymentModule } from '../../plugins/payment';
|
||||||
import { SubscriptionCronJobs } from '../../plugins/payment/cron';
|
import { SubscriptionCronJobs } from '../../plugins/payment/cron';
|
||||||
import { UserSubscriptionManager } from '../../plugins/payment/manager';
|
import { UserSubscriptionManager } from '../../plugins/payment/manager';
|
||||||
|
import { UserSubscriptionResolver } from '../../plugins/payment/resolver';
|
||||||
import {
|
import {
|
||||||
RcEvent,
|
RcEvent,
|
||||||
resolveProductMapping,
|
resolveProductMapping,
|
||||||
@@ -39,6 +40,7 @@ type Ctx = {
|
|||||||
rc: RevenueCatService;
|
rc: RevenueCatService;
|
||||||
webhook: RevenueCatWebhookHandler;
|
webhook: RevenueCatWebhookHandler;
|
||||||
controller: RevenueCatWebhookController;
|
controller: RevenueCatWebhookController;
|
||||||
|
subResolver: UserSubscriptionResolver;
|
||||||
|
|
||||||
mockSub: (subs: Subscription[]) => Sinon.SinonStub;
|
mockSub: (subs: Subscription[]) => Sinon.SinonStub;
|
||||||
mockSubSeq: (sequences: Subscription[][]) => Sinon.SinonStub;
|
mockSubSeq: (sequences: Subscription[][]) => Sinon.SinonStub;
|
||||||
@@ -85,6 +87,7 @@ test.beforeEach(async t => {
|
|||||||
const rc = app.get(RevenueCatService);
|
const rc = app.get(RevenueCatService);
|
||||||
const webhook = app.get(RevenueCatWebhookHandler);
|
const webhook = app.get(RevenueCatWebhookHandler);
|
||||||
const controller = app.get(RevenueCatWebhookController);
|
const controller = app.get(RevenueCatWebhookController);
|
||||||
|
const subResolver = app.get(UserSubscriptionResolver);
|
||||||
|
|
||||||
t.context.module = app;
|
t.context.module = app;
|
||||||
t.context.db = db;
|
t.context.db = db;
|
||||||
@@ -95,6 +98,7 @@ test.beforeEach(async t => {
|
|||||||
t.context.rc = rc;
|
t.context.rc = rc;
|
||||||
t.context.webhook = webhook;
|
t.context.webhook = webhook;
|
||||||
t.context.controller = controller;
|
t.context.controller = controller;
|
||||||
|
t.context.subResolver = subResolver;
|
||||||
|
|
||||||
t.context.mockSub = subs => Sinon.stub(rc, 'getSubscriptions').resolves(subs);
|
t.context.mockSub = subs => Sinon.stub(rc, 'getSubscriptions').resolves(subs);
|
||||||
t.context.mockSubSeq = sequences => {
|
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;
|
const after = event.emitAsync.getCalls()?.length || 0;
|
||||||
t.is(after - before, 0, 'should not emit event');
|
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,
|
ResolveField,
|
||||||
Resolver,
|
Resolver,
|
||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
import type { User } from '@prisma/client';
|
import { PrismaClient, Provider, type User } from '@prisma/client';
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import { GraphQLJSONObject } from 'graphql-scalars';
|
import { GraphQLJSONObject } from 'graphql-scalars';
|
||||||
import { groupBy } from 'lodash-es';
|
import { groupBy } from 'lodash-es';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
@@ -31,6 +30,7 @@ import { AccessController } from '../../core/permission';
|
|||||||
import { UserType } from '../../core/user';
|
import { UserType } from '../../core/user';
|
||||||
import { WorkspaceType } from '../../core/workspaces';
|
import { WorkspaceType } from '../../core/workspaces';
|
||||||
import { Invoice, Subscription, WorkspaceSubscriptionManager } from './manager';
|
import { Invoice, Subscription, WorkspaceSubscriptionManager } from './manager';
|
||||||
|
import { RevenueCatWebhookHandler } from './revenuecat';
|
||||||
import { CheckoutParams, SubscriptionService } from './service';
|
import { CheckoutParams, SubscriptionService } from './service';
|
||||||
import {
|
import {
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
@@ -463,7 +463,22 @@ export class SubscriptionResolver {
|
|||||||
|
|
||||||
@Resolver(() => UserType)
|
@Resolver(() => UserType)
|
||||||
export class UserSubscriptionResolver {
|
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])
|
@ResolveField(() => [SubscriptionType])
|
||||||
async subscriptions(
|
async subscriptions(
|
||||||
@@ -487,16 +502,9 @@ export class UserSubscriptionResolver {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
subscriptions.forEach(subscription => {
|
subscriptions.forEach(subscription =>
|
||||||
if (
|
this.normalizeSubscription(subscription)
|
||||||
subscription.variant &&
|
);
|
||||||
![SubscriptionVariant.EA, SubscriptionVariant.Onetime].includes(
|
|
||||||
subscription.variant as SubscriptionVariant
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
subscription.variant = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return subscriptions;
|
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)
|
@Resolver(() => WorkspaceType)
|
||||||
|
|||||||
@@ -1299,6 +1299,9 @@ type Mutation {
|
|||||||
"""mark notification as read"""
|
"""mark notification as read"""
|
||||||
readNotification(id: String!): Boolean!
|
readNotification(id: String!): Boolean!
|
||||||
recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime!
|
recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime!
|
||||||
|
|
||||||
|
"""Refresh current user subscriptions and return latest."""
|
||||||
|
refreshUserSubscriptions: [SubscriptionType!]!
|
||||||
releaseDeletedBlobs(workspaceId: String!): Boolean!
|
releaseDeletedBlobs(workspaceId: String!): Boolean!
|
||||||
|
|
||||||
"""Remove user avatar"""
|
"""Remove user avatar"""
|
||||||
|
|||||||
@@ -2218,6 +2218,25 @@ export const setWorkspacePublicByIdMutation = {
|
|||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const refreshSubscriptionMutation = {
|
||||||
|
id: 'refreshSubscriptionMutation' as const,
|
||||||
|
op: 'refreshSubscription',
|
||||||
|
query: `mutation refreshSubscription {
|
||||||
|
refreshUserSubscriptions {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
plan
|
||||||
|
recurring
|
||||||
|
start
|
||||||
|
end
|
||||||
|
nextBillAt
|
||||||
|
canceledAt
|
||||||
|
variant
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
deprecations: ["'id' is deprecated: removed"],
|
||||||
|
};
|
||||||
|
|
||||||
export const subscriptionQuery = {
|
export const subscriptionQuery = {
|
||||||
id: 'subscriptionQuery' as const,
|
id: 'subscriptionQuery' as const,
|
||||||
op: 'subscription',
|
op: 'subscription',
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
mutation refreshSubscription {
|
||||||
|
refreshUserSubscriptions {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
plan
|
||||||
|
recurring
|
||||||
|
start
|
||||||
|
end
|
||||||
|
nextBillAt
|
||||||
|
canceledAt
|
||||||
|
variant
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1451,6 +1451,8 @@ export interface Mutation {
|
|||||||
/** mark notification as read */
|
/** mark notification as read */
|
||||||
readNotification: Scalars['Boolean']['output'];
|
readNotification: Scalars['Boolean']['output'];
|
||||||
recoverDoc: Scalars['DateTime']['output'];
|
recoverDoc: Scalars['DateTime']['output'];
|
||||||
|
/** Refresh current user subscriptions and return latest. */
|
||||||
|
refreshUserSubscriptions: Array<SubscriptionType>;
|
||||||
releaseDeletedBlobs: Scalars['Boolean']['output'];
|
releaseDeletedBlobs: Scalars['Boolean']['output'];
|
||||||
/** Remove user avatar */
|
/** Remove user avatar */
|
||||||
removeAvatar: RemoveAvatar;
|
removeAvatar: RemoveAvatar;
|
||||||
@@ -5996,6 +5998,26 @@ export type SetWorkspacePublicByIdMutation = {
|
|||||||
updateWorkspace: { __typename?: 'WorkspaceType'; id: string };
|
updateWorkspace: { __typename?: 'WorkspaceType'; id: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RefreshSubscriptionMutationVariables = Exact<{
|
||||||
|
[key: string]: never;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type RefreshSubscriptionMutation = {
|
||||||
|
__typename?: 'Mutation';
|
||||||
|
refreshUserSubscriptions: Array<{
|
||||||
|
__typename?: 'SubscriptionType';
|
||||||
|
id: string | null;
|
||||||
|
status: SubscriptionStatus;
|
||||||
|
plan: SubscriptionPlan;
|
||||||
|
recurring: SubscriptionRecurring;
|
||||||
|
start: string;
|
||||||
|
end: string | null;
|
||||||
|
nextBillAt: string | null;
|
||||||
|
canceledAt: string | null;
|
||||||
|
variant: SubscriptionVariant | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export type SubscriptionQueryVariables = Exact<{ [key: string]: never }>;
|
export type SubscriptionQueryVariables = Exact<{ [key: string]: never }>;
|
||||||
|
|
||||||
export type SubscriptionQuery = {
|
export type SubscriptionQuery = {
|
||||||
@@ -7081,6 +7103,11 @@ export type Mutations =
|
|||||||
variables: SetWorkspacePublicByIdMutationVariables;
|
variables: SetWorkspacePublicByIdMutationVariables;
|
||||||
response: SetWorkspacePublicByIdMutation;
|
response: SetWorkspacePublicByIdMutation;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
name: 'refreshSubscriptionMutation';
|
||||||
|
variables: RefreshSubscriptionMutationVariables;
|
||||||
|
response: RefreshSubscriptionMutation;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
name: 'updateDocDefaultRoleMutation';
|
name: 'updateDocDefaultRoleMutation';
|
||||||
variables: UpdateDocDefaultRoleMutationVariables;
|
variables: UpdateDocDefaultRoleMutationVariables;
|
||||||
|
|||||||
Reference in New Issue
Block a user