@tuturuuu/ui 0.38.0 → 0.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/package.json +8 -8
  3. package/src/components/ui/custom/public-profile-menu-item.tsx +25 -0
  4. package/src/components/ui/custom/satellite-user-menu-items.test.tsx +61 -0
  5. package/src/components/ui/custom/satellite-user-menu-items.tsx +8 -0
  6. package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
  7. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  8. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  9. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  10. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  11. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  12. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  13. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  14. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  15. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  16. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  17. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  18. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  19. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  20. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  21. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  22. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  23. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  24. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  25. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  26. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  27. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  28. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  29. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  30. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  31. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  32. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +489 -0
  33. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  34. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  35. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  36. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  37. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  38. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  39. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  40. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  41. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  42. package/src/components/ui/finance/invoices/types.ts +3 -3
  43. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  44. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  45. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  46. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  47. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  48. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  49. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  50. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  51. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  52. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  53. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  54. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1554
  55. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  56. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  57. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  58. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  59. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  60. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  61. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  62. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  63. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  64. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  65. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  66. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  67. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  68. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  69. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  70. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  71. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  72. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +1 -1
  73. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +1 -1
  74. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  75. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  76. package/src/components/ui/public-link-button.test.tsx +116 -0
  77. package/src/components/ui/public-link-button.tsx +81 -0
  78. package/src/components/ui/text-editor/editor.tsx +3 -0
  79. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  80. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  81. package/src/components/ui/text-editor/tool-bar.tsx +18 -74
  82. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  83. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  84. package/src/hooks/__tests__/supabase-provider.test.ts +101 -7
  85. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +27 -0
  86. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  87. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  88. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +39 -0
  89. package/src/hooks/calendar-connection-types.ts +49 -0
  90. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  91. package/src/hooks/calendar-event-write-payload.ts +33 -0
  92. package/src/hooks/collaboration-document-hydration.ts +35 -0
  93. package/src/hooks/supabase-provider.ts +39 -41
  94. package/src/hooks/use-calendar-sync.tsx +41 -62
  95. package/src/hooks/use-calendar.tsx +24 -38
  96. package/src/hooks/use-local-storage.ts +9 -11
  97. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  98. package/src/hooks/use-yjs-collaboration.ts +23 -0
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ hasInvalidSubscriptionLinks,
4
+ isSubscriptionContextReady,
5
+ } from './subscription-admission';
6
+ import type { Product, UserGroupProducts } from './types';
7
+
8
+ const valid: UserGroupProducts = {
9
+ group_id: 'g',
10
+ workspace_products: { id: 'p', name: null, product_categories: null },
11
+ inventory_units: { id: 'u', name: null },
12
+ warehouse_id: null,
13
+ };
14
+ const products = [
15
+ { id: 'p', inventory: [{ unit_id: 'u', amount: null }] },
16
+ ] as Product[];
17
+ describe('entire linked billing admission', () => {
18
+ it('accepts valid scoped links without imposing a category or warehouse', () => {
19
+ expect(hasInvalidSubscriptionLinks([valid], products)).toBe(false);
20
+ });
21
+ it.each([
22
+ { ...valid, inventory_units: null },
23
+ { ...valid, workspace_products: null },
24
+ { ...valid, inventory_units: { id: 'other', name: null } },
25
+ { ...valid, inventory_units: { id: ' ', name: null } },
26
+ ])(
27
+ 'blocks a malformed linked charge even when another charge is valid',
28
+ (invalid) => {
29
+ expect(hasInvalidSubscriptionLinks([valid, invalid], products)).toBe(
30
+ true
31
+ );
32
+ }
33
+ );
34
+ it('blocks a disappeared catalog product', () => {
35
+ expect(hasInvalidSubscriptionLinks([valid], [])).toBe(true);
36
+ });
37
+ });
38
+ describe('authoritative schedule readiness', () => {
39
+ const ready = {
40
+ groupIds: ['g'],
41
+ scheduledSessionsByGroupId: { g: [] },
42
+ loading: false,
43
+ error: null,
44
+ };
45
+ it('distinguishes an authoritative empty list from missing data', () => {
46
+ expect(isSubscriptionContextReady(ready)).toBe(true);
47
+ expect(
48
+ isSubscriptionContextReady({
49
+ ...ready,
50
+ scheduledSessionsByGroupId: undefined,
51
+ })
52
+ ).toBe(false);
53
+ expect(
54
+ isSubscriptionContextReady({ ...ready, scheduledSessionsByGroupId: {} })
55
+ ).toBe(false);
56
+ });
57
+ it('does not authorize creation while loading or after a failed refresh with retained data', () => {
58
+ expect(isSubscriptionContextReady({ ...ready, loading: true })).toBe(false);
59
+ expect(
60
+ isSubscriptionContextReady({
61
+ ...ready,
62
+ error: new Error('synthetic unavailable'),
63
+ })
64
+ ).toBe(false);
65
+ });
66
+ it.each(['2026-02-30', '2026-13-01', '2026-01-01T00:00:00Z', 'not-a-date'])(
67
+ 'rejects malformed civil schedule receipt %s',
68
+ (date) => {
69
+ expect(
70
+ isSubscriptionContextReady({
71
+ ...ready,
72
+ scheduledSessionsByGroupId: { g: [date] },
73
+ })
74
+ ).toBe(false);
75
+ }
76
+ );
77
+ });
@@ -0,0 +1,101 @@
1
+ import type { Product, UserGroupProducts } from './types';
2
+ import {
3
+ getMonthStartDate,
4
+ isSubscriptionRangeFullyPaidForGroups,
5
+ parseLocalCalendarDate,
6
+ type SubscriptionCoverageInvoice,
7
+ } from './utils';
8
+
9
+ export function hasInvalidSubscriptionLinks(
10
+ links: UserGroupProducts[],
11
+ products: Product[]
12
+ ): boolean {
13
+ return links.some((link) => {
14
+ const productId = link.workspace_products?.id;
15
+ const unitId = link.inventory_units?.id;
16
+ if (
17
+ typeof productId !== 'string' ||
18
+ !productId.trim() ||
19
+ typeof unitId !== 'string' ||
20
+ !unitId.trim()
21
+ )
22
+ return true;
23
+ const product = products.find((item) => item.id === productId);
24
+ return (
25
+ !product ||
26
+ !Array.isArray(product.inventory) ||
27
+ !product.inventory.some((inventory) => inventory.unit_id === unitId)
28
+ );
29
+ });
30
+ }
31
+
32
+ export function isSubscriptionContextReady({
33
+ groupIds,
34
+ scheduledSessionsByGroupId,
35
+ loading,
36
+ error,
37
+ }: {
38
+ groupIds: string[];
39
+ scheduledSessionsByGroupId?: Record<string, string[]>;
40
+ loading: boolean;
41
+ error: unknown;
42
+ }): boolean {
43
+ return (
44
+ !loading &&
45
+ !error &&
46
+ !!scheduledSessionsByGroupId &&
47
+ groupIds.every(
48
+ (id) =>
49
+ Object.hasOwn(scheduledSessionsByGroupId, id) &&
50
+ Array.isArray(scheduledSessionsByGroupId[id]) &&
51
+ scheduledSessionsByGroupId[id]!.every(
52
+ (date) =>
53
+ typeof date === 'string' &&
54
+ /^\d{4}-\d{2}-\d{2}$/.test(date) &&
55
+ !Number.isNaN(parseLocalCalendarDate(date).getTime())
56
+ )
57
+ )
58
+ );
59
+ }
60
+
61
+ export function isSelectedSubscriptionRangePaid(
62
+ groupIds: string[],
63
+ month: string,
64
+ monthCount: number,
65
+ invoices: SubscriptionCoverageInvoice[]
66
+ ): boolean {
67
+ if (
68
+ !groupIds.length ||
69
+ !month ||
70
+ Number.isNaN(getMonthStartDate(month).getTime())
71
+ )
72
+ return false;
73
+ return isSubscriptionRangeFullyPaidForGroups(
74
+ groupIds,
75
+ month,
76
+ monthCount,
77
+ invoices
78
+ );
79
+ }
80
+
81
+ export function getSubscriptionBlockReason(
82
+ invalidLinks: boolean,
83
+ ready: boolean,
84
+ pending: boolean,
85
+ errors: unknown[],
86
+ groupIds: string[],
87
+ scheduledSessionsByGroupId?: Record<string, string[]>
88
+ ) {
89
+ if (invalidLinks) return 'invalid_subscription_inventory' as const;
90
+ if (
91
+ !ready ||
92
+ !isSubscriptionContextReady({
93
+ groupIds,
94
+ scheduledSessionsByGroupId,
95
+ loading: pending,
96
+ error: errors.some(Boolean),
97
+ })
98
+ )
99
+ return 'subscription_context_not_ready' as const;
100
+ return undefined;
101
+ }
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { mergeBillingSessionDates } from './subscription-billing-groups';
2
+ import {
3
+ getBillingScheduleWindow,
4
+ mergeBillingSessionDates,
5
+ } from './subscription-billing-groups';
3
6
  import { getBillableQuantityForGroupRange, type UserGroup } from './utils';
