mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-06 11:59:51 +08:00
fix(server): member loading (#15156)
#### PR Dependency Tree * **PR #15156** 👈 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 * **Bug Fixes** * Fixed Stripe subscription syncing for Team plans to update the correct existing local subscription (avoiding duplicates) while refreshing quantity and billing/trial period details. * **UI/UX Improvements** * Improved workspace member list loading/error states with shared UI components and steadier pagination behavior. * Refined fallback styling for cleaner, more stable layout. * **Tests** * Expanded subscription and projection coverage and adjusted seat-allocation/e2e assertions to be more robust. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -187,6 +187,7 @@ e2e('should allocate seats', async t => {
|
||||
source: 'Link',
|
||||
});
|
||||
|
||||
const invitationCount = app.queue.count('notification.sendInvitation');
|
||||
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
|
||||
workspaceId: workspace.id,
|
||||
quantity: 5,
|
||||
@@ -206,7 +207,7 @@ e2e('should allocate seats', async t => {
|
||||
WorkspaceMemberStatus.Accepted
|
||||
);
|
||||
|
||||
t.is(app.queue.count('notification.sendInvitation'), 1);
|
||||
t.is(app.queue.count('notification.sendInvitation') - invitationCount, 1);
|
||||
});
|
||||
|
||||
e2e('should set all rests to NeedMoreSeat', async t => {
|
||||
|
||||
@@ -1552,6 +1552,48 @@ test('should be able to create team subscription', async t => {
|
||||
t.is(subInDB?.stripeSubscriptionId, sub.id);
|
||||
});
|
||||
|
||||
test('should replace old team subscription row when stripe creates a new subscription', async t => {
|
||||
const { service, db } = t.context;
|
||||
|
||||
const old = await db.subscription.create({
|
||||
data: {
|
||||
targetId: 'ws_1',
|
||||
stripeSubscriptionId: 'sub_old_team',
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Canceled,
|
||||
start: new Date('2026-03-26T08:23:57.000Z'),
|
||||
end: new Date('2027-03-26T08:23:57.000Z'),
|
||||
quantity: 24,
|
||||
},
|
||||
});
|
||||
|
||||
await service.saveStripeSubscription({
|
||||
...teamSub,
|
||||
id: 'sub_new_team',
|
||||
status: SubscriptionStatus.Active,
|
||||
items: {
|
||||
...teamSub.items,
|
||||
data: [
|
||||
{
|
||||
...teamSub.items.data[0],
|
||||
quantity: 11,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const subscriptions = await db.subscription.findMany({
|
||||
where: { targetId: 'ws_1', plan: SubscriptionPlan.Team },
|
||||
});
|
||||
|
||||
t.is(subscriptions.length, 1);
|
||||
t.is(subscriptions[0].id, old.id);
|
||||
t.is(subscriptions[0].stripeSubscriptionId, 'sub_new_team');
|
||||
t.is(subscriptions[0].status, SubscriptionStatus.Active);
|
||||
t.is(subscriptions[0].quantity, 11);
|
||||
});
|
||||
|
||||
test('should be able to update team subscription', async t => {
|
||||
const { service, db, event } = t.context;
|
||||
|
||||
@@ -1586,6 +1628,77 @@ test('should be able to update team subscription', async t => {
|
||||
);
|
||||
});
|
||||
|
||||
test('should persist mutable team subscription fields on same stripe subscription update', async t => {
|
||||
const { service, db } = t.context;
|
||||
|
||||
await service.saveStripeSubscription(teamSub);
|
||||
|
||||
await service.saveStripeSubscription({
|
||||
...teamSub,
|
||||
current_period_start: 1780000000,
|
||||
current_period_end: 1811536000,
|
||||
trial_start: 1780000000,
|
||||
trial_end: 1780604800,
|
||||
items: {
|
||||
...teamSub.items,
|
||||
data: [
|
||||
{
|
||||
...teamSub.items.data[0],
|
||||
quantity: 9,
|
||||
price: {
|
||||
...PRICES[TEAM_YEARLY],
|
||||
lookup_key: TEAM_YEARLY,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const subInDB = await db.subscription.findFirst({
|
||||
where: { targetId: 'ws_1' },
|
||||
});
|
||||
const entitlement = await db.entitlement.findFirst({
|
||||
where: {
|
||||
source: 'cloud_subscription',
|
||||
subjectId: teamSub.id,
|
||||
},
|
||||
});
|
||||
const providerFact = await db.providerSubscription.findUnique({
|
||||
where: {
|
||||
provider_externalSubscriptionId: {
|
||||
provider: 'stripe',
|
||||
externalSubscriptionId: teamSub.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.like(subInDB, {
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
quantity: 9,
|
||||
start: new Date(1780000000 * 1000),
|
||||
end: new Date(1811536000 * 1000),
|
||||
trialStart: new Date(1780000000 * 1000),
|
||||
trialEnd: new Date(1780604800 * 1000),
|
||||
});
|
||||
t.like(entitlement, {
|
||||
plan: 'team',
|
||||
quantity: 9,
|
||||
startsAt: new Date(1780000000 * 1000),
|
||||
expiresAt: new Date(1811536000 * 1000),
|
||||
});
|
||||
t.like(providerFact, {
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
externalPriceId: TEAM_YEARLY,
|
||||
currency: 'usd',
|
||||
amount: 14400,
|
||||
quantity: 9,
|
||||
periodStart: new Date(1780000000 * 1000),
|
||||
periodEnd: new Date(1811536000 * 1000),
|
||||
trialStart: new Date(1780000000 * 1000),
|
||||
trialEnd: new Date(1780604800 * 1000),
|
||||
});
|
||||
});
|
||||
|
||||
test('should suspend on dispute and restore when dispute won', async t => {
|
||||
const { service, db, stripe, event } = t.context;
|
||||
|
||||
|
||||
@@ -76,15 +76,15 @@ test('checker reports missing legacy projection and stale state', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: 'pro',
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
});
|
||||
await t.context.db.subscription.delete({
|
||||
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
||||
});
|
||||
await t.context.entitlement.upsertFromCloudSubscription(
|
||||
{
|
||||
targetId: user.id,
|
||||
plan: 'pro',
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
},
|
||||
{ emit: false }
|
||||
);
|
||||
await t.context.db.effectiveUserQuotaState.update({
|
||||
where: { userId: user.id },
|
||||
data: {
|
||||
|
||||
@@ -48,18 +48,24 @@ test('projects user entitlement to legacy user features and subscriptions', asyn
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: 'active',
|
||||
});
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
});
|
||||
await t.context.entitlement.upsertFromCloudSubscription(
|
||||
{
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: 'active',
|
||||
},
|
||||
{ emit: false }
|
||||
);
|
||||
await t.context.entitlement.upsertFromCloudSubscription(
|
||||
{
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
},
|
||||
{ emit: false }
|
||||
);
|
||||
await t.context.projection.onEntitlementChanged({
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient, Provider, UserStripeCustomer } from '@prisma/client';
|
||||
import { omit, pick } from 'lodash-es';
|
||||
import { omit } from 'lodash-es';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { SubscriptionPlanNotFound, URLHelper } from '../../../base';
|
||||
@@ -163,13 +163,14 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
|
||||
where: {
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
data: pick(subscriptionData, [
|
||||
'status',
|
||||
'stripeScheduleId',
|
||||
'nextBillAt',
|
||||
'canceledAt',
|
||||
'end',
|
||||
]),
|
||||
data: {
|
||||
...omit(subscriptionData, ['provider', 'iapStore']),
|
||||
provider: Provider.stripe,
|
||||
iapStore: null,
|
||||
rcEntitlement: null,
|
||||
rcProductId: null,
|
||||
rcExternalRef: null,
|
||||
},
|
||||
});
|
||||
await this.upsertStripeProviderSubscription(
|
||||
saved.targetId,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Provider,
|
||||
UserStripeCustomer,
|
||||
} from '@prisma/client';
|
||||
import { omit, pick } from 'lodash-es';
|
||||
import { omit } from 'lodash-es';
|
||||
import Stripe from 'stripe';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -272,13 +272,14 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
const saved = existingByStripeId
|
||||
? await this.db.subscription.update({
|
||||
where: { id: existingByStripeId.id },
|
||||
data: pick(subscriptionData, [
|
||||
'status',
|
||||
'stripeScheduleId',
|
||||
'nextBillAt',
|
||||
'canceledAt',
|
||||
'end',
|
||||
]),
|
||||
data: {
|
||||
...omit(subscriptionData, ['provider', 'iapStore']),
|
||||
provider: Provider.stripe,
|
||||
iapStore: null,
|
||||
rcEntitlement: null,
|
||||
rcProductId: null,
|
||||
rcExternalRef: null,
|
||||
},
|
||||
})
|
||||
: await this.db.subscription.upsert({
|
||||
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient, Provider, UserStripeCustomer } from '@prisma/client';
|
||||
import { omit, pick } from 'lodash-es';
|
||||
import { omit } from 'lodash-es';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
@@ -163,28 +163,44 @@ 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,
|
||||
},
|
||||
update: {
|
||||
...pick(subscriptionData, [
|
||||
'status',
|
||||
'stripeScheduleId',
|
||||
'nextBillAt',
|
||||
'canceledAt',
|
||||
'quantity',
|
||||
'end',
|
||||
]),
|
||||
},
|
||||
create: {
|
||||
targetId: workspaceId,
|
||||
...omit(subscriptionData, 'provider', 'iapStore'),
|
||||
},
|
||||
const existingByStripeId = await this.db.subscription.findUnique({
|
||||
where: { stripeSubscriptionId: stripeSubscription.id },
|
||||
});
|
||||
|
||||
const saved = existingByStripeId
|
||||
? await this.db.subscription.update({
|
||||
where: { id: existingByStripeId.id },
|
||||
data: {
|
||||
...omit(subscriptionData, ['provider', 'iapStore']),
|
||||
provider: Provider.stripe,
|
||||
iapStore: null,
|
||||
rcEntitlement: null,
|
||||
rcProductId: null,
|
||||
rcExternalRef: null,
|
||||
},
|
||||
})
|
||||
: 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: workspaceId,
|
||||
plan: lookupKey.plan,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
...omit(subscriptionData, ['provider', 'iapStore']),
|
||||
provider: Provider.stripe,
|
||||
iapStore: null,
|
||||
rcEntitlement: null,
|
||||
rcProductId: null,
|
||||
rcExternalRef: null,
|
||||
},
|
||||
create: {
|
||||
targetId: workspaceId,
|
||||
...omit(subscriptionData, 'provider', 'iapStore'),
|
||||
},
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user