From 4a7c931ecaf140adfb265962b1e92a465781b143 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:29:15 +0800 Subject: [PATCH] fix(server): member loading (#15156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### PR Dependency Tree * **PR #15156** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## 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. --- .../src/__tests__/e2e/workspace/team.spec.ts | 3 +- .../src/__tests__/payment/service.spec.ts | 113 ++++++++++++++++++ .../__tests__/projection-checker.spec.ts | 18 +-- .../entitlement/__tests__/projection.spec.ts | 30 +++-- .../src/plugins/payment/manager/selfhost.ts | 17 +-- .../src/plugins/payment/manager/user.ts | 17 +-- .../src/plugins/payment/manager/workspace.ts | 60 ++++++---- .../members/cloud-members-panel.tsx | 36 +----- .../workspace-setting/members/member-list.tsx | 56 ++++++--- .../workspace-setting/members/styles.css.ts | 5 +- .../modules/permissions/entities/members.ts | 3 +- 11 files changed, 248 insertions(+), 110 deletions(-) diff --git a/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts b/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts index 24a0edff72..bbda0e523a 100644 --- a/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts @@ -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 => { diff --git a/packages/backend/server/src/__tests__/payment/service.spec.ts b/packages/backend/server/src/__tests__/payment/service.spec.ts index fcf1c7afba..fb42a127f7 100644 --- a/packages/backend/server/src/__tests__/payment/service.spec.ts +++ b/packages/backend/server/src/__tests__/payment/service.spec.ts @@ -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; diff --git a/packages/backend/server/src/core/entitlement/__tests__/projection-checker.spec.ts b/packages/backend/server/src/core/entitlement/__tests__/projection-checker.spec.ts index a161ed33de..96cf477bcb 100644 --- a/packages/backend/server/src/core/entitlement/__tests__/projection-checker.spec.ts +++ b/packages/backend/server/src/core/entitlement/__tests__/projection-checker.spec.ts @@ -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: { diff --git a/packages/backend/server/src/core/entitlement/__tests__/projection.spec.ts b/packages/backend/server/src/core/entitlement/__tests__/projection.spec.ts index 324cf24015..fc05490aa6 100644 --- a/packages/backend/server/src/core/entitlement/__tests__/projection.spec.ts +++ b/packages/backend/server/src/core/entitlement/__tests__/projection.spec.ts @@ -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, diff --git a/packages/backend/server/src/plugins/payment/manager/selfhost.ts b/packages/backend/server/src/plugins/payment/manager/selfhost.ts index 9f65cfa2d1..481ff0e978 100644 --- a/packages/backend/server/src/plugins/payment/manager/selfhost.ts +++ b/packages/backend/server/src/plugins/payment/manager/selfhost.ts @@ -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, diff --git a/packages/backend/server/src/plugins/payment/manager/user.ts b/packages/backend/server/src/plugins/payment/manager/user.ts index a12fce9c32..bd242684b3 100644 --- a/packages/backend/server/src/plugins/payment/manager/user.ts +++ b/packages/backend/server/src/plugins/payment/manager/user.ts @@ -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. diff --git a/packages/backend/server/src/plugins/payment/manager/workspace.ts b/packages/backend/server/src/plugins/payment/manager/workspace.ts index e50f7397ab..99e697a078 100644 --- a/packages/backend/server/src/plugins/payment/manager/workspace.ts +++ b/packages/backend/server/src/plugins/payment/manager/workspace.ts @@ -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; } diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/cloud-members-panel.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/cloud-members-panel.tsx index c1cb7de58c..e501388a1d 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/cloud-members-panel.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/cloud-members-panel.tsx @@ -1,4 +1,4 @@ -import { Button, Loading, notify, useConfirmModal } from '@affine/component'; +import { Button, notify, useConfirmModal } from '@affine/component'; import { InviteTeamMemberModal, type InviteTeamMemberModalProps, @@ -31,7 +31,7 @@ import { nanoid } from 'nanoid'; import { useCallback, useEffect, useMemo, useState } from 'react'; import type { SettingState } from '../../types'; -import { MemberList } from './member-list'; +import { MemberList, MemberListError, MemberListFallback } from './member-list'; import * as styles from './styles.css'; const parseCSV = async (blob: Blob): Promise => { @@ -290,13 +290,7 @@ export const CloudWorkspaceMembersPanel = ({ if (isLoading) { return ; } else { - return ( - - {error - ? UserFriendlyError.fromAny(error).message - : 'Failed to load members'} - - ); + return ; } } @@ -358,30 +352,6 @@ export const MembersPanelFallback = () => { ); }; -const MemberListFallback = ({ memberCount }: { memberCount?: number }) => { - // prevent page jitter - const height = useMemo(() => { - if (memberCount) { - // height and margin-bottom - return memberCount * 58 + (memberCount - 1) * 6; - } - return 'auto'; - }, [memberCount]); - const t = useI18n(); - - return ( -
- - {t['com.affine.settings.member.loading']()} -
- ); -}; - const ImportCSV = ({ onImport }: { onImport: (file: File) => void }) => { const t = useI18n(); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-list.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-list.tsx index 26d3586fa7..6fea9f905c 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-list.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-list.tsx @@ -45,7 +45,6 @@ export const MemberList = ({ const handlePageChange = useCallback( (_: number, pageNum: number) => { membersService.members.setPageNum(pageNum); - membersService.members.revalidate(); }, [membersService] ); @@ -57,7 +56,7 @@ export const MemberList = ({ return (
{pageMembers === undefined ? ( - isLoading ? ( + isLoading || !error ? ( ) : ( - - {error - ? UserFriendlyError.fromAny(error).message - : 'Failed to load members'} - + ) ) : ( pageMembers?.map(member => ( @@ -278,19 +273,24 @@ const getMemberStatus = (member: Member): I18nString => { } }; +const getMembersFallbackHeight = (memberCount?: number) => { + if (memberCount) { + // height and margin-bottom + return memberCount * 58 + (memberCount - 1) * 6; + } + return 'auto'; +}; + export const MemberListFallback = ({ memberCount, }: { memberCount?: number; }) => { // prevent page jitter - const height = useMemo(() => { - if (memberCount) { - // height and margin-bottom - return memberCount * 58 + (memberCount - 1) * 6; - } - return 'auto'; - }, [memberCount]); + const height = useMemo( + () => getMembersFallbackHeight(memberCount), + [memberCount] + ); const t = useI18n(); return ( @@ -305,3 +305,31 @@ export const MemberListFallback = ({
); }; + +export const MemberListError = ({ + error, + memberCount, +}: { + error?: unknown; + memberCount?: number; +}) => { + const height = useMemo( + () => getMembersFallbackHeight(memberCount), + [memberCount] + ); + + return ( +
+ + {error + ? UserFriendlyError.fromAny(error).message + : 'Failed to load members'} + +
+ ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/styles.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/styles.css.ts index 03b9a2734a..e6a2ee0880 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/styles.css.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/styles.css.ts @@ -55,11 +55,12 @@ export const errorStyle = style({ export const membersFallback = style({ display: 'flex', justifyContent: 'center', - alignItems: 'flexStart', + alignItems: 'center', color: cssVarV2('text/secondary'), gap: '4px', - padding: '8px', + padding: '16px 8px', fontSize: cssVar('fontXs'), + textAlign: 'center', }); export const memberListItem = style({ diff --git a/packages/frontend/core/src/modules/permissions/entities/members.ts b/packages/frontend/core/src/modules/permissions/entities/members.ts index 9d9effec99..b06fe35425 100644 --- a/packages/frontend/core/src/modules/permissions/entities/members.ts +++ b/packages/frontend/core/src/modules/permissions/entities/members.ts @@ -66,8 +66,9 @@ export class WorkspaceMembers extends Entity { } private async requestMembers(signal: AbortSignal) { - this.pageMembers$.setValue(undefined); this.isLoading$.setValue(true); + this.error$.setValue(null); + this.pageMembers$.setValue(undefined); try { const pageNum = this.pageNum$.value; return await this.store.fetchMembers(