4
7
 
5
8
  describe('subscription billing session dates', () => {
@@ -44,3 +47,161 @@ describe('subscription billing session dates', () => {
44
47
  ).toBe(7);
45
48
  });
46
49
  });
50
+
51
+ describe('bounded authoritative schedule responses', () => {
52
+ const groupId = 'bounded-class';
53
+ const window = { startDate: '2026-09-01', endDate: '2026-10-01' };
54
+ const groups: UserGroup[] = [
55
+ {
56
+ workspace_user_groups: {
57
+ id: groupId,
58
+ name: 'Synthetic class',
59
+ // Dates outside the requested September window are historical data.
60
+ sessions: ['2026-08-31', '2026-09-06', '2026-10-04'],
61
+ } as NonNullable<UserGroup['workspace_user_groups']>,
62
+ },
63
+ ];
64
+ const quantity = (userGroups: UserGroup[]) =>
65
+ getBillableQuantityForGroupRange({
66
+ groupId,
67
+ userGroups,
68
+ selectedMonth: '2026-09',
69
+ useAttendanceBased: false,
70
+ userAttendance: [],
71
+ now: new Date('2026-09-15T12:00:00Z'),
72
+ workspaceTimezone: 'Asia/Ho_Chi_Minh',
73
+ });
74
+
75
+ it('does not bill a removed same-window date from a nonempty authoritative response', () => {
76
+ // The context route queried [2026-09-01, 2026-10-01). September 6 is
77
+ // absent from its scheduled/cancellation-aware result, not extra billing.
78
+ const merged = mergeBillingSessionDates(
79
+ groups,
80
+ {
81
+ [groupId]: ['2026-09-05', '2026-09-12'],
82
+ },
83
+ window
84
+ );
85
+ expect(quantity(merged)).toBe(2);
86
+ expect(merged[0]?.workspace_user_groups?.sessions).toEqual([
87
+ '2026-08-31',
88
+ '2026-09-05',
89
+ '2026-09-12',
90
+ '2026-10-04',
91
+ ]);
92
+ expect(groups[0]?.workspace_user_groups?.sessions).toEqual([
93
+ '2026-08-31',
94
+ '2026-09-06',
95
+ '2026-10-04',
96
+ ]);
97
+ });
98
+
99
+ it('accepts an authoritative empty window without deleting historical dates', () => {
100
+ const merged = mergeBillingSessionDates(groups, { [groupId]: [] }, window);
101
+ expect(quantity(merged)).toBe(0);
102
+ expect(merged[0]?.workspace_user_groups?.sessions).toEqual([
103
+ '2026-08-31',
104
+ '2026-10-04',
105
+ ]);
106
+ });
107
+
108
+ it('retains legacy dates when no authoritative response has arrived', () => {
109
+ const merged = mergeBillingSessionDates(groups, undefined, window);
110
+ expect(quantity(merged)).toBe(1);
111
+ expect(merged[0]).toBe(groups[0]);
112
+ });
113
+
114
+ it('does not crash or acquire authority from a malformed non-array group receipt', () => {
115
+ const merged = mergeBillingSessionDates(
116
+ groups,
117
+ { [groupId]: {} } as unknown as Record<string, string[]>,
118
+ window
119
+ );
120
+ expect(quantity(merged)).toBe(1);
121
+ expect(merged[0]).toBe(groups[0]);
122
+ });
123
+
124
+ it('does not treat a missing group key as an authoritative empty result', () => {
125
+ const merged = mergeBillingSessionDates(groups, {}, window);
126
+ expect(quantity(merged)).toBe(1);
127
+ expect(merged[0]).toBe(groups[0]);
128
+ });
129
+ });
130
+
131
+ describe('prepaid civil authority boundaries', () => {
132
+ it.each([
133
+ ['2026-12', 2, '2027-02-01'],
134
+ ['2026-01', 12, '2027-01-01'],
135
+ ['2026-01', 0, '2026-02-01'],
136
+ ['2026-01', 99, '2027-01-01'],
137
+ ] as const)('bounds %s for %i prepaid months', (month, count, endDate) => {
138
+ expect(getBillingScheduleWindow(month, count)).toEqual({
139
+ startDate: `${month}-01`,
140
+ endDate,
141
+ });
142
+ });
143
+ it('rejects an invalid civil month', () => {
144
+ expect(getBillingScheduleWindow('2026-13', 2)).toBeUndefined();
145
+ });
146
+ it('limits authoritative replacements and incoming dates to the prepaid year-crossing window', () => {
147
+ const window = getBillingScheduleWindow('2026-12', 2)!;
148
+ const groups = [
149
+ {
150
+ workspace_user_groups: {
151
+ id: 'g',
152
+ sessions: ['2026-11-30', '2026-12-06', '2027-01-03', '2027-02-01'],
153
+ } as NonNullable<UserGroup['workspace_user_groups']>,
154
+ },
155
+ ];
156
+ const merged = mergeBillingSessionDates(
157
+ groups,
158
+ {
159
+ g: [
160
+ '2026-11-29',
161
+ '2026-12-05',
162
+ '2027-01-02',
163
+ '2027-01-02',
164
+ '2027-02-02',
165
+ ],
166
+ },
167
+ window
168
+ );
169
+ expect(merged[0]?.workspace_user_groups?.sessions).toEqual([
170
+ '2026-11-30',
171
+ '2026-12-05',
172
+ '2027-01-02',
173
+ '2027-02-01',
174
+ ]);
175
+ for (const workspaceTimezone of [
176
+ 'Pacific/Honolulu',
177
+ 'Asia/Ho_Chi_Minh',
178
+ 'Pacific/Kiritimati',
179
+ ]) {
180
+ expect(
181
+ getBillableQuantityForGroupRange({
182
+ groupId: 'g',
183
+ userGroups: merged,
184
+ selectedMonth: '2026-12',
185
+ prepaidMonthCount: 2,
186
+ useAttendanceBased: false,
187
+ userAttendance: [],
188
+ workspaceTimezone,
189
+ now: new Date('2026-12-31T15:00:00Z'),
190
+ })
191
+ ).toBe(2);
192
+ expect(
193
+ getBillableQuantityForGroupRange({
194
+ groupId: 'g',
195
+ userGroups: merged,
196
+ selectedMonth: '2026-12',
197
+ prepaidMonthCount: 2,
198
+ useAttendanceBased: false,
199
+ userAttendance: [],
200
+ workspaceTimezone,
201
+ latestInvoices: [{ group_id: 'g', covered_months: ['2027-01-01'] }],
202
+ now: new Date('2026-12-31T15:00:00Z'),
203
+ })
204
+ ).toBe(1);
205
+ }
206
+ });
207
+ });
@@ -1,21 +1,60 @@
1
1
  import { useMemo } from 'react';
