@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,562 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import {
3
+ act,
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ } from '@testing-library/react';
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
11
+ import {
12
+ useWorkspaceActor,
13
+ WorkspaceVisibilityProvider,
14
+ } from '../../../../hooks/use-workspace-visibility';
15
+ import { SubscriptionInvoice } from './subscription-invoice';
16
+ import type { UserGroupProducts } from './types';
17
+
18
+ const testState = vi.hoisted(() => {
19
+ const inventory = {
20
+ amount: 5,
21
+ min_amount: 1,
22
+ price: 100,
23
+ unit_id: 'unit-1',
24
+ unit_name: 'Seat',
25
+ warehouse_id: 'warehouse-1',
26
+ warehouse_name: 'Main',
27
+ };
28
+ const product = {
29
+ category: null,
30
+ category_id: 'inventory-category',
31
+ created_at: null,
32
+ description: null,
33
+ finance_category_id: null as string | null,
34
+ id: 'product-1',
35
+ inventory: [inventory],
36
+ manufacturer: null,
37
+ name: 'Subscription seat',
38
+ usage: null,
39
+ ws_id: 'ws-1',
40
+ };
41
+
42
+ return {
43
+ push: vi.fn(),
44
+ toast: vi.fn(),
45
+ InvoicePaymentSettings: vi.fn(),
46
+ SubscriptionAttendanceSummary: vi.fn(),
47
+ categories: [] as Array<{ id: string; name: string }>,
48
+ groupIds: ['group-1'],
49
+ month: '2026-07',
50
+ setMonth: vi.fn(),
51
+ setUserId: vi.fn(),
52
+ productSelectionInjected: false,
53
+ products: [product],
54
+ productsError: null as Error | null,
55
+ userGroupsError: null as Error | null,
56
+ groupProductsError: null as Error | null,
57
+ contextError: null as Error | null,
58
+ actualAutoSelection: false,
59
+ groupProducts: [] as UserGroupProducts[],
60
+ contextLoading: false,
61
+ scheduleMap: { 'group-1': ['2026-07-05'] } as
62
+ | Record<string, string[]>
63
+ | undefined,
64
+ createInvoice: vi.fn(),
65
+ refetchUserGroups: vi.fn(),
66
+ refetchGroupProducts: vi.fn(),
67
+ refetchContext: vi.fn(),
68
+ refetchProducts: vi.fn(),
69
+ refetchUsers: vi.fn(),
70
+ selectedProducts: [{ inventory, product, quantity: 1 }],
71
+ useCategories: vi.fn(),
72
+ useWallets: vi.fn(),
73
+ userId: 'user-1',
74
+ userGroups: [
75
+ {
76
+ workspace_user_groups: {
77
+ ending_date: null as string | null,
78
+ id: 'group-1',
79
+ name: 'Group 1',
80
+ sessions: ['2026-07-05'],
81
+ starting_date: '2026-07-01',
82
+ },
83
+ },
84
+ ],
85
+ wallets: [] as Array<{
86
+ currency: string;
87
+ id: string;
88
+ name: string;
89
+ type: string;
90
+ }>,
91
+ };
92
+ });
93
+
94
+ vi.mock('./internal-api', () => ({
95
+ createSubscriptionInvoiceWithInternalApi: testState.createInvoice,
96
+ }));
97
+
98
+ vi.mock('@tuturuuu/ui/sonner', () => ({ toast: testState.toast }));
99
+
100
+ vi.mock('next-intl', () => ({
101
+ useLocale: () => 'en-US',
102
+ useTranslations: () => (key: string) => key,
103
+ }));
104
+
105
+ vi.mock('next/navigation', () => ({
106
+ useRouter: () => ({ push: testState.push }),
107
+ }));
108
+
109
+ vi.mock('nuqs', () => ({
110
+ parseAsArrayOf: () => ({
111
+ withDefault: () => ({
112
+ withOptions: () => ({}),
113
+ }),
114
+ }),
115
+ parseAsString: {},
116
+ useQueryState: (key: string, options?: { defaultValue?: unknown }) => {
117
+ if (key === 'user_id') return [testState.userId, testState.setUserId];
118
+ if (key === 'group_ids') return [testState.groupIds, vi.fn()];
119
+ if (key === 'month') return [testState.month, testState.setMonth];
120
+ return [options?.defaultValue ?? '', vi.fn()];
121
+ },
122
+ }));
123
+
124
+ vi.mock('../../../../hooks/use-debounce', () => ({
125
+ useDebounce: (value: string) => [value],
126
+ }));
127
+
128
+ vi.mock('../finance-route-context', () => ({
129
+ useFinanceHref: () => (path: string) => path,
130
+ }));
131
+
132
+ vi.mock('../shared/use-finance-confidential-visibility', () => ({
133
+ useFinanceConfidentialVisibility: () => ({ isConfidential: false }),
134
+ }));
135
+
136
+ vi.mock('./components/invoice-blocked-state', () => ({
137
+ InvoiceBlockedState: () => null,
138
+ }));
139
+
140
+ vi.mock('./components/invoice-checkout-summary', () => ({
141
+ InvoiceCheckoutSummary: () => null,
142
+ }));
143
+
144
+ vi.mock('./components/invoice-content-editor', () => ({
145
+ InvoiceContentEditor: ({
146
+ contentValue,
147
+ onContentChange,
148
+ }: {
149
+ contentValue: string;
150
+ onContentChange: (value: string) => void;
151
+ }) => (
152
+ <textarea
153
+ aria-label="Synthetic draft content"
154
+ value={contentValue}
155
+ onChange={(event) => onContentChange(event.target.value)}
156
+ />
157
+ ),
158
+ }));
159
+
160
+ vi.mock('./components/invoice-customer-select-card', () => ({
161
+ InvoiceCustomerSelectCard: () => null,
162
+ }));
163
+
164
+ vi.mock('./components/invoice-payment-settings', () => ({
165
+ InvoicePaymentSettings: (props: unknown) => {
166
+ testState.InvoicePaymentSettings(props);
167
+ return null;
168
+ },
169
+ }));
170
+
171
+ vi.mock('./components/invoice-products-permission-warning', () => ({
172
+ InvoiceProductsPermissionWarning: () => null,
173
+ isPermissionRequestError: () => false,
174
+ }));
175
+
176
+ vi.mock('./components/subscription-attendance-summary', () => ({
177
+ SubscriptionAttendanceSummary: (props: unknown) => {
178
+ testState.SubscriptionAttendanceSummary(props);
179
+ return null;
180
+ },
181
+ }));
182
+
183
+ vi.mock('./components/subscription-group-selector', () => ({
184
+ SubscriptionGroupSelector: () => null,
185
+ }));
186
+
187
+ vi.mock('./components/subscription-prepaid-controls', () => ({
188
+ SubscriptionPrepaidControls: () => null,
189
+ }));
190
+
191
+ vi.mock('./create-promotion-dialog', () => ({
192
+ CreatePromotionDialog: () => null,
193
+ }));
194
+
195
+ vi.mock('./product-selection', () => ({
196
+ ProductSelection: (props: {
197
+ onSelectedProductsChange: (
198
+ products: typeof testState.selectedProducts
199
+ ) => void;
200
+ }) => {
201
+ if (!testState.productSelectionInjected) {
202
+ testState.productSelectionInjected = true;
203
+ queueMicrotask(() =>
204
+ props.onSelectedProductsChange(testState.selectedProducts)
205
+ );
206
+ }
207
+ return null;
208
+ },
209
+ }));
210
+
211
+ vi.mock('./hooks', () => ({
212
+ useAvailablePromotions: () => ({ data: [] }),
213
+ useCategories: (wsId: string, options?: unknown) => {
214
+ testState.useCategories(wsId, options);
215
+ return { data: testState.categories };
216
+ },
217
+ useInvoiceAttendanceConfig: () => ({ data: true }),
218
+ useInvoiceBlockedGroups: () => ({ data: [] }),
219
+ useInvoiceCustomerSearch: () => ({
220
+ customers: [],
221
+ refetch: testState.refetchUsers,
222
+ error: null,
223
+ fetchNextPage: vi.fn(),
224
+ hasNextPage: false,
225
+ isFetching: false,
226
+ isFetchingNextPage: false,
227
+ isLoading: false,
228
+ selectedUser: { id: testState.userId, display_name: 'Customer' },
229
+ }),
230
+ useMultiGroupProducts: () => ({
231
+ data: testState.groupProducts,
232
+ error: testState.groupProductsError,
233
+ refetch: testState.refetchGroupProducts,
234
+ isLoading: false,
235
+ }),
236
+ useProducts: () => ({
237
+ data: testState.products,
238
+ error: testState.productsError,
239
+ refetch: testState.refetchProducts,
240
+ isLoading: false,
241
+ }),
242
+ useSubscriptionInvoiceContext: () => ({
243
+ data: {
244
+ attendance: [],
245
+ latestInvoices: [],
246
+ scheduledSessionsByGroupId: testState.scheduleMap,
247
+ },
248
+ error: testState.contextError,
249
+ refetch: testState.refetchContext,
250
+ isLoading: testState.contextLoading,
251
+ }),
252
+ useUserGroups: () => ({
253
+ data: testState.userGroups,
254
+ error: testState.userGroupsError,
255
+ refetch: testState.refetchUserGroups,
256
+ isLoading: false,
257
+ }),
258
+ useUserLinkedPromotions: () => ({ data: [] }),
259
+ useUserReferralDiscounts: () => ({ data: [] }),
260
+ useWallets: (wsId: string, options?: unknown) => {
261
+ testState.useWallets(wsId, options);
262
+ return { data: testState.wallets };
263
+ },
264
+ }));
265
+
266
+ vi.mock('./hooks/use-best-promotion-selection', () => ({
267
+ useBestPromotionSelection: () => undefined,
268
+ }));
269
+
270
+ vi.mock('./hooks/use-invoice-rounding', () => ({
271
+ useInvoiceRounding: () => ({
272
+ resetRounding: vi.fn(),
273
+ roundDown: vi.fn(),
274
+ roundUp: vi.fn(),
275
+ roundedTotal: 100,
276
+ }),
277
+ }));
278
+
279
+ vi.mock('./hooks/use-invoice-subtotal', () => ({
280
+ useInvoiceSubtotal: () => 100,
281
+ }));
282
+
283
+ vi.mock('./hooks/use-subscription-auto-selection', async (importOriginal) => {
284
+ const original =
285
+ await importOriginal<
286
+ typeof import('./hooks/use-subscription-auto-selection')
287
+ >();
288
+ return {
289
+ useSubscriptionAutoSelection: (
290
+ props: Parameters<typeof original.useSubscriptionAutoSelection>[0]
291
+ ) =>
292
+ original.useSubscriptionAutoSelection({
293
+ ...props,
294
+ enabled: testState.actualAutoSelection,
295
+ }),
296
+ };
297
+ });
298
+
299
+ vi.mock('./hooks/use-subscription-invoice-content', () => ({
300
+ useSubscriptionInvoiceContent: () => undefined,
301
+ }));
302
+
303
+ function held() {
304
+ let resolve!: (value: unknown) => void, reject!: (error: Error) => void;
305
+ const promise = new Promise((yes, no) => {
306
+ resolve = yes;
307
+ reject = no;
308
+ });
309
+ return { promise, resolve, reject };
310
+ }
311
+ let observedActor: ReturnType<typeof useWorkspaceActor>;
312
+ function ActorWitness() {
313
+ observedActor = useWorkspaceActor();
314
+ return null;
315
+ }
316
+ function mount(createMultipleInvoices = false) {
317
+ const client = new QueryClient({
318
+ defaultOptions: { queries: { retry: false } },
319
+ });
320
+ const view = (actor: string, ws: string) => (
321
+ <QueryClientProvider client={client}>
322
+ <WorkspaceVisibilityProvider actorId={actor}>
323
+ <ActorWitness />
324
+ <SubscriptionInvoice
325
+ wsId={ws}
326
+ createMultipleInvoices={createMultipleInvoices}
327
+ defaultCategoryId="category-default"
328
+ defaultCurrency="VND"
329
+ defaultWalletId="wallet-default"
330
+ workspaceTimezone="Asia/Ho_Chi_Minh"
331
+ />
332
+ </WorkspaceVisibilityProvider>
333
+ </QueryClientProvider>
334
+ );
335
+ const result = render(view('account-A', 'ws-1'));
336
+ return {
337
+ ...result,
338
+ client,
339
+ redraw: (actor = 'account-A', ws = 'ws-1') => {
340
+ testState.productSelectionInjected = false;
341
+ result.rerender(view(actor, ws));
342
+ },
343
+ };
344
+ }
345
+ async function ready() {
346
+ await waitFor(() =>
347
+ expect(
348
+ screen.getByRole('button', {
349
+ name: 'ws-invoices.create_subscription_invoice',
350
+ })
351
+ ).not.toBeDisabled()
352
+ );
353
+ }
354
+ async function start() {
355
+ await ready();
356
+ fireEvent.click(
357
+ screen.getByRole('button', {
358
+ name: 'ws-invoices.create_subscription_invoice',
359
+ })
360
+ );
361
+ await waitFor(() => expect(testState.createInvoice).toHaveBeenCalledOnce());
362
+ }
363
+ const saved = { invoice_id: 'synthetic-invoice' };
364
+ afterEach(cleanup);
365
+ beforeEach(() => {
366
+ vi.clearAllMocks();
367
+ testState.createInvoice.mockReset();
368
+ testState.createInvoice.mockResolvedValue(saved);
369
+ testState.productSelectionInjected = false;
370
+ testState.userId = 'user-1';
371
+ testState.groupIds = ['group-1'];
372
+ testState.month = '2026-07';
373
+ testState.contextLoading = false;
374
+ testState.contextError = null;
375
+ testState.productsError = null;
376
+ testState.groupProductsError = null;
377
+ testState.userGroupsError = null;
378
+ testState.actualAutoSelection = false;
379
+ testState.groupProducts = [];
380
+ testState.scheduleMap = { 'group-1': ['2026-07-05'] };
381
+ });
382
+ describe('actual invoice create completion lifetime', () => {
383
+ it('current actor/workspace success remains truthful and navigates to its admitted invoice', async () => {
384
+ mount();
385
+ await start();
386
+ await waitFor(() =>
387
+ expect(testState.push).toHaveBeenCalledWith(
388
+ '/ws-1/invoices/synthetic-invoice'
389
+ )
390
+ );
391
+ expect(testState.toast).toHaveBeenCalledWith(
392
+ 'ws-invoices.subscription_invoice_created_success'
393
+ );
394
+ expect(testState.createInvoice.mock.calls[0]?.[0]).toBe('ws-1');
395
+ expect(testState.createInvoice.mock.calls[0]?.[1]).toEqual(
396
+ expect.objectContaining({
397
+ customer_id: 'user-1',
398
+ group_ids: ['group-1'],
399
+ selected_month: '2026-07',
400
+ })
401
+ );
402
+ });
403
+ it('current-scope failure remains visible and permits retry', async () => {
404
+ testState.createInvoice.mockRejectedValueOnce(
405
+ new Error('Synthetic failure')
406
+ );
407
+ mount();
408
+ await start();
409
+ await waitFor(() =>
410
+ expect(testState.toast).toHaveBeenCalledWith(
411
+ 'ws-invoices.error_creating_subscription_invoice'
412
+ )
413
+ );
414
+ await ready();
415
+ expect(testState.push).not.toHaveBeenCalled();
416
+ });
417
+ for (const boundary of [
418
+ 'actor ABA',
419
+ 'workspace ABA',
420
+ 'customer ABA',
421
+ 'unmount',
422
+ ] as const) {
423
+ for (const outcome of ['success', 'error'] as const) {
424
+ it(`late ${outcome} after ${boundary} cannot announce or navigate in a later lifetime`, async () => {
425
+ const old = held();
426
+ testState.createInvoice.mockReturnValueOnce(old.promise);
427
+ const view = mount();
428
+ await start();
429
+ const oldActor = observedActor;
430
+ if (boundary === 'actor ABA') {
431
+ view.redraw('account-B');
432
+ await ready();
433
+ view.redraw('account-A');
434
+ await ready();
435
+ expect(() => oldActor?.assertActive()).toThrow(
436
+ 'Workspace account changed'
437
+ );
438
+ expect(() => observedActor?.assertActive()).not.toThrow();
439
+ } else if (boundary === 'customer ABA') {
440
+ testState.userId = 'user-2';
441
+ view.redraw();
442
+ await act(async () => {
443
+ await Promise.resolve();
444
+ });
445
+ testState.userId = 'user-1';
446
+ view.redraw();
447
+ await act(async () => {
448
+ await Promise.resolve();
449
+ });
450
+ } else if (boundary === 'workspace ABA') {
451
+ view.redraw('account-A', 'ws-2');
452
+ view.redraw('account-A', 'ws-1');
453
+ } else view.unmount();
454
+ await act(async () => {
455
+ if (outcome === 'success') old.resolve(saved);
456
+ else old.reject(new Error('Synthetic old failure'));
457
+ await old.promise.catch(() => {});
458
+ });
459
+ expect(testState.push).not.toHaveBeenCalled();
460
+ expect(testState.toast).not.toHaveBeenCalled();
461
+ });
462
+ }
463
+ }
464
+ for (const multiple of [false, true]) {
465
+ it(`preserves a newer same-customer draft after old success (multiple=${multiple})`, async () => {
466
+ const old = held();
467
+ testState.createInvoice.mockReturnValueOnce(old.promise);
468
+ mount(multiple);
469
+ fireEvent.change(screen.getByLabelText('Synthetic draft content'), {
470
+ target: { value: 'Admitted draft' },
471
+ });
472
+ await start();
473
+ const admitted = structuredClone(
474
+ testState.createInvoice.mock.calls[0]?.[1]
475
+ );
476
+ fireEvent.change(screen.getByLabelText('Synthetic draft content'), {
477
+ target: { value: 'Newer draft' },
478
+ });
479
+ await act(async () => {
480
+ old.resolve(saved);
481
+ await old.promise;
482
+ });
483
+ expect(screen.getByLabelText('Synthetic draft content')).toHaveValue(
484
+ 'Newer draft'
485
+ );
486
+ expect(testState.createInvoice.mock.calls[0]?.[1]).toEqual(admitted);
487
+ expect(testState.setUserId).not.toHaveBeenCalled();
488
+ expect(testState.push).not.toHaveBeenCalled();
489
+ expect(testState.toast).not.toHaveBeenCalled();
490
+ await ready();
491
+ });
492
+ }
493
+ it('current multiple-create success performs its own reset', async () => {
494
+ mount(true);
495
+ fireEvent.change(screen.getByLabelText('Synthetic draft content'), {
496
+ target: { value: 'Current draft' },
497
+ });
498
+ await start();
499
+ await waitFor(() => expect(testState.setUserId).toHaveBeenCalledWith(null));
500
+ expect(screen.getByLabelText('Synthetic draft content')).toHaveValue('');
501
+ expect(testState.push).not.toHaveBeenCalled();
502
+ expect(testState.toast).toHaveBeenCalledWith(
503
+ 'ws-invoices.subscription_invoice_created_success'
504
+ );
505
+ });
506
+ it('old finally cannot clear a new scope pending owner', async () => {
507
+ const old = held(),
508
+ current = held();
509
+ testState.createInvoice
510
+ .mockReturnValueOnce(old.promise)
511
+ .mockReturnValueOnce(current.promise);
512
+ const view = mount();
513
+ await start();
514
+ view.redraw('account-A', 'ws-2');
515
+ await ready();
516
+ fireEvent.click(
517
+ screen.getByRole('button', {
518
+ name: 'ws-invoices.create_subscription_invoice',
519
+ })
520
+ );
521
+ await waitFor(() =>
522
+ expect(testState.createInvoice).toHaveBeenCalledTimes(2)
523
+ );
524
+ await act(async () => {
525
+ old.resolve(saved);
526
+ await old.promise;
527
+ });
528
+ expect(
529
+ screen.getByRole('button', {
530
+ name: 'ws-invoices.creating_subscription_invoice',
531
+ })
532
+ ).toBeDisabled();
533
+ expect(testState.push).not.toHaveBeenCalled();
534
+ expect(testState.toast).not.toHaveBeenCalled();
535
+ await act(async () => {
536
+ current.resolve(saved);
537
+ await current.promise;
538
+ });
539
+ expect(testState.push).toHaveBeenCalledWith(
540
+ '/ws-2/invoices/synthetic-invoice'
541
+ );
542
+ await ready();
543
+ });
544
+ it('duplicate clicks admit only one write', async () => {
545
+ const pending = held();
546
+ testState.createInvoice.mockReturnValueOnce(pending.promise);
547
+ mount();
548
+ await ready();
549
+ const button = screen.getByRole('button', {
550
+ name: 'ws-invoices.create_subscription_invoice',
551
+ });
552
+ act(() => {
553
+ fireEvent.click(button);
554
+ fireEvent.click(button);
555
+ });
556
+ expect(testState.createInvoice).toHaveBeenCalledOnce();
557
+ await act(async () => {
558
+ pending.resolve(saved);
559
+ await pending.promise;
560
+ });
561
+ });
562
+ });
@@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
3
  import type { ComponentProps, PropsWithChildren } from 'react';
