@tuturuuu/ui 0.32.0 → 0.33.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 (78) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/biome.json +1 -1
  3. package/package.json +53 -51
  4. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +58 -24
  5. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +57 -0
  6. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +28 -14
  7. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.test.tsx +321 -0
  8. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.ts +182 -0
  9. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +12 -14
  10. package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
  11. package/src/components/ui/chat/ai-message-parts.tsx +23 -16
  12. package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
  13. package/src/components/ui/commercial-comparison-style.ts +104 -0
  14. package/src/components/ui/commercial-comparison-table.tsx +159 -0
  15. package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
  16. package/src/components/ui/commercial-comparison.test.tsx +86 -0
  17. package/src/components/ui/commercial-comparison.tsx +224 -0
  18. package/src/components/ui/comparison-value.test.tsx +32 -0
  19. package/src/components/ui/comparison-value.tsx +72 -0
  20. package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
  21. package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
  22. package/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +13 -2
  23. package/src/components/ui/custom/report-preview-pagination.ts +5 -3
  24. package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
  25. package/src/components/ui/custom/report-preview.tsx +2 -3
  26. package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
  27. package/src/components/ui/finance/invoices/columns.tsx +2 -0
  28. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
  29. package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
  30. package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
  31. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
  32. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
  33. package/src/components/ui/finance/invoices/hooks.ts +5 -110
  34. package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
  35. package/src/components/ui/finance/invoices/internal-api.ts +19 -1
  36. package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
  37. package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
  38. package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
  39. package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
  40. package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
  41. package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
  42. package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
  43. package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
  44. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
  45. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
  46. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
  47. package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
  48. package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
  49. package/src/components/ui/finance/invoices/query-invalidation.ts +1 -0
  50. package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
  51. package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
  52. package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
  53. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
  54. package/src/components/ui/finance/invoices/subscription-invoice.tsx +44 -45
  55. package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
  56. package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
  57. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
  58. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
  59. package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
  60. package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
  61. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
  62. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
  63. package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
  64. package/src/components/ui/public-workspace-prices.tsx +13 -0
  65. package/src/components/ui/pwa-status.test.tsx +13 -2
  66. package/src/components/ui/pwa-status.tsx +15 -3
  67. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
  68. package/src/components/ui/text-editor/editor.tsx +11 -3
  69. package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
  70. package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
  71. package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
  72. package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
  73. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
  74. package/src/hooks/calendar-provider-sync.ts +33 -0
  75. package/src/hooks/notification-subscription.ts +74 -0
  76. package/src/hooks/use-calendar-sync.tsx +11 -14
  77. package/src/hooks/use-calendar.tsx +2 -2
  78. package/src/hooks/use-notifications.ts +5 -141
