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:
DarkSky
2025-09-29 20:35:18 +08:00
committed by GitHub
parent 8006812bc0
commit 2d1caff45c
6 changed files with 240 additions and 14 deletions

View File

@@ -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');
}
});

View File

@@ -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)

View File

@@ -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"""