4
4
  import { beforeEach, describe, expect, it, vi } from 'vitest';
5
5
  import { SubscriptionInvoice } from './subscription-invoice';
6
+ import type { UserGroupProducts } from './types';
6
7
 
7
8
  const testState = vi.hoisted(() => {
8
9
  const inventory = {
@@ -41,6 +42,13 @@ const testState = vi.hoisted(() => {
41
42
  userGroupsError: null as Error | null,
42
43
  groupProductsError: null as Error | null,
43
44
  contextError: null as Error | null,
45
+ actualAutoSelection: false,
46
+ groupProducts: [] as UserGroupProducts[],
47
+ contextLoading: false,
48
+ scheduleMap: { 'group-1': ['2026-07-05'] } as
49
+ | Record<string, string[]>
50
+ | undefined,
51
+ createInvoice: vi.fn(),
44
52
  refetchUserGroups: vi.fn(),
45
53
  refetchGroupProducts: vi.fn(),
46
54
  refetchContext: vi.fn(),
@@ -70,6 +78,10 @@ const testState = vi.hoisted(() => {
70
78
  };
71
79
  });
72
80
 
81
+ vi.mock('./internal-api', () => ({
82
+ createSubscriptionInvoiceWithInternalApi: testState.createInvoice,
83
+ }));
84
+
73
85
  vi.mock('next-intl', () => ({
74
86
  useLocale: () => 'en-US',
75
87
  useTranslations: () => (key: string) => key,
@@ -189,7 +201,7 @@ vi.mock('./hooks', () => ({
189
201
  selectedUser: { id: testState.userId, display_name: 'Customer' },
190
202
  }),
191
203
  useMultiGroupProducts: () => ({
192
- data: [],
204
+ data: testState.groupProducts,
193
205
  error: testState.groupProductsError,
194
206
  refetch: testState.refetchGroupProducts,
195
207
  isLoading: false,
@@ -201,10 +213,14 @@ vi.mock('./hooks', () => ({
201
213
  isLoading: false,
202
214
  }),
203
215
  useSubscriptionInvoiceContext: () => ({
204
- data: { attendance: [], latestInvoices: [] },
216
+ data: {
217
+ attendance: [],
218
+ latestInvoices: [],
219
+ scheduledSessionsByGroupId: testState.scheduleMap,
220
+ },
205
221
  error: testState.contextError,
206
222
  refetch: testState.refetchContext,
207
- isLoading: false,
223
+ isLoading: testState.contextLoading,
208
224
  }),
209
225
  useUserGroups: () => ({
210
226
  data: testState.userGroups,
@@ -237,9 +253,21 @@ vi.mock('./hooks/use-invoice-subtotal', () => ({
237
253
  useInvoiceSubtotal: () => 100,
238
254
  }));
239
255
 
240
- vi.mock('./hooks/use-subscription-auto-selection', () => ({
241
- useSubscriptionAutoSelection: () => undefined,
242
- }));
256
+ vi.mock('./hooks/use-subscription-auto-selection', async (importOriginal) => {
257
+ const original =
258
+ await importOriginal<
259
+ typeof import('./hooks/use-subscription-auto-selection')
260
+ >();
261
+ return {
262
+ useSubscriptionAutoSelection: (
263
+ props: Parameters<typeof original.useSubscriptionAutoSelection>[0]
264
+ ) =>
265
+ original.useSubscriptionAutoSelection({
266
+ ...props,
267
+ enabled: testState.actualAutoSelection,
268
+ }),
269
+ };
270
+ });
243
271
 
244
272
  vi.mock('./hooks/use-subscription-invoice-content', () => ({
245
273
  useSubscriptionInvoiceContent: () => undefined,
@@ -291,6 +319,11 @@ describe('SubscriptionInvoice checkout defaults', () => {
291
319
  testState.userGroupsError = null;
292
320
  testState.groupProductsError = null;
293
321
  testState.contextError = null;
322
+ testState.contextLoading = false;
323
+ testState.scheduleMap = { 'group-1': ['2026-07-05'] };
324
+ testState.actualAutoSelection = false;
325
+ testState.groupProducts = [];
326
+ testState.createInvoice.mockReset();
294
327
  testState.refetchUserGroups.mockClear();
295
328
  testState.refetchGroupProducts.mockClear();
296
329
  testState.refetchContext.mockClear();
@@ -305,6 +338,55 @@ describe('SubscriptionInvoice checkout defaults', () => {
305
338
  testState.categories = [];
306
339
  });
307
340
 
341
+ it('blocks the real invoice form for a null linked billing unit despite manually selected charges', async () => {
342
+ testState.actualAutoSelection = true;
343
+ testState.groupProducts = [
344
+ {
345
+ group_id: 'group-1',
346
+ workspace_products: {
347
+ id: 'product-1',
348
+ name: 'Seat',
349
+ product_categories: null,
350
+ },
351
+ inventory_units: null,
352
+ warehouse_id: 'warehouse-1',
353
+ },
354
+ ];
355
+ renderSubscriptionInvoice();
356
+ expect(
357
+ await screen.findByText('ws-invoices.invalid_subscription_inventory')
358
+ ).toBeInTheDocument();
359
+ const create = screen.getByRole('button', {
360
+ name: 'ws-invoices.create_subscription_invoice',
361
+ });
362
+ expect(create).toBeDisabled();
363
+ fireEvent.click(create);
364
+ expect(testState.createInvoice).not.toHaveBeenCalled();
365
+ });
366
+
367
+ it.each(['pending', 'missing', 'missing-group'] as const)(
368
+ 'blocks creation for %s schedule authority instead of treating it as empty',
369
+ async (state) => {
370
+ testState.contextLoading = state === 'pending';
371
+ testState.scheduleMap =
372
+ state === 'missing'
373
+ ? undefined
374
+ : state === 'missing-group'
375
+ ? {}
376
+ : { 'group-1': [] };
377
+ renderSubscriptionInvoice();
378
+ expect(
379
+ await screen.findByText('ws-invoices.subscription_context_not_ready')
380
+ ).toBeInTheDocument();
381
+ const create = screen.getByRole('button', {
382
+ name: 'ws-invoices.create_subscription_invoice',
383
+ });
384
+ expect(create).toBeDisabled();
385
+ fireEvent.click(create);
386
+ expect(testState.createInvoice).not.toHaveBeenCalled();
387
+ }
388
+ );
389
+
308
390
  it('shows a retry when product loading fails and reopens the form after recovery', async () => {
309
391
  testState.productsError = new Error('Timed out');
310
392
  const { rerender } = renderSubscriptionInvoice();