@tuturuuu/ui 0.37.2 → 0.39.0

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 (126) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/package.json +10 -7
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
  4. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  5. package/src/components/ui/custom/education-todo.tsx +191 -0
  6. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  7. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  8. package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
  9. package/src/components/ui/custom/workspace-select.tsx +12 -4
  10. package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
  11. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  12. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  13. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  14. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  15. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  16. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  17. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  18. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  19. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  20. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  21. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  22. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  23. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  24. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  25. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  26. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  27. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  28. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  29. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  30. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  31. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  32. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  33. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  34. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  35. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  36. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
  37. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  38. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  39. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  40. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  41. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  42. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  43. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  44. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  45. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  46. package/src/components/ui/finance/invoices/types.ts +3 -3
  47. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  48. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  49. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  50. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  51. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  52. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  53. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  54. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  55. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  56. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  57. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  58. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  59. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1614
  60. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  61. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  62. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  63. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  64. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  65. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  66. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  67. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  68. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  69. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  70. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  71. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  72. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  73. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  74. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  75. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  76. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  77. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  78. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  79. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  80. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  81. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  82. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  83. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  84. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  85. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  86. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  87. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  88. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  89. package/src/components/ui/popover.tsx +3 -1
  90. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  91. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  92. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  93. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  94. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  95. package/src/components/ui/text-editor/editor.tsx +11 -3
  96. package/src/components/ui/text-editor/extensions.ts +8 -2
  97. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  98. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  99. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  100. package/src/components/ui/text-editor/markdown.ts +11 -0
  101. package/src/components/ui/text-editor/tool-bar.tsx +45 -102
  102. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  103. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  104. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  105. package/src/hooks/__tests__/supabase-provider.test.ts +110 -7
  106. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +39 -2
  107. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  108. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  109. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  110. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  111. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +147 -0
  112. package/src/hooks/calendar-connection-types.ts +49 -0
  113. package/src/hooks/calendar-creation-request.test.ts +62 -0
  114. package/src/hooks/calendar-creation-request.ts +34 -0
  115. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  116. package/src/hooks/calendar-event-write-payload.ts +57 -0
  117. package/src/hooks/collaboration-document-hydration.ts +35 -0
  118. package/src/hooks/notification-subscription.ts +5 -2
  119. package/src/hooks/supabase-provider.ts +76 -112
  120. package/src/hooks/use-calendar-sync.tsx +41 -62
  121. package/src/hooks/use-calendar.tsx +44 -82
  122. package/src/hooks/use-exchange-rates.ts +4 -15
  123. package/src/hooks/use-local-storage.ts +9 -11
  124. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  125. package/src/hooks/use-workspace-presence.ts +9 -9
  126. package/src/hooks/use-yjs-collaboration.ts +68 -27
@@ -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
+ });