@@ -0,0 +1,27 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { InvoiceDataState } from './invoice-data-state';
4
+
5
+ vi.mock('next-intl', () => ({
6
+ useTranslations: () => (key: string) => key,
7
+ }));
8
+
9
+ describe('InvoiceDataState', () => {
10
+ it('announces loading without offering duplicate requests', () => {
11
+ render(<InvoiceDataState loading onRetry={vi.fn()} />);
12
+ expect(screen.getByRole('status')).toHaveTextContent('ws-invoices.loading');
13
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
14
+ });
15
+
16
+ it('replaces loading with an actionable retry after failure', () => {
17
+ const onRetry = vi.fn();
18
+ const { rerender } = render(<InvoiceDataState loading onRetry={onRetry} />);
19
+ rerender(<InvoiceDataState loading={false} onRetry={onRetry} />);
20
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
21
+ expect(screen.getByRole('alert')).toHaveTextContent(
22
+ 'ws-invoices.load_failed'
23
+ );
24
+ fireEvent.click(screen.getByRole('button', { name: 'common.retry' }));
25
+ expect(onRetry).toHaveBeenCalledOnce();
26
+ });
27
+ });
@@ -0,0 +1,34 @@
1
+ 'use client';
2
+
3
+ import { Loader2 } from '@tuturuuu/icons';
4
+ import { Button } from '@tuturuuu/ui/button';
5
+ import { useTranslations } from 'next-intl';
6
+
7
+ export function InvoiceDataState({
8
+ loading,
9
+ onRetry,
10
+ }: {
11
+ loading: boolean;
12
+ onRetry: () => void;
13
+ }) {
14
+ const t = useTranslations();
15
+
16
+ return (
17
+ <div className="flex flex-col items-center justify-center gap-3 py-8">
18
+ <div
19
+ role={loading ? 'status' : 'alert'}
20
+ className="flex items-center gap-2"
21
+ >
22
+ {loading && <Loader2 className="h-4 w-4 animate-spin" />}
23
+ <p className="text-muted-foreground text-sm">
24
+ {t(loading ? 'ws-invoices.loading' : 'ws-invoices.load_failed')}
25
+ </p>
26
+ </div>
27
+ {!loading && (
28
+ <Button type="button" variant="outline" onClick={onRetry}>
29
+ {t('common.retry')}
30
+ </Button>
31
+ )}
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,89 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { act, renderHook, waitFor } from '@testing-library/react';
3
+ import type { PropsWithChildren } from 'react';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { useInvoiceCustomerSearch } from './use-invoice-customer-search';
6
+
7
+ const api = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn() }));
8
+ vi.mock('../internal-api', () => ({
9
+ listWorkspaceUsersWithInternalApi: api.list,
10
+ getWorkspaceUserWithInternalApi: api.get,
11
+ }));
12
+
13
+ beforeEach(() => {
14
+ api.list.mockReset();
15
+ api.get.mockReset();
16
+ });
17
+
18
+ function renderSearch(selectedUserId = '') {
19
+ const client = new QueryClient();
20
+ function Wrapper({ children }: PropsWithChildren) {
21
+ return (
22
+ <QueryClientProvider client={client}>{children}</QueryClientProvider>
23
+ );
24
+ }
25
+ return renderHook(
26
+ () => useInvoiceCustomerSearch('ws-1', '', selectedUserId),
27
+ {
28
+ wrapper: Wrapper,
29
+ }
30
+ );
31
+ }
32
+
33
+ describe('invoice customer loading recovery', () => {
34
+ it('surfaces a failed read immediately and allows a manual retry', async () => {
35
+ api.list.mockRejectedValueOnce(new Error('Timed out')).mockResolvedValue({
36
+ data: [{ id: 'customer-1' }],
37
+ count: 1,
38
+ });
39
+ const { result } = renderSearch();
40
+ await waitFor(() => expect(result.current.error).toBeTruthy());
41
+ expect(result.current.isLoading).toBe(false);
42
+ expect(api.list).toHaveBeenCalledOnce();
43
+
44
+ await act(async () => {
45
+ await result.current.refetch();
46
+ });
47
+ await waitFor(() => expect(result.current.error).toBeNull());
48
+ expect(result.current.customers).toEqual([{ id: 'customer-1' }]);
49
+ });
50
+
51
+ it('retries the selected customer as well as the customer list', async () => {
52
+ api.list.mockResolvedValue({ data: [], count: 0 });
53
+ api.get
54
+ .mockRejectedValueOnce(new Error('Timed out'))
55
+ .mockResolvedValue({ id: 'selected' });
56
+ const { result } = renderSearch('selected');
57
+ await waitFor(() => expect(result.current.error).toBeTruthy());
58
+ await act(async () => {
59
+ await result.current.refetch();
60
+ });
61
+ await waitFor(() =>
62
+ expect(result.current.selectedUser?.id).toBe('selected')
63
+ );
64
+ expect(result.current.error).toBeNull();
65
+ expect(api.get).toHaveBeenCalledTimes(2);
66
+ });
67
+
68
+ it('ignores an old detail error once the list contains the selected customer', async () => {
69
+ api.list.mockResolvedValueOnce({ data: [], count: 0 }).mockResolvedValue({
70
+ data: [{ id: 'selected' }],
71
+ count: 1,
72
+ });
73
+ api.get.mockRejectedValue(new Error('Detail unavailable'));
74
+ const { result } = renderSearch('selected');
75
+ await waitFor(() => expect(result.current.error).toBeTruthy());
76
+ await act(async () => {
77
+ await result.current.refetch();
78
+ });
79
+ await waitFor(() => expect(result.current.error).toBeNull());
80
+ expect(result.current.selectedUser?.id).toBe('selected');
81
+ });
82
+
83
+ it('stops offering more customers when an empty page has a stale count', async () => {
84
+ api.list.mockResolvedValue({ data: [], count: 10 });
85
+ const { result } = renderSearch();
86
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
87
+ expect(result.current.hasNextPage).toBe(false);
88
+ });
89
+ });
@@ -0,0 +1,117 @@
1
+ import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
2
+ import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser';
3
+ import {
4
+ getWorkspaceUserWithInternalApi,
5
+ listWorkspaceUsersWithInternalApi,
6
+ } from '../internal-api';
7
+
8
+ const INVOICE_CUSTOMER_PAGE_SIZE = 25;
9
+
10
+ type WorkspaceUsersPage = {
11
+ data: WorkspaceUser[];
12
+ count: number;
13
+ offset: number;
14
+ };
15
+
16
+ async function fetchInvoiceCustomerPage(
17
+ wsId: string,
18
+ searchQuery: string,
19
+ offset: number
20
+ ): Promise<WorkspaceUsersPage> {
21
+ const payload = await listWorkspaceUsersWithInternalApi(wsId, {
22
+ from: offset,
23
+ limit: INVOICE_CUSTOMER_PAGE_SIZE,
24
+ q: searchQuery.trim() || undefined,
25
+ to: offset + INVOICE_CUSTOMER_PAGE_SIZE - 1,
26
+ });
27
+
28
+ return {
29
+ data: payload.data,
30
+ count: payload.count,
31
+ offset,
32
+ };
33
+ }
34
+
35
+ async function fetchWorkspaceUserById(
36
+ wsId: string,
37
+ userId: string
38
+ ): Promise<WorkspaceUser | null> {
39
+ return getWorkspaceUserWithInternalApi(wsId, userId);
40
+ }
41
+
42
+ export function useInvoiceCustomerSearch(
43
+ wsId: string,
44
+ searchQuery: string,
45
+ selectedUserId: string
46
+ ) {
47
+ const normalizedSearchQuery = searchQuery.trim();
48
+
49
+ const usersQuery = useInfiniteQuery({
50
+ queryKey: ['invoice-customer-search', wsId, normalizedSearchQuery],
51
+ queryFn: async ({ pageParam = 0 }) =>
52
+ fetchInvoiceCustomerPage(wsId, normalizedSearchQuery, pageParam),
53
+ initialPageParam: 0,
54
+ placeholderData: (previousData) => previousData,
55
+ getNextPageParam: (lastPage, allPages) => {
56
+ const loadedCount = allPages.reduce(
57
+ (total, page) => total + page.data.length,
58
+ 0
59
+ );
60
+ return lastPage.data.length > 0 && loadedCount < lastPage.count
61
+ ? loadedCount
62
+ : undefined;
63
+ },
64
+ enabled: !!wsId,
65
+ retry: false,
66
+ staleTime: 30 * 1000,
67
+ gcTime: 5 * 60 * 1000,
68
+ refetchOnWindowFocus: false,
69
+ });
70
+
71
+ const loadedCustomers =
72
+ usersQuery.data?.pages.flatMap((page) => page.data) ?? [];
73
+ const needsSelectedUser =
74
+ !!wsId &&
75
+ !!selectedUserId &&
76
+ !loadedCustomers.some((user) => user.id === selectedUserId);
77
+ const selectedUserQuery = useQuery({
78
+ queryKey: ['invoice-customer', wsId, selectedUserId],
79
+ queryFn: async () => fetchWorkspaceUserById(wsId, selectedUserId),
80
+ enabled: needsSelectedUser,
81
+ retry: false,
82
+ staleTime: 30 * 1000,
83
+ gcTime: 5 * 60 * 1000,
84
+ refetchOnWindowFocus: false,
85
+ });
86
+
87
+ const customersById = new Map<string, WorkspaceUser>();
88
+
89
+ if (selectedUserQuery.data) {
90
+ customersById.set(selectedUserQuery.data.id, selectedUserQuery.data);
91
+ }
92
+
93
+ for (const customer of loadedCustomers) {
94
+ customersById.set(customer.id, customer);
95
+ }
96
+
97
+ const customers = Array.from(customersById.values());
98
+ const selectedUser =
99
+ (selectedUserId
100
+ ? customers.find((user) => user.id === selectedUserId)
101
+ : undefined) ?? undefined;
102
+
103
+ return {
104
+ ...usersQuery,
105
+ customers,
106
+ selectedUser,
107
+ error:
108
+ usersQuery.error ?? (needsSelectedUser ? selectedUserQuery.error : null),
109
+ isLoading: usersQuery.isLoading || selectedUserQuery.isLoading,
110
+ isFetchingSelectedUser: selectedUserQuery.isFetching,
111
+ refetch: () =>
112
+ Promise.all([
113
+ usersQuery.refetch(),
114
+ ...(needsSelectedUser ? [selectedUserQuery.refetch()] : []),
115
+ ]),
116
+ };
117
+ }
@@ -20,12 +20,10 @@ import {
20
20
  import type { Invoice } from '@tuturuuu/types/primitives/Invoice';
21
21
  import type { PendingInvoice } from '@tuturuuu/types/primitives/PendingInvoice';
22
22
  import { parseMonthsOwed } from '@tuturuuu/types/primitives/PendingInvoice';
23
- import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser';
24
23
  import { z } from 'zod';
25
24
  import type { WorkspaceUserLinkedPromotion } from './internal-api';
26
25
  import {
27
26
  createPromotionWithInternalApi,
28
- getWorkspaceUserWithInternalApi,
29
27
  listInvoiceProductsWithInternalApi,
30
28
  listMultiGroupProductsWithInternalApi,
31
29
  listPromotionsWithInternalApi,
@@ -37,7 +35,7 @@ import {
37
35
  } from './internal-api';
38
36
  import type { UserGroup } from './utils';
39
37
 
40
- // ==================== ZOD SCHEMAS ====================
38
+ export { useInvoiceCustomerSearch } from './hooks/use-invoice-customer-search';
41
39
 
42
40
  const invoiceSchema = z.object({
43
41
  id: z.string(),
@@ -184,8 +182,6 @@ function isInvoiceQueryEnabled(options?: InvoiceQueryOptions) {
184
182
  return options?.enabled !== false;
185
183
  }
186
184
 
187
- // ==================== INVOICES DATA FETCHING ====================
188
-
189
185
  export interface InvoicesParams {
190
186
  q?: string;
191
187
  page?: number;
@@ -258,8 +254,6 @@ export function useWorkspaceInvoices(
258
254
  });
259
255
  }
260
256
 
261
- // ==================== LEGACY HOOKS ====================
262
-
263
257
  // React Query hooks for data fetching
264
258
  export const useUsers = (wsId: string) => {
265
259
  return useQuery({
@@ -273,106 +267,6 @@ export const useUsers = (wsId: string) => {
273
267
  });
274
268
  };
275
269
 
276
- const INVOICE_CUSTOMER_PAGE_SIZE = 25;
277
-
278
- type WorkspaceUsersPage = {
279
- data: WorkspaceUser[];
280
- count: number;
281
- offset: number;
282
- };
283
-
284
- async function fetchInvoiceCustomerPage(
285
- wsId: string,
286
- searchQuery: string,
287
- offset: number
288
- ): Promise<WorkspaceUsersPage> {
289
- const payload = await listWorkspaceUsersWithInternalApi(wsId, {
290
- from: offset,
291
- limit: INVOICE_CUSTOMER_PAGE_SIZE,
292
- q: searchQuery.trim() || undefined,
293
- to: offset + INVOICE_CUSTOMER_PAGE_SIZE - 1,
294
- });
295
-
296
- return {
297
- data: payload.data,
298
- count: payload.count,
299
- offset,
300
- };
301
- }
302
-
303
- async function fetchWorkspaceUserById(
304
- wsId: string,
305
- userId: string
306
- ): Promise<WorkspaceUser | null> {
307
- return getWorkspaceUserWithInternalApi(wsId, userId);
308
- }
309
-
310
- export function useInvoiceCustomerSearch(
311
- wsId: string,
312
- searchQuery: string,
313
- selectedUserId: string
314
- ) {
315
- const normalizedSearchQuery = searchQuery.trim();
316
-
317
- const usersQuery = useInfiniteQuery({
318
- queryKey: ['invoice-customer-search', wsId, normalizedSearchQuery],
319
- queryFn: async ({ pageParam = 0 }) =>
320
- fetchInvoiceCustomerPage(wsId, normalizedSearchQuery, pageParam),
321
- initialPageParam: 0,
322
- placeholderData: (previousData) => previousData,
323
- getNextPageParam: (lastPage, allPages) => {
324
- const loadedCount = allPages.reduce(
325
- (total, page) => total + page.data.length,
326
- 0
327
- );
328
- return loadedCount < lastPage.count ? loadedCount : undefined;
329
- },
330
- enabled: !!wsId,
331
- staleTime: 30 * 1000,
332
- gcTime: 5 * 60 * 1000,
333
- refetchOnWindowFocus: false,
334
- });
335
-
336
- const loadedCustomers =
337
- usersQuery.data?.pages.flatMap((page) => page.data) ?? [];
338
- const selectedUserQuery = useQuery({
339
- queryKey: ['invoice-customer', wsId, selectedUserId],
340
- queryFn: async () => fetchWorkspaceUserById(wsId, selectedUserId),
341
- enabled:
342
- !!wsId &&
343
- !!selectedUserId &&
344
- !loadedCustomers.some((user) => user.id === selectedUserId),
345
- staleTime: 30 * 1000,
346
- gcTime: 5 * 60 * 1000,
347
- refetchOnWindowFocus: false,
348
- });
349
-
350
- const customersById = new Map<string, WorkspaceUser>();
351
-
352
- if (selectedUserQuery.data) {
353
- customersById.set(selectedUserQuery.data.id, selectedUserQuery.data);
354
- }
355
-
356
- for (const customer of loadedCustomers) {
357
- customersById.set(customer.id, customer);
358
- }
359
-
360
- const customers = Array.from(customersById.values());
361
- const selectedUser =
362
- (selectedUserId
363
- ? customers.find((user) => user.id === selectedUserId)
364
- : undefined) ?? undefined;
365
-
366
- return {
367
- ...usersQuery,
368
- customers,
369
- selectedUser,
370
- error: usersQuery.error ?? selectedUserQuery.error,
371
- isLoading: usersQuery.isLoading || selectedUserQuery.isLoading,
372
- isFetchingSelectedUser: selectedUserQuery.isFetching,
373
- };
374
- }
375
-
376
270
  // Users with selectable groups (groups where they have STUDENT role)
377
271
  export const useUsersWithSelectableGroups = (wsId: string) => {
378
272
  return useQuery({
@@ -391,6 +285,7 @@ export const useProducts = (wsId: string, options?: InvoiceQueryOptions) => {
391
285
  queryKey: ['products', wsId],
392
286
  queryFn: () => listInvoiceProductsWithInternalApi(wsId),
393
287
  enabled: !!wsId && isInvoiceQueryEnabled(options),
288
+ retry: false,
394
289
  staleTime: INVOICE_STATIC_QUERY_STALE_TIME,
395
290
  gcTime: INVOICE_STATIC_QUERY_GC_TIME,
396
291
  refetchOnWindowFocus: false,
@@ -493,7 +388,7 @@ export const useUserGroups = (wsId: string, userId: string) => {
493
388
  staleTime: 5 * 60 * 1000, // 5 minutes
494
389
  gcTime: 10 * 60 * 1000, // 10 minutes
495
390
  refetchOnWindowFocus: false,
496
- retry: 3,
391
+ retry: false,
497
392
  });
498
393
  };
499
394
 
@@ -540,7 +435,7 @@ export const useSubscriptionInvoiceContext = (
540
435
  staleTime: 5 * 60 * 1000,
541
436
  gcTime: 10 * 60 * 1000,
542
437
  refetchOnWindowFocus: false,
543
- retry: 3,
438
+ retry: false,
544
439
  });
545
440
  };
546
441
 
@@ -677,7 +572,7 @@ export const useMultiGroupProducts = (
677
572
  staleTime: 5 * 60 * 1000, // 5 minutes
678
573
  gcTime: 10 * 60 * 1000, // 10 minutes
679
574
  refetchOnWindowFocus: false,
680
- retry: 3,
575
+ retry: false,
681
576
  });
682
577
  };
683
578
 
@@ -0,0 +1,122 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ getWorkspaceUserWithInternalApi,
4
+ listInvoiceProductsWithInternalApi,
5
+ listMultiGroupProductsWithInternalApi,
6
+ listUserGroupsWithInternalApi,
7
+ listWorkspaceUsersWithInternalApi,
8
+ } from './internal-api';
9
+
10
+ const mocks = vi.hoisted(() => ({ json: vi.fn() }));
11
+ vi.mock('@tuturuuu/internal-api/client', () => ({
12
+ encodePathSegment: encodeURIComponent,
13
+ getInternalApiClient: () => ({ json: mocks.json }),
14
+ }));
15
+
16
+ beforeEach(() => {
17
+ mocks.json.mockReset();
18
+ });
19
+ afterEach(() => vi.restoreAllMocks());
20
+
21
+ describe('invoice product loading', () => {
22
+ it('loads every product page and shares one deadline across pages', async () => {
23
+ const firstPage = Array.from({ length: 500 }, (_, id) => ({
24
+ id: String(id),
25
+ }));
26
+ mocks.json
27
+ .mockResolvedValueOnce({ data: firstPage, count: 501 })
28
+ .mockResolvedValueOnce({ data: [{ id: 'last' }], count: 501 });
29
+
30
+ const result = await listInvoiceProductsWithInternalApi('ws-1');
31
+
32
+ expect(result).toEqual([...firstPage, { id: 'last' }]);
33
+ expect(mocks.json).toHaveBeenCalledTimes(2);
34
+ expect(mocks.json.mock.calls.map((call) => call[1].query.page)).toEqual([
35
+ 1, 2,
36
+ ]);
37
+ expect(mocks.json.mock.calls[0]?.[1].signal).toBe(
38
+ mocks.json.mock.calls[1]?.[1].signal
39
+ );
40
+ });
41
+
42
+ it('accepts an empty catalog', async () => {
43
+ mocks.json.mockResolvedValue({ data: [], count: 0 });
44
+ await expect(listInvoiceProductsWithInternalApi('ws-1')).resolves.toEqual(
45
+ []
46
+ );
47
+ expect(mocks.json).toHaveBeenCalledTimes(1);
48
+ });
49
+
50
+ it('fails instead of looping forever when an empty page retains a total', async () => {
51
+ mocks.json
52
+ .mockResolvedValueOnce({ data: [{ id: 'first' }], count: 2 })
53
+ .mockResolvedValueOnce({ data: [], count: 2 })
54
+ .mockRejectedValue(new Error('Unexpected extra page'));
55
+
56
+ await expect(listInvoiceProductsWithInternalApi('ws-1')).rejects.toThrow(
57
+ 'stopped making progress'
58
+ );
59
+ expect(mocks.json).toHaveBeenCalledTimes(2);
60
+ });
61
+
62
+ it('fails when the server repeats a page without adding products', async () => {
63
+ mocks.json.mockResolvedValue({ data: [{ id: 'same' }], count: 3 });
64
+ await expect(listInvoiceProductsWithInternalApi('ws-1')).rejects.toThrow(
65
+ 'stopped making progress'
66
+ );
67
+ expect(mocks.json).toHaveBeenCalledTimes(2);
68
+ });
69
+
70
+ it('does not return a partial catalog when a later page fails', async () => {
71
+ mocks.json
72
+ .mockResolvedValueOnce({ data: [{ id: 'first' }], count: 2 })
73
+ .mockRejectedValueOnce(new Error('Unavailable'));
74
+ await expect(listInvoiceProductsWithInternalApi('ws-1')).rejects.toThrow(
75
+ 'Unavailable'
76
+ );
77
+ });
78
+
79
+ it('stops when the catalog shrinks between pages', async () => {
80
+ mocks.json
81
+ .mockResolvedValueOnce({ data: [{ id: 'first' }], count: 2 })
82
+ .mockResolvedValueOnce({ data: [], count: 1 });
83
+ await expect(listInvoiceProductsWithInternalApi('ws-1')).resolves.toEqual([
84
+ { id: 'first' },
85
+ ]);
86
+ });
87
+ });
88
+
89
+ describe('invoice startup request deadlines', () => {
90
+ it.each([
91
+ ['products', () => listInvoiceProductsWithInternalApi('ws-1')],
92
+ ['customers', () => listWorkspaceUsersWithInternalApi('ws-1')],
93
+ [
94
+ 'selected customer',
95
+ () => getWorkspaceUserWithInternalApi('ws-1', 'user-1'),
96
+ ],
97
+ ['customer groups', () => listUserGroupsWithInternalApi('ws-1', 'user-1')],
98
+ [
99
+ 'group products',
100
+ () => listMultiGroupProductsWithInternalApi('ws-1', ['group-1']),
101
+ ],
102
+ ])('aborts a stalled %s read', async (_name, load) => {
103
+ const controller = new AbortController();
104
+ const timeout = vi
105
+ .spyOn(AbortSignal, 'timeout')
106
+ .mockReturnValue(controller.signal);
107
+ mocks.json.mockImplementation(
108
+ (_path, options) =>
109
+ new Promise((_resolve, reject) => {
110
+ options.signal.addEventListener('abort', () =>
111
+ reject(options.signal.reason)
112
+ );
113
+ })
114
+ );
115
+
116
+ const pending = load();
117
+ const assertion = expect(pending).rejects.toThrow('Timed out');
118
+ controller.abort(new Error('Timed out'));
119
+ await assertion;
120
+ expect(timeout).toHaveBeenCalledWith(15_000);
121
+ });
122
+ });
@@ -203,6 +203,7 @@ export async function listWorkspaceUsersWithInternalApi(
203
203
  WorkspaceUser[] | { data?: WorkspaceUser[]; count?: number }
204
204
  >(`/api/v1/workspaces/${encodePathSegment(workspaceId)}/users`, {
205
205
  cache: 'no-store',
206
+ signal: AbortSignal.timeout(15_000),
206
207
  query: {
207
208
  from: query.from,
208
209
  limit: query.limit,
@@ -233,6 +234,7 @@ export async function getWorkspaceUserWithInternalApi(
233
234
  `/api/v1/workspaces/${encodePathSegment(workspaceId)}/users/${encodePathSegment(userId)}`,
234
235
  {
235
236
  cache: 'no-store',
237
+ signal: AbortSignal.timeout(15_000),
236
238
  }
237
239
  );
238
240
 
@@ -246,6 +248,8 @@ export async function getWorkspaceUserWithInternalApi(
246
248
  export async function listInvoiceProductsWithInternalApi(workspaceId: string) {
247
249
  const client = getInternalApiClient();
248
250
  const products: Product[] = [];
251
+ const productIds = new Set<string>();
252
+ const signal = AbortSignal.timeout(15_000);
249
253
  const pageSize = 500;
250
254
  let page = 1;
251
255
  let count = 0;
@@ -255,6 +259,7 @@ export async function listInvoiceProductsWithInternalApi(workspaceId: string) {
255
259
  `/api/v1/workspaces/${encodePathSegment(workspaceId)}/inventory/products`,
256
260
  {
257
261
  cache: 'no-store',
262
+ signal,
258
263
  query: {
259
264
  page,
260
265
  pageSize,
@@ -262,7 +267,18 @@ export async function listInvoiceProductsWithInternalApi(workspaceId: string) {
262
267
  }
263
268
  );
264
269
 
265
- products.push(...(payload.data ?? []));
270
+ const pageProducts = payload.data ?? [];
271
+ const newProducts = pageProducts.filter(
272
+ (product) => !productIds.has(product.id)
273
+ );
274
+ if (pageProducts.length === 0 || newProducts.length === 0) {
275
+ if (products.length < (payload.count ?? 0)) {
276
+ throw new Error('Invoice product pagination stopped making progress');
277
+ }
278
+ break;
279
+ }
280
+ for (const product of newProducts) productIds.add(product.id);
281
+ products.push(...newProducts);
266
282
  count = payload.count ?? products.length;
267
283
  page += 1;
268
284
  } while (products.length < count);
@@ -326,6 +342,7 @@ export function listUserGroupsWithInternalApi(
326
342
  `/api/v1/workspaces/${encodePathSegment(workspaceId)}/users/${encodePathSegment(userId)}/user-groups`,
327
343
  {
328
344
  cache: 'no-store',
345
+ signal: AbortSignal.timeout(15_000),
329
346
  }
330
347
  );
331
348
  }
@@ -384,6 +401,7 @@ export function listMultiGroupProductsWithInternalApi(
384
401
  `/api/v1/workspaces/${encodePathSegment(workspaceId)}/user-groups/linked-products?${searchParams.toString()}`,
385
402
  {
386
403
  cache: 'no-store',
404
+ signal: AbortSignal.timeout(15_000),
387
405
  }
388
406
  )
389
407
  .then((payload) => payload.items ?? []);