2
+ import {
3
+ getCoverageMonths,
4
+ getCoverageValidUntilMonthValue,
5
+ } from './civil-month';
2
6
  import type { UserGroup } from './utils';
3
7
 
8
+ export type BillingScheduleWindow = { startDate: string; endDate: string };
9
+ export function getBillingScheduleWindow(
10
+ month: string,
11
+ monthCount = 1
12
+ ): BillingScheduleWindow | undefined {
13
+ const months = getCoverageMonths(month, monthCount);
14
+ if (!months.length) return undefined;
15
+ return {
16
+ startDate: `${months[0]}-01`,
17
+ endDate: `${getCoverageValidUntilMonthValue(month, months.length)}-01`,
18
+ };
19
+ }
20
+
4
21
  export function mergeBillingSessionDates(
5
22
  userGroups: UserGroup[],
6
- scheduledSessionsByGroupId?: Record<string, string[]>
23
+ scheduledSessionsByGroupId?: Record<string, string[]>,
24
+ window?: BillingScheduleWindow
7
25
  ): UserGroup[] {
8
26
  return userGroups.map((membership) => {
9
27
  const group = membership.workspace_user_groups;
10
28
  if (!group) return membership;
11
- const planned = scheduledSessionsByGroupId?.[group.id] ?? [];
12
- if (planned.length === 0) return membership;
29
+ const response = scheduledSessionsByGroupId?.[group.id];
30
+ const planned = Array.isArray(response) ? response : [];
31
+ const authoritative =
32
+ !!window &&
33
+ !!scheduledSessionsByGroupId &&
34
+ Object.hasOwn(scheduledSessionsByGroupId, group.id) &&
35
+ Array.isArray(scheduledSessionsByGroupId[group.id]);
36
+ if (!authoritative && planned.length === 0) return membership;
13
37
  const existing = Array.isArray(group.sessions) ? group.sessions : [];
14
38
  return {
15
39
  ...membership,
16
40
  workspace_user_groups: {
17
41
  ...group,
18
- sessions: Array.from(new Set([...existing, ...planned])),
42
+ sessions: Array.from(
43
+ new Set(
44
+ authoritative
45
+ ? [
46
+ ...existing.filter(
47
+ (date) =>
48
+ date < window!.startDate || date >= window!.endDate
49
+ ),
50
+ ...planned.filter(
51
+ (date) =>
52
+ date >= window!.startDate && date < window!.endDate
53
+ ),
54
+ ]
55
+ : [...existing, ...planned]
56
+ )
57
+ ).sort(),
19
58
  },
20
59
  };
21
60
  });
@@ -31,15 +70,17 @@ export function getGroupsWithScheduleIds(userGroups: UserGroup[]): string[] {
31
70
 
32
71
  export function useSubscriptionBillingGroups(
33
72
  userGroups: UserGroup[],
34
- scheduledSessionsByGroupId?: Record<string, string[]>
73
+ scheduledSessionsByGroupId?: Record<string, string[]>,
74
+ window?: BillingScheduleWindow
35
75
  ) {
36
76
  const billingUserGroups = useMemo(
37
- () => mergeBillingSessionDates(userGroups, scheduledSessionsByGroupId),
38
- [scheduledSessionsByGroupId, userGroups]
77
+ () =>
78
+ mergeBillingSessionDates(userGroups, scheduledSessionsByGroupId, window),
79
+ [scheduledSessionsByGroupId, userGroups, window]
39
80
  );
40
81
  const groupsWithScheduleIds = useMemo(
41
- () => getGroupsWithScheduleIds(userGroups),
42
- [userGroups]
82
+ () => getGroupsWithScheduleIds(billingUserGroups),
83
+ [billingUserGroups]
43
84
  );
44
85
  return { billingUserGroups, groupsWithScheduleIds };
45
86
  }
@@ -0,0 +1,246 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
3
+ import type { SubscriptionInvoiceContextResponse } from '@tuturuuu/internal-api/finance';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import {
6
+ useWorkspaceActor,
7
+ WorkspaceVisibilityProvider,
8
+ } from '../../../../hooks/use-workspace-visibility';
9
+ import { useSubscriptionInvoiceContext } from './hooks';
10
+ import { invalidateInvoiceMutationQueries } from './query-invalidation';
11
+
12
+ function context(owner: string): SubscriptionInvoiceContextResponse {
13
+ return {
14
+ attendance: [{ date: '2026-09-01', status: owner }],
15
+ latestInvoices: [],
16
+ scheduledSessionsByGroupId: { 'group-1': ['2026-09-01'] },
17
+ };
18
+ }
19
+ function reply(owner: string) {
20
+ return new Response(JSON.stringify(context(owner)), {
21
+ status: 200,
22
+ headers: { 'content-type': 'application/json' },
23
+ });
24
+ }
25
+ function held() {
26
+ let resolve!: (response: Response) => void;
27
+ let reject!: (error: Error) => void;
28
+ const promise = new Promise<Response>((yes, no) => {
29
+ resolve = yes;
30
+ reject = no;
31
+ });
32
+ return { promise, resolve, reject };
33
+ }
34
+ const transport = vi.fn();
35
+ let actorLease: ReturnType<typeof useWorkspaceActor>;
36
+ function Probe({ wsId, customerId }: { wsId: string; customerId: string }) {
37
+ actorLease = useWorkspaceActor();
38
+ const query = useSubscriptionInvoiceContext(
39
+ wsId,
40
+ customerId,
41
+ ['group-1'],
42
+ '2026-09'
43
+ );
44
+ return (
45
+ <output aria-label="Synthetic context owner">
46
+ {query.data?.attendance[0]?.status ?? 'pending'}
47
+ </output>
48
+ );
49
+ }
50
+ function mount() {
51
+ // Matches satellite ClientProviders: the QueryClient survives actor remounts.
52
+ const client = new QueryClient({
53
+ defaultOptions: { queries: { retry: false } },
54
+ });
55
+ const view = (actor: string, wsId: string, customerId: string) => (
56
+ <QueryClientProvider client={client}>
57
+ <WorkspaceVisibilityProvider actorId={actor}>
58
+ <Probe wsId={wsId} customerId={customerId} />
59
+ </WorkspaceVisibilityProvider>
60
+ </QueryClientProvider>
61
+ );
62
+ const result = render(view('account-A', 'ws-1', 'customer-1'));
63
+ return {
64
+ ...result,
65
+ client,
66
+ redraw: (actor = 'account-A', ws = 'ws-1', customer = 'customer-1') =>
67
+ result.rerender(view(actor, ws, customer)),
68
+ };
69
+ }
70
+ async function displayed(owner: string) {
71
+ await waitFor(() =>
72
+ expect(screen.getByLabelText('Synthetic context owner')).toHaveTextContent(
73
+ owner
74
+ )
75
+ );
76
+ }
77
+ afterEach(() => {
78
+ cleanup();
79
+ vi.unstubAllGlobals();
80
+ });
81
+ beforeEach(() => {
82
+ transport.mockReset();
83
+ transport.mockImplementation(() => Promise.resolve(reply('account-A')));
84
+ vi.stubGlobal('fetch', transport);
85
+ });
86
+
87
+ describe('actual subscription context transport and shared cache actor lifetime', () => {
88
+ it('reauthorizes a fresh account-A context after changing to account-B at identical coordinates', async () => {
89
+ const view = mount();
90
+ await displayed('account-A');
91
+ const expired = actorLease;
92
+ transport.mockImplementation(() => Promise.resolve(reply('account-B')));
93
+ view.redraw('account-B');
94
+ expect(() => expired?.assertActive()).toThrow('Workspace account changed');
95
+ await waitFor(() => expect(transport).toHaveBeenCalledTimes(2));
96
+ await displayed('account-B');
97
+ expect(transport.mock.calls[0]?.[0]).toContain(
98
+ '/api/v1/workspaces/ws-1/finance/invoices/subscription/context'
99
+ );
100
+ expect(transport.mock.calls[1]?.[1]).toEqual(
101
+ expect.objectContaining({
102
+ cache: 'no-store',
103
+ signal: expect.any(AbortSignal),
104
+ })
105
+ );
106
+ });
107
+ it('does not deliver an old account-A held transport into the account-B observer or cache', async () => {
108
+ const old = held();
109
+ transport
110
+ .mockReturnValueOnce(old.promise)
111
+ .mockImplementation(() => Promise.resolve(reply('account-B')));
112
+ const view = mount();
113
+ await waitFor(() => expect(transport).toHaveBeenCalledOnce());
114
+ const expired = actorLease;
115
+ view.redraw('account-B');
116
+ expect(() => expired?.assertActive()).toThrow('Workspace account changed');
117
+ await act(async () => {
118
+ old.resolve(reply('account-A'));
119
+ await old.promise;
120
+ });
121
+ await waitFor(() => expect(transport).toHaveBeenCalledTimes(2));
122
+ await displayed('account-B');
123
+ expect(
124
+ view.client
125
+ .getQueryCache()
126
+ .getAll()
127
+ .filter(
128
+ (entry) =>
129
+ entry.queryKey[0] === 'subscription-invoice-context' &&
130
+ entry.getObserversCount() > 0
131
+ )
132
+ .map((entry) => entry.state.data)
133
+ ).toEqual([context('account-B')]);
134
+ });
135
+ it('reuses fresh context inside the same actor lifetime', async () => {
136
+ const view = mount();
137
+ await displayed('account-A');
138
+ view.redraw();
139
+ await displayed('account-A');
140
+ expect(transport).toHaveBeenCalledOnce();
141
+ });
142
+ for (const boundary of ['workspace', 'customer'] as const) {
143
+ it(`keeps same-actor ${boundary} contexts distinct`, async () => {
144
+ const view = mount();
145
+ await displayed('account-A');
146
+ transport.mockImplementation(() =>
147
+ Promise.resolve(reply('other-context'))
148
+ );
149
+ view.redraw(
150
+ 'account-A',
151
+ boundary === 'workspace' ? 'ws-2' : 'ws-1',
152
+ boundary === 'customer' ? 'customer-2' : 'customer-1'
153
+ );
154
+ await displayed('other-context');
155
+ expect(transport).toHaveBeenCalledTimes(2);
156
+ const url = new URL(transport.mock.calls[1]?.[0]);
157
+ expect(url.pathname).toContain(
158
+ boundary === 'workspace' ? '/workspaces/ws-2/' : '/workspaces/ws-1/'
159
+ );
160
+ expect(url.searchParams.get('userId')).toBe(
161
+ boundary === 'customer' ? 'customer-2' : 'customer-1'
162
+ );
163
+ });
164
+ }
165
+ for (const outcome of ['success', 'error'] as const) {
166
+ it(`isolates held old account-A ${outcome} after actor ABA`, async () => {
167
+ const old = held();
168
+ transport
169
+ .mockReturnValueOnce(old.promise)
170
+ .mockImplementation(() => Promise.resolve(reply('new-lifetime')));
171
+ const view = mount();
172
+ await waitFor(() => expect(transport).toHaveBeenCalledOnce());
173
+ const expired = actorLease;
174
+ view.redraw('account-B');
175
+ await displayed('new-lifetime');
176
+ view.redraw('account-A');
177
+ await displayed('new-lifetime');
178
+ expect(transport).toHaveBeenCalledTimes(3);
179
+ expect(() => expired?.assertActive()).toThrow(
180
+ 'Workspace account changed'
181
+ );
182
+ await act(async () => {
183
+ if (outcome === 'success') old.resolve(reply('expired-lifetime'));
184
+ else old.reject(new Error('Synthetic expired transport failure'));
185
+ await old.promise.catch(() => {});
186
+ });
187
+ await displayed('new-lifetime');
188
+ const entries = view.client.getQueryCache().getAll();
189
+ await waitFor(() =>
190
+ expect(
191
+ entries.find(
192
+ (entry) => entry.getObserversCount() === 0 && entry.state.error
193
+ )?.state.error
194
+ ).toEqual(new Error('Workspace account changed'))
195
+ );
196
+ expect(
197
+ entries
198
+ .filter((entry) => entry.getObserversCount() > 0)
199
+ .map((entry) => entry.state.data)
200
+ ).toEqual([context('new-lifetime')]);
201
+ expect(
202
+ entries
203
+ .filter(
204
+ (entry) =>
205
+ entry.queryKey[0] === 'subscription-invoice-context' &&
206
+ entry.queryKey[6] === 'account-A'
207
+ )
208
+ .map((entry) => entry.queryKey[7])
209
+ ).toHaveLength(2);
210
+ });
211
+ }
212
+ it('does not admit context requests without a verified actor provider', async () => {
213
+ const client = new QueryClient();
214
+ render(
215
+ <QueryClientProvider client={client}>
216
+ <Probe wsId="ws-1" customerId="customer-1" />
217
+ </QueryClientProvider>
218
+ );
219
+ await act(async () => {
220
+ await Promise.resolve();
221
+ });
222
+ expect(transport).not.toHaveBeenCalled();
223
+ expect(screen.getByLabelText('Synthetic context owner')).toHaveTextContent(
224
+ 'pending'
225
+ );
226
+ });
227
+ it('workspace invalidation still refetches the actor-scoped context', async () => {
228
+ const view = mount();
229
+ await displayed('account-A');
230
+ transport.mockImplementation(() =>
231
+ Promise.resolve(reply('refreshed-context'))
232
+ );
233
+ await act(async () => {
234
+ await invalidateInvoiceMutationQueries(view.client, 'other-workspace');
235
+ });
236
+ expect(transport).toHaveBeenCalledOnce();
237
+ await act(async () => {
238
+ await invalidateInvoiceMutationQueries(view.client, 'ws-1');
239
+ });
240
+ await displayed('refreshed-context');
241
+ expect(transport).toHaveBeenCalledTimes(2);
242
+ expect(
243
+ view.client.getQueryCache().getAll()[0]?.queryKey.slice(0, 2)
244
+ ).toEqual(['subscription-invoice-context', 'ws-1']);
245
+ });
246
+ });