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

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