diff --git a/apps/mobile/src/components/code-reviewer/review-list-screen.tsx b/apps/mobile/src/components/code-reviewer/review-list-screen.tsx index 4a164f4d48..531085768b 100644 --- a/apps/mobile/src/components/code-reviewer/review-list-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-list-screen.tsx @@ -15,6 +15,7 @@ import { TabScreenScrollView } from '@/components/tab-screen'; import { i18n } from '@/i18n'; import { useGitHubStatus, useGitLabStatus } from '@/lib/hooks/use-code-reviewer'; import { useReviewList } from '@/lib/hooks/use-code-reviews'; +import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh'; import { cn, parseTimestamp, timeAgo } from '@/lib/utils'; // Tone classes stay mobile-local; the label is the translated catalog key @@ -60,6 +61,7 @@ export function ReviewListScreen({ scope }: Readonly<{ scope: string }>) { const router = useRouter(); const { t } = useTranslation(); const { data, isLoading, isError, isFetching, error, refetch } = useReviewList(scope); + useRouteForegroundRefresh([[['codeReviews']]]); const githubStatus = useGitHubStatus(scope); const gitlabStatus = useGitLabStatus(scope); const hasConnectedProvider = diff --git a/apps/mobile/src/components/organization/credit-activity-screen.mounted.test.tsx b/apps/mobile/src/components/organization/credit-activity-screen.mounted.test.tsx new file mode 100644 index 0000000000..a5e81085e2 --- /dev/null +++ b/apps/mobile/src/components/organization/credit-activity-screen.mounted.test.tsx @@ -0,0 +1,353 @@ +/* eslint-disable max-lines -- cohesive mounted suite for the credit-activity screen state contract */ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer for RN trees under vitest (node env, no jsdom). */ + +// Credit-activity screen state contract: loading skeleton, first-page error +// (retryable vs. permanent NOT_FOUND/FORBIDDEN/UNAUTHORIZED no-retry), the empty +// state, the `hasMore` footer (truncated string + Load more, busy while a page is +// loading), and the later-page failure footer (rows kept + Retry). The query layer +// is mocked so each state is driven directly through the screen JSX. + +import { createElement, type ReactElement } from 'react'; +import { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '@/test/render-with-providers'; + +import '@/i18n'; +import { OrganizationCreditActivityScreen } from './credit-activity-screen'; + +const pageQuery = vi.hoisted(() => ({ + isPending: false, + isError: false, + isFetchNextPageError: false, + isFetching: false, + isFetchingNextPage: false, + data: null as unknown, + error: null as unknown, + refetch: vi.fn(), + fetchNextPage: vi.fn(), +})); + +const pageHook = vi.hoisted(() => ({ + entries: [] as unknown[], + hasMore: false, +})); + +const queryErrors = vi.hoisted(() => ({ + errors: [] as { variant?: string; onRetry?: () => void }[], +})); + +const buttons = vi.hoisted(() => ({ + rendered: [] as { + children?: unknown; + onPress?: () => void; + accessibilityLabel?: string; + loading?: boolean; + }[], +})); + +vi.mock('@/lib/hooks/use-organization-queries', () => ({ + useOrgBoundary: () => ({ + organizationId: 'org-1', + role: 'owner', + org: { organizationId: 'org-1', role: 'owner' }, + orgs: [{ organizationId: 'org-1', role: 'owner' }], + isLoading: false, + isResolving: false, + isError: false, + }), + useOrgCreditTransactionsPage: () => ({ + query: pageQuery, + entries: pageHook.entries, + hasMore: pageHook.hasMore, + }), +})); + +vi.mock('@/components/tab-screen', () => ({ + useTabBarBottomPadding: () => 0, +})); + +vi.mock('@/lib/hooks/use-route-foreground-refresh', () => ({ + useRouteForegroundRefresh: vi.fn(), +})); + +vi.mock('@/lib/organization-context', () => ({ + useOrganization: () => ({ + organizationId: 'org-1', + isLoaded: true, + setOrganizationId: vi.fn(), + }), +})); + +vi.mock('@/lib/org-deep-link', () => ({ + reconcileOrgDeepLink: () => ({ + effectiveOrganizationId: 'org-1', + validatedOrg: undefined, + queryOrganizationId: 'org-1', + shouldPersistOverride: false, + isResolving: false, + }), +})); + +vi.mock('expo-router', () => ({ + useLocalSearchParams: () => ({}), +})); + +vi.mock('@kilocode/app-shared/utils', () => ({ + fromMicrodollars: (microdollars: number) => microdollars / 1_000_000, +})); + +vi.mock('@/lib/format', () => ({ + formatDate: String, + formatMoney: (amount: number) => `$${amount}`, +})); + +vi.mock('@/lib/utils', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), + firstNonEmpty: (...args: (string | null | undefined)[]) => + args.find(value => value != null && value !== '') ?? '', + parseTimestamp: (value: string) => new Date(value), +})); + +vi.mock('@/components/empty-state', () => ({ + EmptyState: ({ title }: { title: string }) => `EMPTY_STATE:${title}`, +})); + +vi.mock('@/components/query-error', () => ({ + QueryError: (props: { variant?: string; onRetry?: () => void }) => { + queryErrors.errors.push(props); + return null; + }, +})); + +vi.mock('@/components/organization/organization-boundary', () => ({ + OrganizationBoundary: () => null, +})); + +vi.mock('@/components/screen-header', () => ({ ScreenHeader: () => null })); + +vi.mock('@/components/ui/button', () => ({ + Button: (props: { + children?: unknown; + onPress?: () => void; + accessibilityLabel?: string; + loading?: boolean; + }) => { + buttons.rendered.push(props); + return props.children; + }, +})); + +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); + +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); + +vi.mock('@/components/ui/icons', () => ({ Receipt: 'Receipt' })); + +vi.mock('react-native-reanimated', () => ({ + default: { View: 'AnimatedView' }, + FadeIn: { duration: () => ({}) }, + FadeOut: { duration: () => ({}) }, +})); + +vi.mock('react-native', () => ({ + View: 'View', + FlatList: (props: { + data?: unknown[]; + renderItem?: (info: { item: unknown; index: number }) => ReactElement; + ListEmptyComponent?: ReactElement; + ListFooterComponent?: ReactElement | null; + }) => { + const data = props.data ?? []; + if (data.length === 0) { + return props.ListEmptyComponent ?? null; + } + return createElement( + 'View', + null, + data.map((item, index) => props.renderItem?.({ item, index })), + props.ListFooterComponent ?? null + ); + }, +})); + +const TRANSACTION = { + id: 't1', + amount_microdollars: 1_000_000, + description: 'Top-up', + credit_category: null, + created_at: '2026-01-01T00:00:00.000Z', + expiry_date: null, +}; + +function collectText(node: unknown): string[] { + if (node == null) { + return []; + } + if (typeof node === 'string') { + return [node]; + } + if (Array.isArray(node)) { + return node.flatMap(item => collectText(item)); + } + if (typeof node === 'object' && 'children' in node) { + return collectText((node as { children?: unknown }).children); + } + return []; +} + +async function renderScreen(): Promise { + const { renderer } = await renderWithProviders(createElement(OrganizationCreditActivityScreen)); + return collectText(renderer.toJSON()); +} + +beforeEach(() => { + pageQuery.isPending = false; + pageQuery.isError = false; + pageQuery.isFetchNextPageError = false; + pageQuery.isFetching = false; + pageQuery.isFetchingNextPage = false; + pageQuery.data = null; + pageQuery.error = null; + pageQuery.refetch.mockClear(); + pageQuery.fetchNextPage.mockClear(); + pageHook.entries = []; + pageHook.hasMore = false; + queryErrors.errors = []; + buttons.rendered = []; +}); + +describe('OrganizationCreditActivityScreen loading', () => { + it('renders the loading skeleton while the first page is pending', async () => { + pageQuery.isPending = true; + + const texts = await renderScreen(); + + expect(texts).not.toContain('No credit activity'); + expect(queryErrors.errors).toHaveLength(0); + }); +}); + +describe('OrganizationCreditActivityScreen first-page errors', () => { + it('renders a retryable neutral error with Retry on a server failure', async () => { + pageQuery.data = { pages: [] }; + pageQuery.isError = true; + pageQuery.error = { data: { code: 'INTERNAL_SERVER_ERROR' } }; + + await renderScreen(); + + expect(queryErrors.errors).toHaveLength(1); + expect(queryErrors.errors[0]?.variant).toBe('neutral'); + expect(typeof queryErrors.errors[0]?.onRetry).toBe('function'); + }); + + it('renders a permanent not-found state with no Retry on NOT_FOUND', async () => { + pageQuery.data = { pages: [] }; + pageQuery.isError = true; + pageQuery.error = { data: { code: 'NOT_FOUND' } }; + + await renderScreen(); + + expect(queryErrors.errors).toHaveLength(1); + expect(queryErrors.errors[0]?.variant).toBe('not-found'); + expect(queryErrors.errors[0]?.onRetry).toBeUndefined(); + }); + + it.each(['FORBIDDEN', 'UNAUTHORIZED'] as const)( + 'renders a permanent permission state with no Retry on %s', + async code => { + pageQuery.data = { pages: [] }; + pageQuery.isError = true; + pageQuery.error = { data: { code } }; + + await renderScreen(); + + expect(queryErrors.errors).toHaveLength(1); + expect(queryErrors.errors[0]?.variant).toBe('permission'); + expect(queryErrors.errors[0]?.onRetry).toBeUndefined(); + } + ); +}); + +describe('OrganizationCreditActivityScreen empty', () => { + it('renders the empty state when the first page has no entries', async () => { + pageQuery.data = { pages: [{ entries: [], nextCursor: null, hasMore: false }] }; + + const texts = await renderScreen(); + + expect(texts).toContain('EMPTY_STATE:No credit activity'); + expect(queryErrors.errors).toHaveLength(0); + }); +}); + +describe('OrganizationCreditActivityScreen pagination', () => { + it('renders the truncated footer with Load more when hasMore is true', async () => { + pageQuery.data = { pages: [{ entries: [TRANSACTION], nextCursor: 1, hasMore: true }] }; + pageHook.entries = [TRANSACTION]; + pageHook.hasMore = true; + + const texts = await renderScreen(); + + expect(texts).toContain('Top-up'); + expect(texts).toContain('Older credit activity is available.'); + + const loadMore = buttons.rendered.find(button => button.accessibilityLabel === 'Load more'); + expect(loadMore).toBeDefined(); + expect(loadMore?.loading).toBe(false); + + act(() => { + loadMore?.onPress?.(); + }); + expect(pageQuery.fetchNextPage).toHaveBeenCalledTimes(1); + }); + + it('marks Load more busy while the next page is loading', async () => { + pageQuery.data = { pages: [{ entries: [TRANSACTION], nextCursor: 1, hasMore: true }] }; + pageQuery.isFetchingNextPage = true; + pageHook.entries = [TRANSACTION]; + pageHook.hasMore = true; + + await renderScreen(); + + const loadMore = buttons.rendered.find(button => button.accessibilityLabel === 'Load more'); + expect(loadMore?.loading).toBe(true); + }); + + it('keeps rows and shows a Retry footer when a later page fails', async () => { + pageQuery.data = { pages: [{ entries: [TRANSACTION], nextCursor: 1, hasMore: true }] }; + pageQuery.isFetchNextPageError = true; + pageQuery.isError = true; + pageQuery.error = { data: { code: 'INTERNAL_SERVER_ERROR' } }; + pageHook.entries = [TRANSACTION]; + pageHook.hasMore = true; + + const texts = await renderScreen(); + + expect(texts).toContain('Top-up'); + expect(texts).toContain("Couldn't load more."); + expect(texts).not.toContain('Older credit activity is available.'); + + const retry = buttons.rendered.find(button => button.accessibilityLabel === 'Retry'); + expect(retry).toBeDefined(); + + act(() => { + retry?.onPress?.(); + }); + expect(pageQuery.fetchNextPage).toHaveBeenCalledTimes(1); + }); + + it('keeps Load more when a background refetch fails after pages loaded', async () => { + pageQuery.data = { pages: [{ entries: [TRANSACTION], nextCursor: 1, hasMore: true }] }; + pageQuery.isError = true; + pageQuery.error = { data: { code: 'INTERNAL_SERVER_ERROR' } }; + pageHook.entries = [TRANSACTION]; + pageHook.hasMore = true; + + const texts = await renderScreen(); + + expect(texts).toContain('Top-up'); + expect(texts).toContain('Older credit activity is available.'); + expect(texts).not.toContain("Couldn't load more."); + expect(buttons.rendered.some(button => button.accessibilityLabel === 'Load more')).toBe(true); + }); +}); diff --git a/apps/mobile/src/components/organization/credit-activity-screen.tsx b/apps/mobile/src/components/organization/credit-activity-screen.tsx index cb140e3a19..f34bfe166c 100644 --- a/apps/mobile/src/components/organization/credit-activity-screen.tsx +++ b/apps/mobile/src/components/organization/credit-activity-screen.tsx @@ -11,6 +11,7 @@ import { OrganizationBoundary } from '@/components/organization/organization-bou import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { useTabBarBottomPadding } from '@/components/tab-screen'; +import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { i18n } from '@/i18n'; @@ -18,8 +19,9 @@ import { formatDate, formatMoney } from '@/lib/format'; import { type CreditTransaction, useOrgBoundary, - useOrgCreditTransactions, + useOrgCreditTransactionsPage, } from '@/lib/hooks/use-organization-queries'; +import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh'; import { useOrganization } from '@/lib/organization-context'; import { reconcileOrgDeepLink } from '@/lib/org-deep-link'; import { cn, firstNonEmpty, parseTimestamp } from '@/lib/utils'; @@ -134,8 +136,13 @@ export function OrganizationCreditActivityScreen() { // Key transactions only on the reconcile query id — never the pre-tap context // org while a deep-link param is present and unvalidated/invalid. - const query = useOrgCreditTransactions(reconcile.queryOrganizationId); + const { + query, + entries: transactions, + hasMore, + } = useOrgCreditTransactionsPage(reconcile.queryOrganizationId); const paddingBottom = useTabBarBottomPadding(); + useRouteForegroundRefresh([[['organizations']]]); const showBoundary = isResolving || @@ -153,9 +160,30 @@ export function OrganizationCreditActivityScreen() { ); } - const isLoading = query.isLoading; - const isQueryError = query.isError && !query.data; - const transactions = query.data ?? []; + const isLoading = query.isPending; + const hasLoadedPages = (query.data?.pages.length ?? 0) > 0; + const isFirstPageError = query.isError && !hasLoadedPages; + + // A thrown NOT_FOUND/FORBIDDEN/UNAUTHORIZED can't be fixed by retrying — show + // a permanent state with no Retry. Any other first-page error stays retryable. + const errorCode = query.error?.data?.code; + const isPermanentError = + errorCode === 'NOT_FOUND' || errorCode === 'FORBIDDEN' || errorCode === 'UNAUTHORIZED'; + + // NOT_FOUND maps to the not-found state; FORBIDDEN/UNAUTHORIZED map to the + // permission state. Any other error stays the retryable neutral state. + let errorVariant: 'neutral' | 'server' | 'not-found' | 'permission' = 'neutral'; + if (errorCode === 'NOT_FOUND') { + errorVariant = 'not-found'; + } else if (errorCode === 'FORBIDDEN' || errorCode === 'UNAUTHORIZED') { + errorVariant = 'permission'; + } + + // A later-page failure must keep the already-loaded rows and offer an inline + // retry instead of replacing the list. Read `isFetchNextPageError`, not + // `isError`: a failed background refetch of page 1 also raises `isError`, + // and the inline Retry calls `fetchNextPage()`, which can never clear it. + const isLaterPageError = query.isFetchNextPageError; let body: ReactNode = null; if (isLoading) { @@ -166,13 +194,54 @@ export function OrganizationCreditActivityScreen() { ); - } else if (isQueryError) { + } else if (isFirstPageError) { body = ( - void query.refetch()} isRetrying={query.isFetching} /> + void query.refetch()} + isRetrying={query.isFetching} + /> ); } else { + const footer = ( + + {hasMore && !isLaterPageError && ( + + + {t('organization.creditActivity.truncated')} + + + + )} + {isLaterPageError && ( + + + {t('organization.creditActivity.loadMoreFailed')} + + + + )} + + + ); + body = ( } - ListFooterComponent={} + ListFooterComponent={footer} /> ); diff --git a/apps/mobile/src/components/organization/invoices-screen.mounted.test.tsx b/apps/mobile/src/components/organization/invoices-screen.mounted.test.tsx new file mode 100644 index 0000000000..79241546a1 --- /dev/null +++ b/apps/mobile/src/components/organization/invoices-screen.mounted.test.tsx @@ -0,0 +1,340 @@ +/* eslint-disable max-lines -- cohesive mounted suite for the invoices screen state contract */ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer for RN trees under vitest (node env, no jsdom). */ + +// Invoices screen state contract: loading skeleton, first-page error +// (retryable vs. permanent NOT_FOUND/FORBIDDEN/UNAUTHORIZED no-retry), the empty +// state, the `hasMore` footer (truncated string + Load more, busy while a page is +// loading), and the later-page failure footer (rows kept + Retry). The query layer +// is mocked so each state is driven directly through the screen JSX. + +import { createElement, type ReactElement } from 'react'; +import { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '@/test/render-with-providers'; + +import '@/i18n'; +import { OrganizationInvoicesScreen } from './invoices-screen'; + +const pageQuery = vi.hoisted(() => ({ + isPending: false, + isError: false, + isFetchNextPageError: false, + isFetching: false, + isFetchingNextPage: false, + data: null as unknown, + error: null as unknown, + refetch: vi.fn(), + fetchNextPage: vi.fn(), +})); + +const pageHook = vi.hoisted(() => ({ + entries: [] as unknown[], + hasMore: false, +})); + +const queryErrors = vi.hoisted(() => ({ + errors: [] as { variant?: string; onRetry?: () => void }[], +})); + +const buttons = vi.hoisted(() => ({ + rendered: [] as { + children?: unknown; + onPress?: () => void; + accessibilityLabel?: string; + loading?: boolean; + }[], +})); + +vi.mock('@/lib/hooks/use-organization-queries', () => ({ + useOrgBoundary: () => ({ + organizationId: 'org-1', + role: 'owner', + org: { organizationId: 'org-1', role: 'owner' }, + isResolving: false, + }), + useOrgInvoicesPage: () => ({ + query: pageQuery, + entries: pageHook.entries, + hasMore: pageHook.hasMore, + }), +})); + +vi.mock('@/components/tab-screen', () => ({ + useTabBarBottomPadding: () => 0, +})); + +vi.mock('@/lib/hooks/use-route-foreground-refresh', () => ({ + useRouteForegroundRefresh: vi.fn(), +})); + +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: 'gray' }), +})); + +vi.mock('@/lib/organization-invoice-download', () => ({ + selectInvoiceRowState: () => 'no-affordance', + getInvoiceDownloadErrorMessage: String, + shareOrganizationInvoicePdf: vi.fn(), +})); + +vi.mock('sonner-native', () => ({ + toast: { error: vi.fn() }, +})); + +vi.mock('@/lib/format', () => ({ + formatDate: String, + formatMoneyFromCents: (amount: number) => `$${amount / 100}`, +})); + +vi.mock('@/lib/utils', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), + firstNonEmpty: (...args: (string | null | undefined)[]) => + args.find(value => value != null && value !== '') ?? '', +})); + +vi.mock('@/components/empty-state', () => ({ + EmptyState: ({ title }: { title: string }) => `EMPTY_STATE:${title}`, +})); + +vi.mock('@/components/query-error', () => ({ + QueryError: (props: { variant?: string; onRetry?: () => void }) => { + queryErrors.errors.push(props); + return null; + }, +})); + +vi.mock('@/components/organization/organization-boundary', () => ({ + OrganizationBoundary: () => null, +})); + +vi.mock('@/components/screen-header', () => ({ ScreenHeader: () => null })); + +vi.mock('@/components/ui/button', () => ({ + Button: (props: { + children?: unknown; + onPress?: () => void; + accessibilityLabel?: string; + loading?: boolean; + }) => { + buttons.rendered.push(props); + return props.children; + }, +})); + +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); + +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); + +vi.mock('@/components/ui/icons', () => ({ Download: 'Download', FileText: 'FileText' })); + +vi.mock('react-native-reanimated', () => ({ + default: { View: 'AnimatedView' }, + FadeIn: { duration: () => ({}) }, + FadeOut: { duration: () => ({}) }, +})); + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Pressable: 'Pressable', + View: 'View', + FlatList: (props: { + data?: unknown[]; + renderItem?: (info: { item: unknown; index: number }) => ReactElement; + ListEmptyComponent?: ReactElement; + ListFooterComponent?: ReactElement | null; + }) => { + const data = props.data ?? []; + if (data.length === 0) { + return props.ListEmptyComponent ?? null; + } + return createElement( + 'View', + null, + data.map((item, index) => props.renderItem?.({ item, index })), + props.ListFooterComponent ?? null + ); + }, +})); + +const INVOICE = { + id: 'inv-1', + number: 'INV-0001', + description: 'Seats for June', + amount_due: 5000, + created: 1_710_000_000, + status: 'paid', + invoice_pdf: null, +}; + +function collectText(node: unknown): string[] { + if (node == null) { + return []; + } + if (typeof node === 'string') { + return [node]; + } + if (Array.isArray(node)) { + return node.flatMap(item => collectText(item)); + } + if (typeof node === 'object' && 'children' in node) { + return collectText((node as { children?: unknown }).children); + } + return []; +} + +async function renderScreen(): Promise { + const { renderer } = await renderWithProviders(createElement(OrganizationInvoicesScreen)); + return collectText(renderer.toJSON()); +} + +beforeEach(() => { + pageQuery.isPending = false; + pageQuery.isError = false; + pageQuery.isFetchNextPageError = false; + pageQuery.isFetching = false; + pageQuery.isFetchingNextPage = false; + pageQuery.data = null; + pageQuery.error = null; + pageQuery.refetch.mockClear(); + pageQuery.fetchNextPage.mockClear(); + pageHook.entries = []; + pageHook.hasMore = false; + queryErrors.errors = []; + buttons.rendered = []; +}); + +describe('OrganizationInvoicesScreen loading', () => { + it('renders the loading skeleton while the first page is pending', async () => { + pageQuery.isPending = true; + + const texts = await renderScreen(); + + expect(texts).not.toContain('No invoices'); + expect(queryErrors.errors).toHaveLength(0); + }); +}); + +describe('OrganizationInvoicesScreen first-page errors', () => { + it('renders a retryable neutral error with Retry on a server failure', async () => { + pageQuery.data = { pages: [] }; + pageQuery.isError = true; + pageQuery.error = { data: { code: 'INTERNAL_SERVER_ERROR' } }; + + await renderScreen(); + + expect(queryErrors.errors).toHaveLength(1); + expect(queryErrors.errors[0]?.variant).toBe('neutral'); + expect(typeof queryErrors.errors[0]?.onRetry).toBe('function'); + }); + + it('renders a permanent not-found state with no Retry on NOT_FOUND', async () => { + pageQuery.data = { pages: [] }; + pageQuery.isError = true; + pageQuery.error = { data: { code: 'NOT_FOUND' } }; + + await renderScreen(); + + expect(queryErrors.errors).toHaveLength(1); + expect(queryErrors.errors[0]?.variant).toBe('not-found'); + expect(queryErrors.errors[0]?.onRetry).toBeUndefined(); + }); + + it.each(['FORBIDDEN', 'UNAUTHORIZED'] as const)( + 'renders a permanent permission state with no Retry on %s', + async code => { + pageQuery.data = { pages: [] }; + pageQuery.isError = true; + pageQuery.error = { data: { code } }; + + await renderScreen(); + + expect(queryErrors.errors).toHaveLength(1); + expect(queryErrors.errors[0]?.variant).toBe('permission'); + expect(queryErrors.errors[0]?.onRetry).toBeUndefined(); + } + ); +}); + +describe('OrganizationInvoicesScreen empty', () => { + it('renders the empty state when the first page has no entries', async () => { + pageQuery.data = { pages: [{ entries: [], nextCursor: null, hasMore: false }] }; + + const texts = await renderScreen(); + + expect(texts).toContain('EMPTY_STATE:No invoices'); + expect(queryErrors.errors).toHaveLength(0); + }); +}); + +describe('OrganizationInvoicesScreen pagination', () => { + it('renders the truncated footer with Load more when hasMore is true', async () => { + pageQuery.data = { pages: [{ entries: [INVOICE], nextCursor: 'inv-1', hasMore: true }] }; + pageHook.entries = [INVOICE]; + pageHook.hasMore = true; + + const texts = await renderScreen(); + + expect(texts).toContain('INV-0001'); + expect(texts).toContain('Older invoices are available.'); + + const loadMore = buttons.rendered.find(button => button.accessibilityLabel === 'Load more'); + expect(loadMore).toBeDefined(); + expect(loadMore?.loading).toBe(false); + + act(() => { + loadMore?.onPress?.(); + }); + expect(pageQuery.fetchNextPage).toHaveBeenCalledTimes(1); + }); + + it('marks Load more busy while the next page is loading', async () => { + pageQuery.data = { pages: [{ entries: [INVOICE], nextCursor: 'inv-1', hasMore: true }] }; + pageQuery.isFetchingNextPage = true; + pageHook.entries = [INVOICE]; + pageHook.hasMore = true; + + await renderScreen(); + + const loadMore = buttons.rendered.find(button => button.accessibilityLabel === 'Load more'); + expect(loadMore?.loading).toBe(true); + }); + + it('keeps rows and shows a Retry footer when a later page fails', async () => { + pageQuery.data = { pages: [{ entries: [INVOICE], nextCursor: 'inv-1', hasMore: true }] }; + pageQuery.isFetchNextPageError = true; + pageQuery.isError = true; + pageQuery.error = { data: { code: 'INTERNAL_SERVER_ERROR' } }; + pageHook.entries = [INVOICE]; + pageHook.hasMore = true; + + const texts = await renderScreen(); + + expect(texts).toContain('INV-0001'); + expect(texts).toContain("Couldn't load more."); + expect(texts).not.toContain('Older invoices are available.'); + + const retry = buttons.rendered.find(button => button.accessibilityLabel === 'Retry'); + expect(retry).toBeDefined(); + + act(() => { + retry?.onPress?.(); + }); + expect(pageQuery.fetchNextPage).toHaveBeenCalledTimes(1); + }); + + it('keeps Load more when a background refetch fails after pages loaded', async () => { + pageQuery.data = { pages: [{ entries: [INVOICE], nextCursor: 'inv-1', hasMore: true }] }; + pageQuery.isError = true; + pageQuery.error = { data: { code: 'INTERNAL_SERVER_ERROR' } }; + pageHook.entries = [INVOICE]; + pageHook.hasMore = true; + + const texts = await renderScreen(); + + expect(texts).toContain('INV-0001'); + expect(texts).toContain('Older invoices are available.'); + expect(texts).not.toContain("Couldn't load more."); + expect(buttons.rendered.some(button => button.accessibilityLabel === 'Load more')).toBe(true); + }); +}); diff --git a/apps/mobile/src/components/organization/invoices-screen.tsx b/apps/mobile/src/components/organization/invoices-screen.tsx index d28e287a2d..6382efa16c 100644 --- a/apps/mobile/src/components/organization/invoices-screen.tsx +++ b/apps/mobile/src/components/organization/invoices-screen.tsx @@ -10,6 +10,7 @@ import { OrganizationBoundary } from '@/components/organization/organization-bou import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { useTabBarBottomPadding } from '@/components/tab-screen'; +import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { i18n } from '@/i18n'; @@ -17,8 +18,9 @@ import { formatDate, formatMoneyFromCents } from '@/lib/format'; import { type OrgInvoice, useOrgBoundary, - useOrgInvoices, + useOrgInvoicesPage, } from '@/lib/hooks/use-organization-queries'; +import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { getInvoiceDownloadErrorMessage, @@ -183,16 +185,38 @@ function InvoiceRow({ invoice }: Readonly<{ invoice: OrgInvoice }>) { export function OrganizationInvoicesScreen() { const { t } = useTranslation(); const { organizationId, org, isResolving } = useOrgBoundary(); - const query = useOrgInvoices(organizationId); + const { query, entries: invoices, hasMore } = useOrgInvoicesPage(organizationId); const paddingBottom = useTabBarBottomPadding(); + useRouteForegroundRefresh([[['organizations']]]); if (isResolving || organizationId == null || org == null) { return ; } - const isLoading = query.isLoading; - const isError = query.isError && !query.data; - const invoices = query.data ?? []; + const isLoading = query.isPending; + const hasLoadedPages = (query.data?.pages.length ?? 0) > 0; + const isFirstPageError = query.isError && !hasLoadedPages; + + // A thrown NOT_FOUND/FORBIDDEN/UNAUTHORIZED can't be fixed by retrying — show + // a permanent state with no Retry. Any other first-page error stays retryable. + const errorCode = query.error?.data?.code; + const isPermanentError = + errorCode === 'NOT_FOUND' || errorCode === 'FORBIDDEN' || errorCode === 'UNAUTHORIZED'; + + // NOT_FOUND maps to the not-found state; FORBIDDEN/UNAUTHORIZED map to the + // permission state. Any other error stays the retryable neutral state. + let errorVariant: 'neutral' | 'server' | 'not-found' | 'permission' = 'neutral'; + if (errorCode === 'NOT_FOUND') { + errorVariant = 'not-found'; + } else if (errorCode === 'FORBIDDEN' || errorCode === 'UNAUTHORIZED') { + errorVariant = 'permission'; + } + + // A later-page failure must keep the already-loaded rows and offer an inline + // retry instead of replacing the list. Read `isFetchNextPageError`, not + // `isError`: a failed background refetch of page 1 also raises `isError`, + // and the inline Retry calls `fetchNextPage()`, which can never clear it. + const isLaterPageError = query.isFetchNextPageError; let body: ReactNode = null; if (isLoading) { @@ -203,13 +227,54 @@ export function OrganizationInvoicesScreen() { ); - } else if (isError) { + } else if (isFirstPageError) { body = ( - void query.refetch()} isRetrying={query.isFetching} /> + void query.refetch()} + isRetrying={query.isFetching} + /> ); } else { + const footer = ( + + {hasMore && !isLaterPageError && ( + + + {t('organization.invoices.truncated')} + + + + )} + {isLaterPageError && ( + + + {t('organization.invoices.loadMoreFailed')} + + + + )} + + + ); + body = ( } - ListFooterComponent={} + ListFooterComponent={footer} /> ); diff --git a/apps/mobile/src/components/profile-credits-card.mounted.test.tsx b/apps/mobile/src/components/profile-credits-card.mounted.test.tsx index c3d4a1af7e..1e498fac4c 100644 --- a/apps/mobile/src/components/profile-credits-card.mounted.test.tsx +++ b/apps/mobile/src/components/profile-credits-card.mounted.test.tsx @@ -1,46 +1,70 @@ /* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer for RN trees under vitest (node env, no jsdom). */ -// Paused-query regression: a paused balance query (offline/unknown -// connectivity, empty cache) is pending but not fetching, so `isLoading` is -// false while `balance` is still undefined. The card must show a skeleton, -// not `$0` / the AddCreditsRow CTA, on a cold launch before NetInfo settles. +// Owner-keyed financial queries: the balance card must never render one signed-in +// owner's cached balance as the current amount after the owner switches. Each +// query key is suffixed with the userId, and the placeholder gate compares the +// previous query key's last element against the current userId, so a user switch +// shows the skeleton instead of reusing the previous owner's cache. -import { createElement } from 'react'; -import TestRenderer, { act } from 'react-test-renderer'; +import { createElement, type ReactElement } from 'react'; +import { Pressable } from 'react-native'; +import TestRenderer, { act, type ReactTestRenderer } from 'react-test-renderer'; +import { type QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import '@/i18n'; import { CreditsCard } from './profile-credits-card'; +import { type OrgListEntry } from '@/lib/hooks/use-organization-queries'; +import { createTestQueryClient, waitFor } from '@/test/render-with-providers'; -const balanceQuery = vi.hoisted(() => ({ - data: undefined as unknown, - isLoading: false, - isFetching: false, - isError: false, - refetch: vi.fn(), -})); +// ── Hoisted mocks ────────────────────────────────────────────────────────── -vi.mock('@tanstack/react-query', () => ({ - keepPreviousData: (value: unknown) => value, - useQuery: () => balanceQuery, - useQueryClient: () => ({ invalidateQueries: vi.fn() }), -})); - -vi.mock('expo-router', () => ({ - useFocusEffect: () => undefined, +const getContextBalanceQueryFn = vi.hoisted(() => vi.fn()); +const personalCreditBlocksQueryFn = vi.hoisted(() => vi.fn()); +const orgCreditBlocksQueryFn = vi.hoisted(() => vi.fn()); +const refetchUserId = vi.hoisted(() => vi.fn()); +const currentUser = vi.hoisted(() => ({ + userId: undefined as string | undefined, + isError: false, })); vi.mock('@/lib/trpc', () => ({ useTRPC: () => ({ user: { - getContextBalance: { queryOptions: () => ({}) }, - getCreditBlocks: { queryOptions: () => ({}) }, + getContextBalance: { + queryOptions: () => ({ + queryKey: ['user', 'getContextBalance'] as const, + queryFn: getContextBalanceQueryFn, + }), + }, + getCreditBlocks: { + queryOptions: () => ({ + queryKey: ['user', 'getCreditBlocks'] as const, + queryFn: personalCreditBlocksQueryFn, + }), + }, }, organizations: { - getCreditBlocks: { queryOptions: () => ({}) }, + getCreditBlocks: { + queryOptions: () => ({ + queryKey: ['organizations', 'getCreditBlocks'] as const, + queryFn: orgCreditBlocksQueryFn, + }), + }, }, }), })); +vi.mock('@/lib/hooks/use-current-user-id', () => ({ + useCurrentUserId: () => ({ + userId: currentUser.userId, + email: undefined, + isLoading: false, + isError: currentUser.isError, + refetch: refetchUserId, + }), +})); + vi.mock('@/lib/organization-context', () => ({ useOrganization: () => ({ organizationId: null, setOrganizationId: vi.fn() }), })); @@ -98,10 +122,13 @@ vi.mock('@/lib/hooks/use-theme-colors', () => ({ })); vi.mock('@/lib/utils', () => ({ - formatDate: () => 'date', parseTimestamp: () => new Date(0), })); +// ── Helpers ──────────────────────────────────────────────────────────────── + +const BALANCE_KEY = ['user', 'getContextBalance'] as const; + function collectText(node: unknown): string[] { if (node == null) { return []; @@ -118,44 +145,146 @@ function collectText(node: unknown): string[] { return []; } -async function renderCard(): Promise { - const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; +function cardElement(orgs?: OrgListEntry[]): ReactElement { + return createElement(CreditsCard, { enabled: true, orgs }); +} + +type CardHandle = { + renderer: ReactTestRenderer; + texts: () => string[]; + rerender: () => Promise; + unmount: () => void; +}; + +async function mountCard(queryClient: QueryClient = createTestQueryClient()): Promise { + const wrapper = (orgs: OrgListEntry[] | undefined) => + createElement(QueryClientProvider, { client: queryClient }, cardElement(orgs)); + + const ref: { current: ReactTestRenderer | undefined } = { current: undefined }; await act(async () => { - ref.current = TestRenderer.create( - createElement(CreditsCard, { enabled: true, orgs: undefined }) - ); + ref.current = TestRenderer.create(wrapper(undefined)); await Promise.resolve(); }); const renderer = ref.current; if (!renderer) { throw new Error('renderer was not created'); } - return collectText(renderer.toJSON()); + + return { + renderer, + texts: () => collectText(renderer.toJSON()), + // Toggling `orgs` (undefined <-> []) forces a re-render without changing + // the visible tree (both render the personal context with no picker), so a + // userId change read from the mocked hook is picked up by the queries. + rerender: async () => { + await act(async () => { + renderer.update(wrapper([])); + await Promise.resolve(); + }); + }, + unmount: () => { + act(() => { + renderer.unmount(); + }); + queryClient.clear(); + }, + }; } beforeEach(() => { - balanceQuery.data = undefined; - balanceQuery.isLoading = false; - balanceQuery.isFetching = false; - balanceQuery.isError = false; - balanceQuery.refetch.mockClear(); + getContextBalanceQueryFn.mockReset(); + personalCreditBlocksQueryFn.mockReset(); + orgCreditBlocksQueryFn.mockReset(); + refetchUserId.mockReset(); + currentUser.userId = undefined; + currentUser.isError = false; + personalCreditBlocksQueryFn.mockResolvedValue({ creditBlocks: [] }); }); describe('CreditsCard balance state', () => { - it('shows a skeleton (not $0) when the balance query is paused with no data', async () => { - const texts = await renderCard(); + it('shows a skeleton (not $0) when no signed-in user is resolved yet', async () => { + const { texts, unmount } = await mountCard(); + + expect(texts()).toContain('SKELETON'); + expect(texts()).not.toContain('ADD_CREDITS_ROW'); + expect(texts()).not.toContain('$0.00'); - expect(texts).toContain('SKELETON'); - expect(texts).not.toContain('ADD_CREDITS_ROW'); - expect(texts).not.toContain('$0.00'); + unmount(); }); - it('shows the balance when data is present', async () => { - balanceQuery.data = { balance: 1, creditBlocks: [] }; + it('shows the balance when data is cached for the signed-in user', async () => { + currentUser.userId = 'user-A'; + const queryClient = createTestQueryClient(); + queryClient.setQueryData([...BALANCE_KEY, 'user-A'], { balance: 1 }); + + const { texts, unmount } = await mountCard(queryClient); + + await waitFor(() => texts().includes('$1.00') && !texts().includes('SKELETON')); + expect(texts()).not.toContain('SKELETON'); + expect(texts()).toContain('$1.00'); + + unmount(); + }); + + it('never renders user A balance as current after switching to user B', async () => { + currentUser.userId = 'user-A'; + const queryClient = createTestQueryClient(); + queryClient.setQueryData([...BALANCE_KEY, 'user-A'], { balance: 10 }); + + // Hold user B's balance fetch until the test resolves it, so the skeleton + // state between the switch and the resolved fetch is observable. + let resolveB: ((value: { balance: number }) => void) | undefined = undefined; + getContextBalanceQueryFn.mockReturnValue( + new Promise<{ balance: number }>(resolve => { + resolveB = resolve; + }) + ); + + const { texts, rerender, unmount } = await mountCard(queryClient); + + // User A renders from cache. + await waitFor(() => texts().includes('$10.00')); + expect(texts()).toContain('$10.00'); + + // Switch the owner to user B, who has no cache. + currentUser.userId = 'user-B'; + await rerender(); + + // The placeholder gate must not reuse A's cache: show the skeleton, and A's + // dollars must never appear as the current amount. + expect(texts()).toContain('SKELETON'); + expect(texts()).not.toContain('$10.00'); + + // Resolve user B's balance. + await act(async () => { + resolveB?.({ balance: 25 }); + await Promise.resolve(); + }); + await waitFor(() => texts().includes('$25.00')); + + expect(texts()).toContain('$25.00'); + expect(texts()).not.toContain('$10.00'); + + unmount(); + }); + + it('shows the failed-to-load-balance copy when getMe errors, and retries both', async () => { + currentUser.isError = true; + + const { renderer, texts, unmount } = await mountCard(); + + expect(texts()).toContain('Failed to load balance. Tap to retry.'); + + const errorPressable = renderer.root.find(node => node.type === Pressable); + await act(async () => { + const onPress = errorPressable.props.onPress as () => void; + onPress(); + await Promise.resolve(); + }); - const texts = await renderCard(); + expect(refetchUserId).toHaveBeenCalledTimes(1); + expect(getContextBalanceQueryFn).toHaveBeenCalledTimes(1); - expect(texts).not.toContain('SKELETON'); - expect(texts).toContain('$1.00'); + unmount(); }); }); diff --git a/apps/mobile/src/components/profile-credits-card.tsx b/apps/mobile/src/components/profile-credits-card.tsx index 1c183b7451..6a01ad6d64 100644 --- a/apps/mobile/src/components/profile-credits-card.tsx +++ b/apps/mobile/src/components/profile-credits-card.tsx @@ -1,6 +1,7 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; import { fromMicrodollars } from '@kilocode/app-shared/utils'; -import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import { type TRPCQueryKey } from '@trpc/tanstack-react-query'; import { ChevronDown } from '@/components/ui/icons'; import { ActivityIndicator, Platform, Pressable, View } from 'react-native'; import { useTranslation } from 'react-i18next'; @@ -13,6 +14,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { WEB_BASE_URL } from '@/lib/config'; import { formatDate, formatMoney } from '@/lib/format'; +import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { isMoneyRole, type OrgListEntry } from '@/lib/hooks/use-organization-queries'; import { useOrganization } from '@/lib/organization-context'; @@ -24,6 +26,11 @@ type CreditsCardProps = { orgs: OrgListEntry[] | undefined; }; +function ownerScopedKey(key: TRPCQueryKey, userId: string | undefined): TRPCQueryKey { + const scoped: readonly unknown[] = [...key, userId ?? 'unsigned']; + return scoped as TRPCQueryKey; +} + export function CreditsCard({ enabled, orgs }: Readonly) { const trpc = useTRPC(); const colors = useThemeColors(); @@ -33,29 +40,56 @@ export function CreditsCard({ enabled, orgs }: Readonly) { const { organizationId, setOrganizationId } = useOrganization(); const selectedOrgId = organizationId ?? undefined; + const { userId, isError: userIdError, refetch: refetchUserId } = useCurrentUserId({ enabled }); + const hasUserId = userId !== undefined; + + const balanceOptions = trpc.user.getContextBalance.queryOptions({ + organizationId: selectedOrgId, + }); + const personalCreditOptions = trpc.user.getCreditBlocks.queryOptions({}); + const orgCreditOptions = trpc.organizations.getCreditBlocks.queryOptions({ + organizationId: selectedOrgId ?? '', + }); + + // Key every financial query by the signed-in owner. The last key element is + // the userId; the placeholder gate below compares against it so a user switch + // never reuses another owner's cached balance as the current amount. + const balanceQueryKey = ownerScopedKey(balanceOptions.queryKey, userId); + const personalQueryKey = ownerScopedKey(personalCreditOptions.queryKey, userId); + const orgQueryKey = ownerScopedKey(orgCreditOptions.queryKey, userId); + const { data: balance, isLoading: balanceLoading, isFetching: balanceFetching, - isError: balanceError, + isError: balanceQueryError, refetch: refetchBalance, } = useQuery({ - ...trpc.user.getContextBalance.queryOptions({ organizationId: selectedOrgId }), - enabled, - placeholderData: keepPreviousData, + ...balanceOptions, + queryKey: balanceQueryKey, + enabled: enabled && hasUserId, + placeholderData: (previousData, previousQuery) => + previousQuery?.queryKey.at(-1) === userId ? previousData : undefined, }); const { data: personalCreditData, isLoading: personalCreditsLoading } = useQuery({ - ...trpc.user.getCreditBlocks.queryOptions({}), - enabled: enabled && !selectedOrgId, + ...personalCreditOptions, + queryKey: personalQueryKey, + enabled: enabled && hasUserId && !selectedOrgId, }); const { data: orgCreditData, isLoading: orgCreditsLoading } = useQuery({ - ...trpc.organizations.getCreditBlocks.queryOptions({ organizationId: selectedOrgId ?? '' }), - enabled: enabled && Boolean(selectedOrgId), - placeholderData: keepPreviousData, + ...orgCreditOptions, + queryKey: orgQueryKey, + enabled: enabled && hasUserId && Boolean(selectedOrgId), + placeholderData: (previousData, previousQuery) => + previousQuery?.queryKey.at(-1) === userId ? previousData : undefined, }); + // A failed getMe (no userId) can never render a trusted balance, so it shares + // the balance error surface. Retry re-resolves the owner and re-fetches. + const balanceFailed = balanceQueryError || userIdError; + const creditData = selectedOrgId ? orgCreditData : personalCreditData; const creditsLoading = selectedOrgId ? orgCreditsLoading : personalCreditsLoading; @@ -64,7 +98,7 @@ export function CreditsCard({ enabled, orgs }: Readonly) { // not fetching, so `balanceLoading` (isLoading) is false while `balance` // is still undefined. Treat "no data yet" as loading so the card shows a // skeleton instead of `$0` on a cold launch before NetInfo settles. - const balancePending = balance === undefined && !balanceError; + const balancePending = balance === undefined && !balanceFailed; const expiringBlocks = creditData?.creditBlocks.filter(b => b.expiry_date !== null) ?? []; const expiringTotal = fromMicrodollars( expiringBlocks.reduce((sum, b) => sum + b.balance_mUsd, 0) @@ -146,15 +180,18 @@ export function CreditsCard({ enabled, orgs }: Readonly) { {(balanceLoading || balancePending) && } - {balanceError && ( + {balanceFailed && ( void refetchBalance()} + onPress={() => { + refetchUserId(); + void refetchBalance(); + }} > {t('profile.failedToLoadBalance')} )} - {!balanceLoading && !balancePending && !balanceError && ( + {!balanceLoading && !balancePending && !balanceFailed && ( {formatMoney(balanceDollars, i18n.language)} @@ -181,7 +218,7 @@ export function CreditsCard({ enabled, orgs }: Readonly) { )} {!balanceLoading && !balancePending && - !balanceError && + !balanceFailed && balanceDollars === 0 && canShowZeroBalanceCta && ( @@ -191,7 +228,7 @@ export function CreditsCard({ enabled, orgs }: Readonly) { IAP, and a non-money-role member just lacks access). */} {!balanceLoading && !balancePending && - !balanceError && + !balanceFailed && balanceDollars === 0 && !canShowZeroBalanceCta && selectedOrgId == null && ( diff --git a/apps/mobile/src/components/security-agent/finding-list-screen.tsx b/apps/mobile/src/components/security-agent/finding-list-screen.tsx index 6db0cd362e..ffbfd2bdf1 100644 --- a/apps/mobile/src/components/security-agent/finding-list-screen.tsx +++ b/apps/mobile/src/components/security-agent/finding-list-screen.tsx @@ -2,6 +2,7 @@ import { DEFAULT_SECURITY_FINDING_FILTERS, getSecurityRepositoriesInScope, hasActiveSecurityFindingFilters, + isPersonalSecurityScope, parseSecurityFindingFilters, type SecurityFindingRouteParams, toSecurityFindingQuery, @@ -27,6 +28,7 @@ import { useSecurityAnalysisCapacity, } from '@/lib/hooks/use-security-agent'; import { useSecurityFindings } from '@/lib/hooks/use-security-findings'; +import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh'; import { getSecurityAgentPath } from '@/lib/security-agent'; import { setSecurityFindingFilterBridge } from '@/lib/security-finding-filter-bridge'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -67,6 +69,12 @@ export function FindingListScreen({ scope, routeParams }: Readonly toSecurityFindingQuery(filters), [filters]); const findings = useSecurityFindings(scope, query); const capacity = useSecurityAnalysisCapacity(scope); + // An org scope stores its findings under the `organizations.securityAgent` + // tRPC prefix, so the personal prefix alone matches nothing and the screen + // never refreshes on focus. + useRouteForegroundRefresh( + isPersonalSecurityScope(scope) ? [[['securityAgent']]] : [[['organizations', 'securityAgent']]] + ); const slaEnabled = config.data?.slaEnabled ?? true; const hasAnalysisCapacity = diff --git a/apps/mobile/src/i18n/locales/af.json b/apps/mobile/src/i18n/locales/af.json index 541ffbed1b..b81dca4274 100644 --- a/apps/mobile/src/i18n/locales/af.json +++ b/apps/mobile/src/i18n/locales/af.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Span-hervulling-bonus-2025", "accounting_adjustment": "Rekeningkundige aanpassing", "credits_expired": "Krediete verval" - } + }, + "loadMore": "Laai meer", + "loadMoreFailed": "Kon nie meer laai nie", + "truncated": "Ouer kredietaktiwiteit is beskikbaar." }, "inviteMember": { "emailError": "Voer 'n geldige e-posadres in", @@ -2973,7 +2976,10 @@ "draft": "Konsep", "uncollectible": "Oninbaar", "unknown": "Onbekend" - } + }, + "loadMore": "Laai meer", + "loadMoreFailed": "Kon nie meer laai nie", + "truncated": "Ouer faktuur is beskikbaar." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/am.json b/apps/mobile/src/i18n/locales/am.json index 093c9e8e7c..587c4d318e 100644 --- a/apps/mobile/src/i18n/locales/am.json +++ b/apps/mobile/src/i18n/locales/am.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "የቡድን ሙሌት ጉርሻ-2025", "accounting_adjustment": "የሂሳብ ማስተካከያ", "credits_expired": "ክሬዲቶች ጊዜያቸው አልፏል" - } + }, + "loadMore": "ተጨማሪ ጫን", + "loadMoreFailed": "ተጨማሪ መጫን አልተቻለም", + "truncated": "የቆዩ የክሬዲት እንቅስቃሴዎች ይገኛሉ።" }, "inviteMember": { "emailError": "ትክክለኛ የኢሜይል አድራሻ ያስገቡ", @@ -2973,7 +2976,10 @@ "draft": "ረቂቅ", "uncollectible": "የማይሰበሰብ", "unknown": "ያልታወቀ" - } + }, + "loadMore": "ተጨማሪ ጫን", + "loadMoreFailed": "ተጨማሪ መጫን አልተቻለም", + "truncated": "የቆዩ ኢንቮይሶች ይገኛሉ።" }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/ar.json b/apps/mobile/src/i18n/locales/ar.json index b09d14b1a5..3ba7fa7a99 100644 --- a/apps/mobile/src/i18n/locales/ar.json +++ b/apps/mobile/src/i18n/locales/ar.json @@ -3018,7 +3018,10 @@ "team-topup-bonus-2025": "مكافأة شحن الفريق 2025", "accounting_adjustment": "تعديل محاسبي", "credits_expired": "انتهت صلاحية الأرصدة" - } + }, + "loadMore": "تحميل المزيد", + "loadMoreFailed": "تعذّر تحميل المزيد", + "truncated": "تتوفر نشاطات رصيد أقدم." }, "hub": { "balance": "الرصيد", @@ -3054,7 +3057,10 @@ "draft": "مسودة", "uncollectible": "غير قابل للتحصيل", "unknown": "غير معروف" - } + }, + "loadMore": "تحميل المزيد", + "loadMoreFailed": "تعذّر تحميل المزيد", + "truncated": "تتوفر فواتير أقدم." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/az.json b/apps/mobile/src/i18n/locales/az.json index e0e943c860..d9660b5017 100644 --- a/apps/mobile/src/i18n/locales/az.json +++ b/apps/mobile/src/i18n/locales/az.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Komanda doldurma bonusu-2025", "accounting_adjustment": "Mühasibat düzəlişi", "credits_expired": "Kreditlərin müddəti bitib" - } + }, + "loadMore": "Daha çox yüklə", + "loadMoreFailed": "Daha çox yüklənə bilmədi", + "truncated": "Köhnə kredit fəaliyyəti mövcuddur." }, "inviteMember": { "emailError": "Etibarlı e-poçt ünvanı daxil edin", @@ -2973,7 +2976,10 @@ "draft": "Qaralama", "uncollectible": "Yığıla bilməyən", "unknown": "Naməlum" - } + }, + "loadMore": "Daha çox yüklə", + "loadMoreFailed": "Daha çox yüklənə bilmədi", + "truncated": "Köhnə qaimələr mövcuddur." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/be.json b/apps/mobile/src/i18n/locales/be.json index 5a0efb8f1a..7cc099c503 100644 --- a/apps/mobile/src/i18n/locales/be.json +++ b/apps/mobile/src/i18n/locales/be.json @@ -2986,7 +2986,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Бухгалтарская карэкціроўка", "credits_expired": "Крэдыты скончыліся" - } + }, + "loadMore": "Загрузіць больш", + "loadMoreFailed": "Не атрымалася загрузіць больш", + "truncated": "Даступная больш ранняя актыўнасць па крэдыту." }, "inviteMember": { "emailError": "Увядзіце сапраўдны адрас электроннай пошты", @@ -3013,7 +3016,10 @@ "draft": "Чарнавік", "uncollectible": "Неспагнаны", "unknown": "Невядомы" - } + }, + "loadMore": "Загрузіць больш", + "loadMoreFailed": "Не атрымалася загрузіць больш", + "truncated": "Даступныя больш раннія рахункі." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/bg.json b/apps/mobile/src/i18n/locales/bg.json index 58b2b5e089..16d778f5b1 100644 --- a/apps/mobile/src/i18n/locales/bg.json +++ b/apps/mobile/src/i18n/locales/bg.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Счетоводна корекция", "credits_expired": "Изтекли кредити" - } + }, + "loadMore": "Зареди още", + "loadMoreFailed": "Неуспешно зареждане на още", + "truncated": "Налична е по-стара кредитна активност." }, "inviteMember": { "emailError": "Въведете валиден имейл адрес", @@ -2973,7 +2976,10 @@ "draft": "Чернова", "uncollectible": "Несъбираема", "unknown": "Неизвестна" - } + }, + "loadMore": "Зареди още", + "loadMoreFailed": "Неуспешно зареждане на още", + "truncated": "Налични са по-стари фактури." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/bn.json b/apps/mobile/src/i18n/locales/bn.json index 5c5565bf5f..55b544700e 100644 --- a/apps/mobile/src/i18n/locales/bn.json +++ b/apps/mobile/src/i18n/locales/bn.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "অ্যাকাউন্টিং সমন্বয়", "credits_expired": "ক্রেডিটের মেয়াদ শেষ" - } + }, + "loadMore": "আরও লোড করুন", + "loadMoreFailed": "আরও লোড করা যায়নি", + "truncated": "পুরোনো ক্রেডিট কার্যকলাপ উপলব্ধ।" }, "inviteMember": { "emailError": "একটি বৈধ ইমেইল ঠিকানা লিখুন", @@ -2973,7 +2976,10 @@ "draft": "খসড়া", "uncollectible": "আদায়যোগ্য নয়", "unknown": "অজানা" - } + }, + "loadMore": "আরও লোড করুন", + "loadMoreFailed": "আরও লোড করা যায়নি", + "truncated": "পুরোনো ইনভয়েস উপলব্ধ।" }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/bs.json b/apps/mobile/src/i18n/locales/bs.json index 373d693a3e..65e9ed8265 100644 --- a/apps/mobile/src/i18n/locales/bs.json +++ b/apps/mobile/src/i18n/locales/bs.json @@ -2966,7 +2966,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Računovodstvena korekcija", "credits_expired": "Istekli krediti" - } + }, + "loadMore": "Učitaj više", + "loadMoreFailed": "Više se nije moglo učitati", + "truncated": "Starija aktivnost kredita je dostupna." }, "inviteMember": { "emailError": "Unesite važeću email adresu", @@ -2993,7 +2996,10 @@ "draft": "Naert", "uncollectible": "Nenaplativo", "unknown": "Nepoznato" - } + }, + "loadMore": "Učitaj više", + "loadMoreFailed": "Više se nije moglo učitati", + "truncated": "Starije fakture su dostupne." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/ca.json b/apps/mobile/src/i18n/locales/ca.json index 40cb4f6d65..f79230b1f3 100644 --- a/apps/mobile/src/i18n/locales/ca.json +++ b/apps/mobile/src/i18n/locales/ca.json @@ -2966,7 +2966,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Ajust comptable", "credits_expired": "Crèdits caducats" - } + }, + "loadMore": "Carrega més", + "loadMoreFailed": "No s'ha pogut carregar més", + "truncated": "Hi ha activitat de crèdit més antiga disponible." }, "inviteMember": { "emailError": "Introduïu una adreça de correu vàlida", @@ -2993,7 +2996,10 @@ "draft": "Esborrany", "uncollectible": "No cobrable", "unknown": "Desconegut" - } + }, + "loadMore": "Carrega més", + "loadMoreFailed": "No s'ha pogut carregar més", + "truncated": "Hi ha factures més antigues disponibles." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/ckb.json b/apps/mobile/src/i18n/locales/ckb.json index fe97b32ce5..426df2521d 100644 --- a/apps/mobile/src/i18n/locales/ckb.json +++ b/apps/mobile/src/i18n/locales/ckb.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "بۆنەی زیادکردنی تیم 2025", "accounting_adjustment": "ڕێکخستنی هەژماری", "credits_expired": "بەسەرچوونی کرێدت" - } + }, + "loadMore": "بارکردنی زیاتر", + "loadMoreFailed": "نەتوانرا زیاتر بار بکرێت", + "truncated": "چالاکی بەرایی کۆنیتر بەردەستە." }, "inviteMember": { "emailError": "ناونیشانی ئیمەیڵی دروست بنووسە", @@ -2973,7 +2976,10 @@ "draft": "ڕەشنووس", "uncollectible": "نەکۆڵکراو", "unknown": "نەزانراو" - } + }, + "loadMore": "بارکردنی زیاتر", + "loadMoreFailed": "نەتوانرا زیاتر بار بکرێت", + "truncated": "فاکتورە کۆنەکان بەردەستن." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/cs.json b/apps/mobile/src/i18n/locales/cs.json index cc681a8ba9..f8f89835bb 100644 --- a/apps/mobile/src/i18n/locales/cs.json +++ b/apps/mobile/src/i18n/locales/cs.json @@ -2986,7 +2986,10 @@ "team-topup-bonus-2025": "Bonus za doplnění týmu 2025", "accounting_adjustment": "Účetní úprava", "credits_expired": "Kredity s prošlou platností" - } + }, + "loadMore": "Načíst více", + "loadMoreFailed": "Další se nepodařilo načíst", + "truncated": "Starší aktivita kreditů je k dispozici." }, "inviteMember": { "emailError": "Zadejte platnou e-mailovou adresu", @@ -3013,7 +3016,10 @@ "draft": "Návrh", "uncollectible": "Nedobytné", "unknown": "Neznámé" - } + }, + "loadMore": "Načíst více", + "loadMoreFailed": "Další se nepodařilo načíst", + "truncated": "Starší faktury jsou k dispozici." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/cy.json b/apps/mobile/src/i18n/locales/cy.json index a23353e012..1aa5cb9dc3 100644 --- a/apps/mobile/src/i18n/locales/cy.json +++ b/apps/mobile/src/i18n/locales/cy.json @@ -3026,7 +3026,10 @@ "team-topup-bonus-2025": "Bonws ail-lenwi tîm 2025", "accounting_adjustment": "Addasiad cyfrifyddu", "credits_expired": "Credydau wedi dod i ben" - } + }, + "loadMore": "Llwytho mwy", + "loadMoreFailed": "Ni ellid llwytho mwy", + "truncated": "Mae gweithgaredd credyd hŷn ar gael." }, "inviteMember": { "emailError": "Nodwch gyfeiriad e-bost dilys", @@ -3053,7 +3056,10 @@ "draft": "Drafft", "uncollectible": "Anghasgladwy", "unknown": "Anhysbys" - } + }, + "loadMore": "Llwytho mwy", + "loadMoreFailed": "Ni ellid llwytho mwy", + "truncated": "Mae anfonebau hŷn ar gael." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/da.json b/apps/mobile/src/i18n/locales/da.json index 1f85d21bc2..56d8e6b49e 100644 --- a/apps/mobile/src/i18n/locales/da.json +++ b/apps/mobile/src/i18n/locales/da.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Regnskabsmæssig justering", "credits_expired": "Udløbne kreditter" - } + }, + "loadMore": "Indlæs mere", + "loadMoreFailed": "Kunne ikke indlæse mere", + "truncated": "Ældre kreditaktivitet er tilgængelig." }, "inviteMember": { "emailError": "Angiv en gyldig e-mailadresse", @@ -2973,7 +2976,10 @@ "draft": "Kladde", "uncollectible": "Uinddrivelig", "unknown": "Ukendt" - } + }, + "loadMore": "Indlæs mere", + "loadMoreFailed": "Kunne ikke indlæse mere", + "truncated": "Ældre fakturaer er tilgængelige." }, "lowBalanceAlert": { "emailPlaceholder": "navn@virksomhed.dk", diff --git a/apps/mobile/src/i18n/locales/de.json b/apps/mobile/src/i18n/locales/de.json index e63470e1da..9e0e08e632 100644 --- a/apps/mobile/src/i18n/locales/de.json +++ b/apps/mobile/src/i18n/locales/de.json @@ -2938,7 +2938,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Buchhaltungsanpassung", "credits_expired": "Abgelaufene Credits" - } + }, + "loadMore": "Mehr laden", + "loadMoreFailed": "Mehr konnte nicht geladen werden", + "truncated": "Ältere Guthabenaktivität ist verfügbar." }, "hub": { "balance": "Guthaben", @@ -2974,7 +2977,10 @@ "draft": "Entwurf", "uncollectible": "Nicht einziehbar", "unknown": "Unbekannt" - } + }, + "loadMore": "Mehr laden", + "loadMoreFailed": "Mehr konnte nicht geladen werden", + "truncated": "Ältere Rechnungen sind verfügbar." }, "lowBalanceAlert": { "emailPlaceholder": "name@firma.de", diff --git a/apps/mobile/src/i18n/locales/el.json b/apps/mobile/src/i18n/locales/el.json index 4fe7b30e83..7836c9a7bc 100644 --- a/apps/mobile/src/i18n/locales/el.json +++ b/apps/mobile/src/i18n/locales/el.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Λογιστική προσαρμογή", "credits_expired": "Ληγμένες πιστώσεις" - } + }, + "loadMore": "Φόρτωση περισσότερων", + "loadMoreFailed": "Δεν ήταν δυνατή η φόρτωση περισσότερων", + "truncated": "Παλαιότερη δραστηριότητα πιστώσεων είναι διαθέσιμη." }, "inviteMember": { "emailError": "Εισαγάγετε μια έγκυρη διεύθυνση email", @@ -2973,7 +2976,10 @@ "draft": "Πρόχειρο", "uncollectible": "Μη εισπράξιμο", "unknown": "Άγνωστο" - } + }, + "loadMore": "Φόρτωση περισσότερων", + "loadMoreFailed": "Δεν ήταν δυνατή η φόρτωση περισσότερων", + "truncated": "Παλαιότερα τιμολόγια είναι διαθέσιμα." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index 8702fa473a..8b2fbed548 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -2935,8 +2935,11 @@ "emptyDescription": "Purchases, usage, and credit adjustments for this organization will appear here as they happen.", "emptyTitle": "No credit activity", "expires": "Expires {{date}}", + "loadMore": "Load more", + "loadMoreFailed": "Couldn't load more.", "title": "Credit activity", "transactionFallback": "Credit transaction", + "truncated": "Older credit activity is available.", "category": { "organization_custom": "Organization custom", "parent_to_child_transfer_in": "Parent to child transfer in", @@ -2963,6 +2966,9 @@ "emptyDescription": "Invoices are generated automatically each billing cycle and will appear here once your organization is billed.", "emptyTitle": "No invoices", "invoiceFallback": "Invoice", + "loadMore": "Load more", + "loadMoreFailed": "Couldn't load more.", + "truncated": "Older invoices are available.", "title": "Invoices", "downloadFailed": "Couldn't download invoice. Check your connection and try again.", "sharingUnavailable": "Sharing is not available on this device.", diff --git a/apps/mobile/src/i18n/locales/es.json b/apps/mobile/src/i18n/locales/es.json index 0880bfbba9..7ae1f1077e 100644 --- a/apps/mobile/src/i18n/locales/es.json +++ b/apps/mobile/src/i18n/locales/es.json @@ -2958,7 +2958,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Ajuste contable", "credits_expired": "Créditos caducados" - } + }, + "loadMore": "Cargar más", + "loadMoreFailed": "No se pudo cargar más", + "truncated": "Hay actividad de crédito más antigua disponible." }, "hub": { "balance": "Saldo", @@ -2994,7 +2997,10 @@ "draft": "Borrador", "uncollectible": "No cobrable", "unknown": "Desconocido" - } + }, + "loadMore": "Cargar más", + "loadMoreFailed": "No se pudo cargar más", + "truncated": "Hay facturas más antiguas disponibles." }, "lowBalanceAlert": { "emailPlaceholder": "nombre@empresa.com", diff --git a/apps/mobile/src/i18n/locales/et.json b/apps/mobile/src/i18n/locales/et.json index 8270383a3c..b649ab9d41 100644 --- a/apps/mobile/src/i18n/locales/et.json +++ b/apps/mobile/src/i18n/locales/et.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Meeskonna täiendusboonus 2025", "accounting_adjustment": "Raamatupidamiskorrigeerimine", "credits_expired": "Krediidid aegusid" - } + }, + "loadMore": "Laadi rohkem", + "loadMoreFailed": "Rohkemate laadimine ebaõnnestus", + "truncated": "Varasem krediidiajalugu on saadaval." }, "inviteMember": { "emailError": "Sisestage kehtiv e-posti aadress", @@ -2973,7 +2976,10 @@ "draft": "Mustand", "uncollectible": "Sissenõudmatu", "unknown": "Teadmata" - } + }, + "loadMore": "Laadi rohkem", + "loadMoreFailed": "Rohkemate laadimine ebaõnnestus", + "truncated": "Varasemad arved on saadaval." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/eu.json b/apps/mobile/src/i18n/locales/eu.json index e689706f94..461fd2270d 100644 --- a/apps/mobile/src/i18n/locales/eu.json +++ b/apps/mobile/src/i18n/locales/eu.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Taldearen topup-bonua 2025", "accounting_adjustment": "Kontabilitate-doikuntza", "credits_expired": "Kredituak iraungita" - } + }, + "loadMore": "Kargatu gehiago", + "loadMoreFailed": "Ezin izan dira gehiago kargatu", + "truncated": "Kreditu-jarduera zaharragoa eskuragarri dago." }, "inviteMember": { "emailError": "Sartu posta-helbide baliozko bat", @@ -2973,7 +2976,10 @@ "draft": "Zirriborroa", "uncollectible": "Kobratu ezina", "unknown": "Ezezaguna" - } + }, + "loadMore": "Kargatu gehiago", + "loadMoreFailed": "Ezin izan dira gehiago kargatu", + "truncated": "Faktura zaharrak eskuragarri daude." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/fa.json b/apps/mobile/src/i18n/locales/fa.json index 42f118d1d2..fa07cefe42 100644 --- a/apps/mobile/src/i18n/locales/fa.json +++ b/apps/mobile/src/i18n/locales/fa.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "پاداش شارژ تیم 2025", "accounting_adjustment": "تعدیل حسابداری", "credits_expired": "اعتبارها منقضی شدند" - } + }, + "loadMore": "بارگذاری بیشتر", + "loadMoreFailed": "بارگذاری بیشتر ممکن نشد", + "truncated": "فعالیت اعتبار قدیمیتر در دسترس است." }, "inviteMember": { "emailError": "یک آدرس ایمیل معتبر وارد کنید", @@ -2973,7 +2976,10 @@ "draft": "پیش‌نویس", "uncollectible": "وصول‌نشدنی", "unknown": "ناشناخته" - } + }, + "loadMore": "بارگذاری بیشتر", + "loadMoreFailed": "بارگذاری بیشتر ممکن نشد", + "truncated": "فاکتورهای قدیمیتر در دسترس هستند." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/fi.json b/apps/mobile/src/i18n/locales/fi.json index 0ed1179821..45e0a6edb4 100644 --- a/apps/mobile/src/i18n/locales/fi.json +++ b/apps/mobile/src/i18n/locales/fi.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Tiimin topup-bonus 2025", "accounting_adjustment": "Kirjanpidon oikaisu", "credits_expired": "Krediitit vanhentuivat" - } + }, + "loadMore": "Lataa lisää", + "loadMoreFailed": "Lisää ei voitu ladata", + "truncated": "Vanhempaa luottotapahtumahistoriaa on saatavilla." }, "inviteMember": { "emailError": "Syötä kelvollinen sähköpostiosoite", @@ -2973,7 +2976,10 @@ "draft": "Luonnos", "uncollectible": "Perimiskelvoton", "unknown": "Tuntematon" - } + }, + "loadMore": "Lataa lisää", + "loadMoreFailed": "Lisää ei voitu ladata", + "truncated": "Vanhempia laskuja on saatavilla." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/fil.json b/apps/mobile/src/i18n/locales/fil.json index 2c3da74919..1edc8376cf 100644 --- a/apps/mobile/src/i18n/locales/fil.json +++ b/apps/mobile/src/i18n/locales/fil.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Team top-up bonus 2025", "accounting_adjustment": "Pagsasaayos ng accounting", "credits_expired": "Nag-expire na credits" - } + }, + "loadMore": "I-load pa", + "loadMoreFailed": "Hindi ma-load ang iba pa", + "truncated": "Magagamit ang mas lumang aktibidad ng kredito." }, "inviteMember": { "emailError": "Maglagay ng wastong email address", @@ -2973,7 +2976,10 @@ "draft": "Draft", "uncollectible": "Hindi makokolekta", "unknown": "Hindi alam" - } + }, + "loadMore": "I-load pa", + "loadMoreFailed": "Hindi ma-load ang iba pa", + "truncated": "Magagamit ang mas lumang mga invoice." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/fr.json b/apps/mobile/src/i18n/locales/fr.json index 78615111fd..91cce0a56e 100644 --- a/apps/mobile/src/i18n/locales/fr.json +++ b/apps/mobile/src/i18n/locales/fr.json @@ -2958,7 +2958,10 @@ "team-topup-bonus-2025": "Bonus de rechargement d'équipe 2025", "accounting_adjustment": "Ajustement comptable", "credits_expired": "Crédits expirés" - } + }, + "loadMore": "Charger plus", + "loadMoreFailed": "Impossible de charger plus", + "truncated": "L'activité de crédit plus ancienne est disponible." }, "hub": { "balance": "Solde", @@ -2994,7 +2997,10 @@ "draft": "Brouillon", "uncollectible": "Irrecouvrable", "unknown": "Inconnu" - } + }, + "loadMore": "Charger plus", + "loadMoreFailed": "Impossible de charger plus", + "truncated": "Les factures plus anciennes sont disponibles." }, "lowBalanceAlert": { "emailPlaceholder": "nom@entreprise.com", diff --git a/apps/mobile/src/i18n/locales/ga.json b/apps/mobile/src/i18n/locales/ga.json index ea1d8a446b..7097b89ed7 100644 --- a/apps/mobile/src/i18n/locales/ga.json +++ b/apps/mobile/src/i18n/locales/ga.json @@ -3006,7 +3006,10 @@ "team-topup-bonus-2025": "Bónas forlíonta foirne 2025", "accounting_adjustment": "Coigeartú cuntasaíochta", "credits_expired": "Creidmheasanna imithe in éag" - } + }, + "loadMore": "Lódáil tuilleadh", + "loadMoreFailed": "Níorbh fhéidir tuilleadh a lódáil", + "truncated": "Tá gníomhaíocht chreidmheasa níos sine ar fáil." }, "inviteMember": { "emailError": "Iontráil seoladh ríomhphoist bailí", @@ -3033,7 +3036,10 @@ "draft": "Dréacht", "uncollectible": "Dobhailithe", "unknown": "Anaithnid" - } + }, + "loadMore": "Lódáil tuilleadh", + "loadMoreFailed": "Níorbh fhéidir tuilleadh a lódáil", + "truncated": "Tá sonraisc níos sine ar fáil." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/gl.json b/apps/mobile/src/i18n/locales/gl.json index a3d97b2824..b92a82ce77 100644 --- a/apps/mobile/src/i18n/locales/gl.json +++ b/apps/mobile/src/i18n/locales/gl.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Bono de recarga de equipo 2025", "accounting_adjustment": "Axuste contable", "credits_expired": "Créditos caducados" - } + }, + "loadMore": "Cargar máis", + "loadMoreFailed": "Non se puideron cargar máis", + "truncated": "Hai dispoñible actividade de crédito máis antiga." }, "inviteMember": { "emailError": "Introduce un enderezo de correo válido", @@ -2973,7 +2976,10 @@ "draft": "Borrador", "uncollectible": "Incobrable", "unknown": "Descoñecido" - } + }, + "loadMore": "Cargar máis", + "loadMoreFailed": "Non se puideron cargar máis", + "truncated": "Hai facturas máis antigas dispoñibles." }, "lowBalanceAlert": { "emailPlaceholder": "nome@empresa.com", diff --git a/apps/mobile/src/i18n/locales/gu.json b/apps/mobile/src/i18n/locales/gu.json index 5efb13f716..16f1278d45 100644 --- a/apps/mobile/src/i18n/locales/gu.json +++ b/apps/mobile/src/i18n/locales/gu.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "ટીમ ટોપઅપ બોનસ 2025", "accounting_adjustment": "એકાઉન્ટિંગ ગોઠવણ", "credits_expired": "ક્રેડિટ સમાપ્ત થઈ" - } + }, + "loadMore": "વધુ લોડ કરો", + "loadMoreFailed": "વધુ લોડ કરી શકાયું નહીં", + "truncated": "જૂની ક્રેડિટ પ્રવૃત્તિ ઉપલબ્ધ છે." }, "inviteMember": { "emailError": "માન્ય ઇમેઇલ સરનામું દાખલ કરો", @@ -2973,7 +2976,10 @@ "draft": "ડ્રાફ્ટ", "uncollectible": "વસૂલ કરી ન શકાય તેવું", "unknown": "અજ્ઞાત" - } + }, + "loadMore": "વધુ લોડ કરો", + "loadMoreFailed": "વધુ લોડ કરી શકાયું નહીં", + "truncated": "જૂના ઇન્વૉઇસ ઉપલબ્ધ છે." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/ha.json b/apps/mobile/src/i18n/locales/ha.json index d80f5c750e..0e503fd8b1 100644 --- a/apps/mobile/src/i18n/locales/ha.json +++ b/apps/mobile/src/i18n/locales/ha.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Kyautar Topup na Ƙungiya 2025", "accounting_adjustment": "Daidaitawar lissafin kuɗi", "credits_expired": "Credits sun ƙare" - } + }, + "loadMore": "Ɗauki ƙari", + "loadMoreFailed": "Ba a iya ƙara ɗora ba", + "truncated": "Ayyukan bashi na baya suna nan." }, "inviteMember": { "emailError": "Shigar da ingantacciyar adireshin imel", @@ -2973,7 +2976,10 @@ "draft": "Daftari", "uncollectible": "Ba za a iya karɓa ba", "unknown": "Ba a sani ba" - } + }, + "loadMore": "Ɗauki ƙari", + "loadMoreFailed": "Ba a iya ƙara ɗora ba", + "truncated": "Lissafin kuɗi na baya suna nan." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/he.json b/apps/mobile/src/i18n/locales/he.json index 51ce3ed511..3488fd19e3 100644 --- a/apps/mobile/src/i18n/locales/he.json +++ b/apps/mobile/src/i18n/locales/he.json @@ -2958,7 +2958,10 @@ "team-topup-bonus-2025": "בונוס טופ-אפ של צוות 2025", "accounting_adjustment": "התאמה חשבונאית", "credits_expired": "הקרדיטים פגו" - } + }, + "loadMore": "טען עוד", + "loadMoreFailed": "לא ניתן היה לטעון עוד", + "truncated": "קיימת פעילות אשראי ישנה יותר." }, "hub": { "balance": "יתרה", @@ -2994,7 +2997,10 @@ "draft": "טיוטה", "uncollectible": "לא ניתן לגביה", "unknown": "לא ידוע" - } + }, + "loadMore": "טען עוד", + "loadMoreFailed": "לא ניתן היה לטעון עוד", + "truncated": "קיימות חשבוניות ישנות יותר." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/hi.json b/apps/mobile/src/i18n/locales/hi.json index ddba5b4267..d9cf672845 100644 --- a/apps/mobile/src/i18n/locales/hi.json +++ b/apps/mobile/src/i18n/locales/hi.json @@ -2938,7 +2938,10 @@ "team-topup-bonus-2025": "टीम टॉप-अप बोनस 2025", "accounting_adjustment": "लेखा समायोजन", "credits_expired": "क्रेडिट समाप्त हुए" - } + }, + "loadMore": "और लोड करें", + "loadMoreFailed": "और लोड नहीं हो सका", + "truncated": "पुरानी क्रेडिट गतिविधि उपलब्ध है।" }, "hub": { "balance": "बैलेंस", @@ -2974,7 +2977,10 @@ "draft": "ड्राफ़्ट", "uncollectible": "वसूली योग्य नहीं", "unknown": "अज्ञात" - } + }, + "loadMore": "और लोड करें", + "loadMoreFailed": "और लोड नहीं हो सका", + "truncated": "पुराने चालान उपलब्ध हैं।" }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/hr.json b/apps/mobile/src/i18n/locales/hr.json index 301c8742c9..442816a5b2 100644 --- a/apps/mobile/src/i18n/locales/hr.json +++ b/apps/mobile/src/i18n/locales/hr.json @@ -2966,7 +2966,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Računovodstvena prilagodba", "credits_expired": "Istekli krediti" - } + }, + "loadMore": "Učitaj više", + "loadMoreFailed": "Nije moguće učitati više", + "truncated": "Dostupna je starija aktivnost kredita." }, "inviteMember": { "emailError": "Unesite valjanu adresu e-pošte", @@ -2993,7 +2996,10 @@ "draft": "Skica", "uncollectible": "Nenaplativo", "unknown": "Nepoznato" - } + }, + "loadMore": "Učitaj više", + "loadMoreFailed": "Nije moguće učitati više", + "truncated": "Dostupne su starije fakture." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/ht.json b/apps/mobile/src/i18n/locales/ht.json index 7a6716ca90..9c062db6fe 100644 --- a/apps/mobile/src/i18n/locales/ht.json +++ b/apps/mobile/src/i18n/locales/ht.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Ajisteman kontab", "credits_expired": "Kredi ekspire" - } + }, + "loadMore": "Chaje plis", + "loadMoreFailed": "Pa t ka chaje plis", + "truncated": "Gen plis aktivite kredi ki pi ansyen disponib." }, "inviteMember": { "emailError": "Antre yon adrès imel valid", @@ -2973,7 +2976,10 @@ "draft": "Brouyon", "uncollectible": "Enkobrèb", "unknown": "Enkoni" - } + }, + "loadMore": "Chaje plis", + "loadMoreFailed": "Pa t ka chaje plis", + "truncated": "Gen plis fakti ki pi ansyen disponib." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/hu.json b/apps/mobile/src/i18n/locales/hu.json index 4d00e0b843..50b0aaea2f 100644 --- a/apps/mobile/src/i18n/locales/hu.json +++ b/apps/mobile/src/i18n/locales/hu.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Számviteli kiigazítás", "credits_expired": "Lejárt kreditek" - } + }, + "loadMore": "További betöltése", + "loadMoreFailed": "Nem sikerült betölteni továbbiakat", + "truncated": "Korábbi hitelaktivitás érhető el." }, "inviteMember": { "emailError": "Adj meg érvényes e-mail címet", @@ -2973,7 +2976,10 @@ "draft": "Piszkozat", "uncollectible": "Behajthatatlan", "unknown": "Ismeretlen" - } + }, + "loadMore": "További betöltése", + "loadMoreFailed": "Nem sikerült betölteni továbbiakat", + "truncated": "Korábbi számlák érhetők el." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/hy.json b/apps/mobile/src/i18n/locales/hy.json index 983321392c..c1b8dd41bb 100644 --- a/apps/mobile/src/i18n/locales/hy.json +++ b/apps/mobile/src/i18n/locales/hy.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Հաշվապահական ճշգրտում", "credits_expired": "Սպառված կրեդիտներ" - } + }, + "loadMore": "Բեռնել ավելին", + "loadMoreFailed": "Չհաջողվեց բեռնել ավելին", + "truncated": "Հին վարկային գործունեությունը հասանելի է։" }, "inviteMember": { "emailError": "Մուտքագրեք վավեր էլփոստի հասցե", @@ -2973,7 +2976,10 @@ "draft": "Սևագիր", "uncollectible": "Անվերականգնելի", "unknown": "Անհայտ" - } + }, + "loadMore": "Բեռնել ավելին", + "loadMoreFailed": "Չհաջողվեց բեռնել ավելին", + "truncated": "Հին հաշիվ-ապրանքագրերը հասանելի են։" }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/id.json b/apps/mobile/src/i18n/locales/id.json index e5dc4378c8..ce61bc54a6 100644 --- a/apps/mobile/src/i18n/locales/id.json +++ b/apps/mobile/src/i18n/locales/id.json @@ -2938,7 +2938,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Penyesuaian akuntansi", "credits_expired": "Kredit kedaluwarsa" - } + }, + "loadMore": "Muat lebih banyak", + "loadMoreFailed": "Tidak dapat memuat lebih banyak", + "truncated": "Aktivitas kredit yang lebih lama tersedia." }, "hub": { "balance": "Saldo", @@ -2974,7 +2977,10 @@ "draft": "Draf", "uncollectible": "Tidak tertagih", "unknown": "Tidak diketahui" - } + }, + "loadMore": "Muat lebih banyak", + "loadMoreFailed": "Tidak dapat memuat lebih banyak", + "truncated": "Faktur yang lebih lama tersedia." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/ig.json b/apps/mobile/src/i18n/locales/ig.json index 915a1262d9..755293353f 100644 --- a/apps/mobile/src/i18n/locales/ig.json +++ b/apps/mobile/src/i18n/locales/ig.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Mmezi ndekọ ego", "credits_expired": "Kredit emebiwo" - } + }, + "loadMore": "Bugo ọzọ", + "loadMoreFailed": "Enweghị ike ibugo ọzọ", + "truncated": "Ọrụ kredit ochie dị." }, "inviteMember": { "emailError": "Tinye adres email ziri ezi", @@ -2973,7 +2976,10 @@ "draft": "Ihe odide", "uncollectible": "Enweghị ike ịnakọta", "unknown": "Amaghị" - } + }, + "loadMore": "Bugo ọzọ", + "loadMoreFailed": "Enweghị ike ibugo ọzọ", + "truncated": "Akwụkwọ ọnụahịa ochie dị." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/is.json b/apps/mobile/src/i18n/locales/is.json index 141f612cb9..a5a228fb60 100644 --- a/apps/mobile/src/i18n/locales/is.json +++ b/apps/mobile/src/i18n/locales/is.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Hópáfyllingarbónus 2025", "accounting_adjustment": "Bókhaldsleiðrétting", "credits_expired": "Kredítur runnu út" - } + }, + "loadMore": "Hlaða meira", + "loadMoreFailed": "Ekki tókst að hlaða meira", + "truncated": "Eldri kreditfærsla er í boði." }, "inviteMember": { "emailError": "Sláðu inn gilt tölvupóstfang", @@ -2973,7 +2976,10 @@ "draft": "Drög", "uncollectible": "Óinnheimtanlegt", "unknown": "Óþekkt" - } + }, + "loadMore": "Hlaða meira", + "loadMoreFailed": "Ekki tókst að hlaða meira", + "truncated": "Eldri reikningar eru í boði." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/it.json b/apps/mobile/src/i18n/locales/it.json index dda6bc362f..d5a9990b82 100644 --- a/apps/mobile/src/i18n/locales/it.json +++ b/apps/mobile/src/i18n/locales/it.json @@ -2958,7 +2958,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Rettifica contabile", "credits_expired": "Crediti scaduti" - } + }, + "loadMore": "Carica altri", + "loadMoreFailed": "Impossibile caricare altri elementi", + "truncated": "Sono disponibili attività di credito più vecchie." }, "hub": { "balance": "Saldo", @@ -2994,7 +2997,10 @@ "draft": "Bozza", "uncollectible": "Non riscuotibile", "unknown": "Sconosciuto" - } + }, + "loadMore": "Carica altri", + "loadMoreFailed": "Impossibile caricare altri elementi", + "truncated": "Sono disponibili fatture più vecchie." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/ja.json b/apps/mobile/src/i18n/locales/ja.json index 434ba97384..d2d906bda8 100644 --- a/apps/mobile/src/i18n/locales/ja.json +++ b/apps/mobile/src/i18n/locales/ja.json @@ -2938,7 +2938,10 @@ "team-topup-bonus-2025": "チームチャージボーナス2025", "accounting_adjustment": "会計調整", "credits_expired": "クレジット失効" - } + }, + "loadMore": "さらに読み込む", + "loadMoreFailed": "これ以上読み込めませんでした", + "truncated": "以前のクレジット利用状況が利用可能です。" }, "hub": { "balance": "残高", @@ -2974,7 +2977,10 @@ "draft": "下書き", "uncollectible": "回収不能", "unknown": "不明" - } + }, + "loadMore": "さらに読み込む", + "loadMoreFailed": "これ以上読み込めませんでした", + "truncated": "以前の請求書が利用可能です。" }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/ka.json b/apps/mobile/src/i18n/locales/ka.json index 3fa310a1c1..b5c96debed 100644 --- a/apps/mobile/src/i18n/locales/ka.json +++ b/apps/mobile/src/i18n/locales/ka.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "გუნდის შევსების ბონუსი 2025", "accounting_adjustment": "ბუღალტრული კორექტირება", "credits_expired": "ვადაგასული კრედიტები" - } + }, + "loadMore": "მეტის ჩატვირთვა", + "loadMoreFailed": "მეტის ჩატვირთვა ვერ მოხერხდა", + "truncated": "უფრო ძველი კრედიტის აქტივობა ხელმისაწვდომია." }, "inviteMember": { "emailError": "შეიყვანეთ მოქმედი ელფოსტის მისამართი", @@ -2973,7 +2976,10 @@ "draft": "მონახაზი", "uncollectible": "ამოუღებელი", "unknown": "უცნობი" - } + }, + "loadMore": "მეტის ჩატვირთვა", + "loadMoreFailed": "მეტის ჩატვირთვა ვერ მოხერხდა", + "truncated": "უფრო ძველი ინვოისები ხელმისაწვდომია." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/kk.json b/apps/mobile/src/i18n/locales/kk.json index 77f7c1528b..2b924b9ede 100644 --- a/apps/mobile/src/i18n/locales/kk.json +++ b/apps/mobile/src/i18n/locales/kk.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Топты толтыру бонусы 2025", "accounting_adjustment": "Есептік түзету", "credits_expired": "Кредиттердің мерзімі аяқталды" - } + }, + "loadMore": "Көбірек жүктеу", + "loadMoreFailed": "Қосымша жүктелмеді", + "truncated": "Ескі несие белсенділігі қолжетімді." }, "inviteMember": { "emailError": "Жарамды электрондық пошта мекенжайын енгізіңіз", @@ -2973,7 +2976,10 @@ "draft": "Жоба", "uncollectible": "Өндіріп алу мүмкін емес", "unknown": "Белгісіз" - } + }, + "loadMore": "Көбірек жүктеу", + "loadMoreFailed": "Қосымша жүктелмеді", + "truncated": "Ескі шот-фактуралар қолжетімді." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/km.json b/apps/mobile/src/i18n/locales/km.json index d396036827..b361f53bdc 100644 --- a/apps/mobile/src/i18n/locales/km.json +++ b/apps/mobile/src/i18n/locales/km.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "ប្រាក់រង្វាន់បន្ថែមក្រុម 2025", "accounting_adjustment": "ការកែតម្រូវគណនេយ្យ", "credits_expired": "ឥណទានផុតកំណត់" - } + }, + "loadMore": "ផ្ទុកបន្ថែម", + "loadMoreFailed": "មិនអាចផ្ទុកបន្ថែមបានទេ", + "truncated": "សកម្មភាពឥណទានចាស់ៗអាចប្រើបាន។" }, "inviteMember": { "emailError": "បញ្ចូលអាសយដ្ឋានអ៊ីមែលត្រឹមត្រូវ", @@ -2973,7 +2976,10 @@ "draft": "សេចក្តីព្រាង", "uncollectible": "មិនអាចទារបាន", "unknown": "មិនស្គាល់" - } + }, + "loadMore": "ផ្ទុកបន្ថែម", + "loadMoreFailed": "មិនអាចផ្ទុកបន្ថែមបានទេ", + "truncated": "វិក្កយបត្រចាស់ៗអាចប្រើបាន។" }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/kn.json b/apps/mobile/src/i18n/locales/kn.json index 502e28d563..e18b659d0c 100644 --- a/apps/mobile/src/i18n/locales/kn.json +++ b/apps/mobile/src/i18n/locales/kn.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "ತಂಡದ ಟಾಪ್-ಅಪ್ ಬೋನಸ್ 2025", "accounting_adjustment": "ಲೆಕ್ಕಪತ್ರ ಹೊಂದಾಣಿಕೆ", "credits_expired": "ಕ್ರೆಡಿಟ್‌ಗಳು ಅವಧಿ ಮುಗಿದವು" - } + }, + "loadMore": "ಹೆಚ್ಚು ಲೋಡ್ ಮಾಡಿ", + "loadMoreFailed": "ಹೆಚ್ಚು ಲೋಡ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ", + "truncated": "ಹಳೆಯ ಕ್ರೆಡಿಟ್ ಚಟುವಟಿಕೆ ಲಭ್ಯವಿದೆ." }, "inviteMember": { "emailError": "ಮಾನ್ಯ ಇಮೇಲ್ ವಿಳಾಸವನ್ನು ನಮೂದಿಸಿ", @@ -2973,7 +2976,10 @@ "draft": "ಡ್ರಾಫ್ಟ್", "uncollectible": "ವಸೂಲಿ ಮಾಡಲಾಗದ", "unknown": "ತಿಳಿದಿಲ್ಲ" - } + }, + "loadMore": "ಹೆಚ್ಚು ಲೋಡ್ ಮಾಡಿ", + "loadMoreFailed": "ಹೆಚ್ಚು ಲೋಡ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ", + "truncated": "ಹಳೆಯ ಇನ್ವಾಯ್ಸ್ಗಳು ಲಭ್ಯವಿದೆ." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/ko.json b/apps/mobile/src/i18n/locales/ko.json index c9f9efcb37..9963d26934 100644 --- a/apps/mobile/src/i18n/locales/ko.json +++ b/apps/mobile/src/i18n/locales/ko.json @@ -2938,7 +2938,10 @@ "team-topup-bonus-2025": "팀 충전 보너스 2025", "accounting_adjustment": "회계 조정", "credits_expired": "크레딧 만료" - } + }, + "loadMore": "더 불러오기", + "loadMoreFailed": "더 불러올 수 없습니다", + "truncated": "이전 크레딧 활동을 확인할 수 있습니다." }, "hub": { "balance": "잔액", @@ -2974,7 +2977,10 @@ "draft": "초안", "uncollectible": "회수 불가", "unknown": "알 수 없음" - } + }, + "loadMore": "더 불러오기", + "loadMoreFailed": "더 불러올 수 없습니다", + "truncated": "이전 인보이스를 확인할 수 있습니다." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/lo.json b/apps/mobile/src/i18n/locales/lo.json index 708a158d4c..be020c31a4 100644 --- a/apps/mobile/src/i18n/locales/lo.json +++ b/apps/mobile/src/i18n/locales/lo.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "ໂບນັດເຕີມເງິນທີມ 2025", "accounting_adjustment": "ການປັບບັນຊີ", "credits_expired": "ເຄຣດິດໝົດອາຍຸ" - } + }, + "loadMore": "ໂຫຼດເພີ່ມເຕີມ", + "loadMoreFailed": "ບໍ່ສາມາດໂຫຼດເພີ່ມເຕີມໄດ້", + "truncated": "ກິດຈະກຳສິນເຊື່ອເກົ່າກວ່າມີໃຫ້." }, "inviteMember": { "emailError": "ໃສ່ທີ່ຢູ່ອີເມວທີ່ຖືກຕ້ອງ", @@ -2973,7 +2976,10 @@ "draft": "ຮ່າງ", "uncollectible": "ເກັບບໍ່ໄດ້", "unknown": "ບໍ່ຮູ້" - } + }, + "loadMore": "ໂຫຼດເພີ່ມເຕີມ", + "loadMoreFailed": "ບໍ່ສາມາດໂຫຼດເພີ່ມເຕີມໄດ້", + "truncated": "ໃບແຈ້ງໜີ້ເກົ່າກວ່າມີໃຫ້." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/lt.json b/apps/mobile/src/i18n/locales/lt.json index b9959801e1..319173b801 100644 --- a/apps/mobile/src/i18n/locales/lt.json +++ b/apps/mobile/src/i18n/locales/lt.json @@ -2986,7 +2986,10 @@ "team-topup-bonus-2025": "Komandos papildymo premija 2025", "accounting_adjustment": "Apskaitos koregavimas", "credits_expired": "Kreditai pasibaigė" - } + }, + "loadMore": "Įkelti daugiau", + "loadMoreFailed": "Nepavyko įkelti daugiau", + "truncated": "Yra ankstesnės kredito veiklos." }, "inviteMember": { "emailError": "Įveskite galiojantį el. pašto adresą", @@ -3013,7 +3016,10 @@ "draft": "Juodraštis", "uncollectible": "Neišieškoma", "unknown": "Nežinomas" - } + }, + "loadMore": "Įkelti daugiau", + "loadMoreFailed": "Nepavyko įkelti daugiau", + "truncated": "Yra ankstesnių sąskaitų faktūrų." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/lv.json b/apps/mobile/src/i18n/locales/lv.json index 46834401ba..58aa26eaba 100644 --- a/apps/mobile/src/i18n/locales/lv.json +++ b/apps/mobile/src/i18n/locales/lv.json @@ -2966,7 +2966,10 @@ "team-topup-bonus-2025": "Komandas papildinājuma bonuss 2025", "accounting_adjustment": "Grāmatvedības korekcija", "credits_expired": "Kredīti beigušies" - } + }, + "loadMore": "Ielādēt vairāk", + "loadMoreFailed": "Neizdevās ielādēt vairāk", + "truncated": "Ir pieejama vecāka kredīta aktivitāte." }, "inviteMember": { "emailError": "Ievadi derīgu e-pasta adresi", @@ -2993,7 +2996,10 @@ "draft": "Melnraksts", "uncollectible": "Nepiedzenams", "unknown": "Nezināms" - } + }, + "loadMore": "Ielādēt vairāk", + "loadMoreFailed": "Neizdevās ielādēt vairāk", + "truncated": "Ir pieejami vecāki rēķini." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/mg.json b/apps/mobile/src/i18n/locales/mg.json index a72a7be9bd..f3a36ebbee 100644 --- a/apps/mobile/src/i18n/locales/mg.json +++ b/apps/mobile/src/i18n/locales/mg.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Bonus top-up ekipa 2025", "accounting_adjustment": "Fanitsiana kaonty", "credits_expired": "Lany daty ny credits" - } + }, + "loadMore": "Hamaky bebe kokoa", + "loadMoreFailed": "Tsy afaka namaky fanampiny", + "truncated": "Misy ny fiasana crédit taloha kokoa." }, "inviteMember": { "emailError": "Ampidiro adiresy email sahaza", @@ -2973,7 +2976,10 @@ "draft": "Draft", "uncollectible": "Tsy azo angonina", "unknown": "Tsy fantatra" - } + }, + "loadMore": "Hamaky bebe kokoa", + "loadMoreFailed": "Tsy afaka namaky fanampiny", + "truncated": "Misy ny faktiora taloha kokoa." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/mi.json b/apps/mobile/src/i18n/locales/mi.json index 46a8c37477..f2583b2595 100644 --- a/apps/mobile/src/i18n/locales/mi.json +++ b/apps/mobile/src/i18n/locales/mi.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Te top-up bonus rōpū 2025", "accounting_adjustment": "Te whakatikatika kaute", "credits_expired": "Kua pau ngā whiwhinga" - } + }, + "loadMore": "Uta anō", + "loadMoreFailed": "Kāore i taea te uta ētahi atu", + "truncated": "E wātea ana ngā mahi kirimana tawhito." }, "inviteMember": { "emailError": "Whakaurua he wāhitau īmēra tika", @@ -2973,7 +2976,10 @@ "draft": "Hukitanga", "uncollectible": "Kāore e taea te kohi", "unknown": "Kāore e mōhiotia" - } + }, + "loadMore": "Uta anō", + "loadMoreFailed": "Kāore i taea te uta ētahi atu", + "truncated": "E wātea ana ngā nama tawhito." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/mk.json b/apps/mobile/src/i18n/locales/mk.json index 40f1c07dd9..c48f305a2f 100644 --- a/apps/mobile/src/i18n/locales/mk.json +++ b/apps/mobile/src/i18n/locales/mk.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Тимски top-up бонус 2025", "accounting_adjustment": "Сметководствена корекција", "credits_expired": "Истечени кредити" - } + }, + "loadMore": "Вчитај повеќе", + "loadMoreFailed": "Не можеше да се вчитаат повеќе", + "truncated": "Постара активност на кредит е достапна." }, "inviteMember": { "emailError": "Внесете валидна е-пошта адреса", @@ -2973,7 +2976,10 @@ "draft": "Нацрт", "uncollectible": "Ненаплатливо", "unknown": "Непознато" - } + }, + "loadMore": "Вчитај повеќе", + "loadMoreFailed": "Не можеше да се вчитаат повеќе", + "truncated": "Постари фактури се достапни." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/ml.json b/apps/mobile/src/i18n/locales/ml.json index 537a6e7d4d..032a68ab76 100644 --- a/apps/mobile/src/i18n/locales/ml.json +++ b/apps/mobile/src/i18n/locales/ml.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "ടീം-ടോപ്പ്-അപ്പ്-ബോണസ്-2025", "accounting_adjustment": "അക്കൗണ്ടിംഗ് ക്രമീകരണം", "credits_expired": "ക്രെഡിറ്റുകൾ കാലഹരണപ്പെട്ടു" - } + }, + "loadMore": "കൂടുതൽ ലോഡ് ചെയ്യുക", + "loadMoreFailed": "കൂടുതൽ ലോഡ് ചെയ്യാൻ കഴിഞ്ഞില്ല", + "truncated": "പഴയ ക്രെഡിറ്റ് പ്രവർത്തനങ്ങൾ ലഭ്യമാണ്." }, "inviteMember": { "emailError": "സാധുവായ ഒരു ഇമെയിൽ വിലാസം നൽകുക", @@ -2973,7 +2976,10 @@ "draft": "ഡ്രാഫ്റ്റ്", "uncollectible": "ഈടാക്കാനാവാത്തത്", "unknown": "അജ്ഞാതം" - } + }, + "loadMore": "കൂടുതൽ ലോഡ് ചെയ്യുക", + "loadMoreFailed": "കൂടുതൽ ലോഡ് ചെയ്യാൻ കഴിഞ്ഞില്ല", + "truncated": "പഴയ ഇൻവോയ്സുകൾ ലഭ്യമാണ്." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/mn.json b/apps/mobile/src/i18n/locales/mn.json index 9e95aa562d..5598acd28f 100644 --- a/apps/mobile/src/i18n/locales/mn.json +++ b/apps/mobile/src/i18n/locales/mn.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Багийн топ-ап бонус-2025", "accounting_adjustment": "Нягтлан бодох бүртгэлийн тохируулга", "credits_expired": "Кредит дууссан" - } + }, + "loadMore": "Нэмэлт ачаалах", + "loadMoreFailed": "Нэмэлт ачаалж чадсангүй", + "truncated": "Хуучин кредит үйл ажиллагааг үзэх боломжтой." }, "inviteMember": { "emailError": "Хүчинтэй имэйл хаяг оруулна уу", @@ -2973,7 +2976,10 @@ "draft": "Ноорог", "uncollectible": "Цуглуулах боломжгүй", "unknown": "Тодорхойгүй" - } + }, + "loadMore": "Нэмэлт ачаалах", + "loadMoreFailed": "Нэмэлт ачаалж чадсангүй", + "truncated": "Хуучин нэхэмжлэлийг үзэх боломжтой." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/mr.json b/apps/mobile/src/i18n/locales/mr.json index f4d4ee6a8e..3313a95d23 100644 --- a/apps/mobile/src/i18n/locales/mr.json +++ b/apps/mobile/src/i18n/locales/mr.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "टीम टॉप-अप बोनस-2025", "accounting_adjustment": "लेखा समायोजन", "credits_expired": "क्रेडिट कालबाह्य झाले" - } + }, + "loadMore": "अधिक लोड करा", + "loadMoreFailed": "अधिक लोड करता आले नाही", + "truncated": "जुनी क्रेडिट क्रियाकलाप उपलब्ध आहे." }, "inviteMember": { "emailError": "वैध ईमेल पत्ता प्रविष्ट करा", @@ -2973,7 +2976,10 @@ "draft": "मसुदा", "uncollectible": "वसूल करता न येणारे", "unknown": "अज्ञात" - } + }, + "loadMore": "अधिक लोड करा", + "loadMoreFailed": "अधिक लोड करता आले नाही", + "truncated": "जुने इनव्हॉइस उपलब्ध आहेत." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/ms.json b/apps/mobile/src/i18n/locales/ms.json index 813d351a81..83ff14bc2f 100644 --- a/apps/mobile/src/i18n/locales/ms.json +++ b/apps/mobile/src/i18n/locales/ms.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Bonus tambah nilai pasukan-2025", "accounting_adjustment": "Pelarasan perakaunan", "credits_expired": "Kredit tamat tempoh" - } + }, + "loadMore": "Muatkan lagi", + "loadMoreFailed": "Tidak dapat memuatkan lagi", + "truncated": "Aktiviti kredit yang lebih lama tersedia." }, "inviteMember": { "emailError": "Masukkan alamat e-mel yang sah", @@ -2973,7 +2976,10 @@ "draft": "Draf", "uncollectible": "Tidak boleh dituntut", "unknown": "Tidak diketahui" - } + }, + "loadMore": "Muatkan lagi", + "loadMoreFailed": "Tidak dapat memuatkan lagi", + "truncated": "Invois yang lebih lama tersedia." }, "lowBalanceAlert": { "emailPlaceholder": "nama@syarikat.com", diff --git a/apps/mobile/src/i18n/locales/mt.json b/apps/mobile/src/i18n/locales/mt.json index cdcd04f15b..70db2c7d5a 100644 --- a/apps/mobile/src/i18n/locales/mt.json +++ b/apps/mobile/src/i18n/locales/mt.json @@ -3006,7 +3006,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Aġġustament tal-kontabilità", "credits_expired": "Krediti skaduti" - } + }, + "loadMore": "Għabbi aktar", + "loadMoreFailed": "Ma stajniex nittellgħu aktar", + "truncated": "Attività ta' kreditu aktar antika hija disponibbli." }, "inviteMember": { "emailError": "Daħħal indirizz ta' email validu", @@ -3033,7 +3036,10 @@ "draft": "Abbozz", "uncollectible": "Ma jistax jinġabar", "unknown": "Mhux magħruf" - } + }, + "loadMore": "Għabbi aktar", + "loadMoreFailed": "Ma stajniex nittellgħu aktar", + "truncated": "Fatturi aktar antiki huma disponibbli." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/my.json b/apps/mobile/src/i18n/locales/my.json index 794f65efdd..b9d264170a 100644 --- a/apps/mobile/src/i18n/locales/my.json +++ b/apps/mobile/src/i18n/locales/my.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "စာရင်းကိုင် ပြုပြင်မှု", "credits_expired": "သက်တမ်းကုန် ခရက်ဒစ်များ" - } + }, + "loadMore": "နောက်ထပ်တင်ပါ", + "loadMoreFailed": "နောက်ထပ်တင်မရပါ", + "truncated": "ပိုဟောင်းသော ခရက်ဒစ်လုပ်ဆောင်ချက်များ ရနိုင်ပါသည်။" }, "inviteMember": { "emailError": "မှန်ကန်သော အီးမေးလ် လိပ်စာ ထည့်ပါ", @@ -2973,7 +2976,10 @@ "draft": "အမူကြမ်း", "uncollectible": "ကောက်ခံ၍မရသော", "unknown": "အမည်မသိ" - } + }, + "loadMore": "နောက်ထပ်တင်ပါ", + "loadMoreFailed": "နောက်ထပ်တင်မရပါ", + "truncated": "ပိုဟောင်းသော ငွေတောင်းခံလွှာများ ရနိုင်ပါသည်။" }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/nb.json b/apps/mobile/src/i18n/locales/nb.json index 948a259d5b..6f92ec112e 100644 --- a/apps/mobile/src/i18n/locales/nb.json +++ b/apps/mobile/src/i18n/locales/nb.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Regnskapsjustering", "credits_expired": "Kreditter utløpt" - } + }, + "loadMore": "Last mer", + "loadMoreFailed": "Kunne ikke laste mer", + "truncated": "Tidligere kredittaktivitet er tilgjengelig." }, "inviteMember": { "emailError": "Angi en gyldig e-postadresse", @@ -2973,7 +2976,10 @@ "draft": "Kladd", "uncollectible": "Uinnkrevbar", "unknown": "Ukjent" - } + }, + "loadMore": "Last mer", + "loadMoreFailed": "Kunne ikke laste mer", + "truncated": "Tidligere fakturaer er tilgjengelige." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/ne.json b/apps/mobile/src/i18n/locales/ne.json index f29a9784c5..52652c0ffe 100644 --- a/apps/mobile/src/i18n/locales/ne.json +++ b/apps/mobile/src/i18n/locales/ne.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "लेखा समायोजन", "credits_expired": "म्याद सकिएका क्रेडिटहरू" - } + }, + "loadMore": "थप लोड गर्नुहोस्", + "loadMoreFailed": "थप लोड गर्न सकिएन", + "truncated": "पुरानो क्रेडिट गतिविधि उपलब्ध छ।" }, "inviteMember": { "emailError": "मान्य इमेल ठेगाना प्रविष्ट गर्नुहोस्", @@ -2973,7 +2976,10 @@ "draft": "मस्यौदा", "uncollectible": "असुली नहुने", "unknown": "अज्ञात" - } + }, + "loadMore": "थप लोड गर्नुहोस्", + "loadMoreFailed": "थप लोड गर्न सकिएन", + "truncated": "पुराना इनभ्वाइसहरू उपलब्ध छन्।" }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/nl.json b/apps/mobile/src/i18n/locales/nl.json index 10f0dbedfd..bfdb3f527b 100644 --- a/apps/mobile/src/i18n/locales/nl.json +++ b/apps/mobile/src/i18n/locales/nl.json @@ -2938,7 +2938,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Boekhoudkundige correctie", "credits_expired": "Credits verlopen" - } + }, + "loadMore": "Meer laden", + "loadMoreFailed": "Kon niet meer laden", + "truncated": "Oudere creditactiviteit is beschikbaar." }, "hub": { "balance": "Saldo", @@ -2974,7 +2977,10 @@ "draft": "Concept", "uncollectible": "Oninbaar", "unknown": "Onbekend" - } + }, + "loadMore": "Meer laden", + "loadMoreFailed": "Kon niet meer laden", + "truncated": "Oudere facturen zijn beschikbaar." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/om.json b/apps/mobile/src/i18n/locales/om.json index d455b3c6bf..687a6f2452 100644 --- a/apps/mobile/src/i18n/locales/om.json +++ b/apps/mobile/src/i18n/locales/om.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Sirreeffama herregaa", "credits_expired": "Kiriiditiin xumuramte" - } + }, + "loadMore": "Dabalata fayyadami", + "loadMoreFailed": "Dabalataa fe'achuu hin dandeenye", + "truncated": "Sochii kireedii durii argachuun ni danda'ama." }, "inviteMember": { "emailError": "Teessoo email sirrii seeni", @@ -2973,7 +2976,10 @@ "draft": "Daraftii", "uncollectible": "Kan walitti hin qabamne", "unknown": "Hin beekamu" - } + }, + "loadMore": "Dabalata fayyadami", + "loadMoreFailed": "Dabalataa fe'achuu hin dandeenye", + "truncated": "Baallii durii argachuun ni danda'ama." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/or.json b/apps/mobile/src/i18n/locales/or.json index f0ab611867..ba7bb658be 100644 --- a/apps/mobile/src/i18n/locales/or.json +++ b/apps/mobile/src/i18n/locales/or.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "ହିସାବ ସଂଶୋଧନ", "credits_expired": "କ୍ରେଡିଟ୍ ସମାପ୍ତ" - } + }, + "loadMore": "ଅଧିକ ଲୋଡ୍ କରନ୍ତୁ", + "loadMoreFailed": "ଅଧିକ ଲୋଡ୍ କରାଯାଇ ପାରିଲା ନାହିଁ", + "truncated": "ପୁରୁଣା କ୍ରେଡିଟ୍ କାର୍ଯ୍ୟକଳାପ ଉପଲବ୍ଧ।" }, "inviteMember": { "emailError": "ଏକ ବୈଧ ଇମେଲ୍ ଠିକଣା ପ୍ରବେଶ କରନ୍ତୁ", @@ -2973,7 +2976,10 @@ "draft": "ଡ୍ରାଫ୍ଟ", "uncollectible": "ଅସଂଗ୍ରହଣୀୟ", "unknown": "ଅଜଣା" - } + }, + "loadMore": "ଅଧିକ ଲୋଡ୍ କରନ୍ତୁ", + "loadMoreFailed": "ଅଧିକ ଲୋଡ୍ କରାଯାଇ ପାରିଲା ନାହିଁ", + "truncated": "ପୁରୁଣା ଇନଭଏସ୍ ଉପଲବ୍ଧ।" }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/pa.json b/apps/mobile/src/i18n/locales/pa.json index 4698f75060..276128b2c9 100644 --- a/apps/mobile/src/i18n/locales/pa.json +++ b/apps/mobile/src/i18n/locales/pa.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "ਲੇਖਾ ਸਮਾਯੋਜਨ", "credits_expired": "ਕ੍ਰੈਡਿਟ ਮਿਆਦ ਪੁੱਗ ਗਏ" - } + }, + "loadMore": "ਹੋਰ ਲੋਡ ਕਰੋ", + "loadMoreFailed": "ਹੋਰ ਲੋਡ ਨਹੀਂ ਹੋ ਸਕਿਆ", + "truncated": "ਪੁਰਾਣੀ ਕ੍ਰੈਡਿਟ ਗਤੀਵਿਧੀ ਉਪਲਬਧ ਹੈ।" }, "inviteMember": { "emailError": "ਵੈਧ ਈਮੇਲ ਪਤਾ ਦਰਜ ਕਰੋ", @@ -2973,7 +2976,10 @@ "draft": "ਡਰਾਫਟ", "uncollectible": "ਅਵਸੂਲਯੋਗ", "unknown": "ਅਣਜਾਣ" - } + }, + "loadMore": "ਹੋਰ ਲੋਡ ਕਰੋ", + "loadMoreFailed": "ਹੋਰ ਲੋਡ ਨਹੀਂ ਹੋ ਸਕਿਆ", + "truncated": "ਪੁਰਾਣੇ ਇਨਵੌਇਸ ਉਪਲਬਧ ਹਨ।" }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/pl.json b/apps/mobile/src/i18n/locales/pl.json index ecf629b038..81fe3c9307 100644 --- a/apps/mobile/src/i18n/locales/pl.json +++ b/apps/mobile/src/i18n/locales/pl.json @@ -2978,7 +2978,10 @@ "team-topup-bonus-2025": "Bonus doładowania zespołu 2025", "accounting_adjustment": "Korekta księgowa", "credits_expired": "Kredyty wygasły" - } + }, + "loadMore": "Załaduj więcej", + "loadMoreFailed": "Nie udało się załadować więcej", + "truncated": "Dostępna jest starsza historia kredytów." }, "hub": { "balance": "Saldo", @@ -3014,7 +3017,10 @@ "draft": "Szkic", "uncollectible": "Nieściągalna", "unknown": "Nieznana" - } + }, + "loadMore": "Załaduj więcej", + "loadMoreFailed": "Nie udało się załadować więcej", + "truncated": "Dostępne są starsze faktury." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/ps.json b/apps/mobile/src/i18n/locales/ps.json index ec89fcf944..fe8d771fca 100644 --- a/apps/mobile/src/i18n/locales/ps.json +++ b/apps/mobile/src/i18n/locales/ps.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "د ټیم ټاپ اپ بونس 2025", "accounting_adjustment": "د محاسبې سمون", "credits_expired": "کریډیټونه منقضي شوي" - } + }, + "loadMore": "نور بار کړئ", + "loadMoreFailed": "نور بار کیدی نشو", + "truncated": "پخوانۍ کریډیټي کړنې شته دي." }, "inviteMember": { "emailError": "یو سم بریښنالیک آدرس دننه کړئ", @@ -2973,7 +2976,10 @@ "draft": "مسوده", "uncollectible": "نه راټولېدونکی", "unknown": "نامعلوم" - } + }, + "loadMore": "نور بار کړئ", + "loadMoreFailed": "نور بار کیدی نشو", + "truncated": "پخوانۍ رسیدونه شته دي." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/pt-BR.json b/apps/mobile/src/i18n/locales/pt-BR.json index bdfcee102f..fc2a59fdea 100644 --- a/apps/mobile/src/i18n/locales/pt-BR.json +++ b/apps/mobile/src/i18n/locales/pt-BR.json @@ -2958,7 +2958,10 @@ "team-topup-bonus-2025": "Bônus de recarga da equipe 2025", "accounting_adjustment": "Ajuste contábil", "credits_expired": "Créditos expirados" - } + }, + "loadMore": "Carregar mais", + "loadMoreFailed": "Não foi possível carregar mais", + "truncated": "A atividade de crédito mais antiga está disponível." }, "hub": { "balance": "Saldo", @@ -2994,7 +2997,10 @@ "draft": "Rascunho", "uncollectible": "Incobrável", "unknown": "Desconhecido" - } + }, + "loadMore": "Carregar mais", + "loadMoreFailed": "Não foi possível carregar mais", + "truncated": "As faturas mais antigas estão disponíveis." }, "lowBalanceAlert": { "emailPlaceholder": "nome@empresa.com", diff --git a/apps/mobile/src/i18n/locales/pt.json b/apps/mobile/src/i18n/locales/pt.json index 6d030a91c4..f7911a3778 100644 --- a/apps/mobile/src/i18n/locales/pt.json +++ b/apps/mobile/src/i18n/locales/pt.json @@ -2966,7 +2966,10 @@ "team-topup-bonus-2025": "Bónus de carregamento da equipa 2025", "accounting_adjustment": "Ajuste contabilístico", "credits_expired": "Créditos expirados" - } + }, + "loadMore": "Carregar mais", + "loadMoreFailed": "Não foi possível carregar mais", + "truncated": "Está disponível atividade de crédito mais antiga." }, "inviteMember": { "emailError": "Introduza um endereço de email válido", @@ -2993,7 +2996,10 @@ "draft": "Rascunho", "uncollectible": "Incobrável", "unknown": "Desconhecido" - } + }, + "loadMore": "Carregar mais", + "loadMoreFailed": "Não foi possível carregar mais", + "truncated": "Estão disponíveis faturas mais antigas." }, "lowBalanceAlert": { "emailPlaceholder": "nome@empresa.com", diff --git a/apps/mobile/src/i18n/locales/ro.json b/apps/mobile/src/i18n/locales/ro.json index 44e89d1c58..b31a30b9d8 100644 --- a/apps/mobile/src/i18n/locales/ro.json +++ b/apps/mobile/src/i18n/locales/ro.json @@ -2966,7 +2966,10 @@ "team-topup-bonus-2025": "Bonus pentru reîncărcarea echipei 2025", "accounting_adjustment": "Ajustare contabilă", "credits_expired": "Credite expirate" - } + }, + "loadMore": "Încarcă mai mult", + "loadMoreFailed": "Nu s-a putut încărca mai mult", + "truncated": "Activitatea de credite mai veche este disponibilă." }, "inviteMember": { "emailError": "Introdu o adresă de email validă", @@ -2993,7 +2996,10 @@ "draft": "Ciornă", "uncollectible": "Neîncasabilă", "unknown": "Necunoscut" - } + }, + "loadMore": "Încarcă mai mult", + "loadMoreFailed": "Nu s-a putut încărca mai mult", + "truncated": "Sunt disponibile facturi mai vechi." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/ru.json b/apps/mobile/src/i18n/locales/ru.json index f8efed3fbc..2c08706b44 100644 --- a/apps/mobile/src/i18n/locales/ru.json +++ b/apps/mobile/src/i18n/locales/ru.json @@ -2978,7 +2978,10 @@ "team-topup-bonus-2025": "Бонус за пополнение баланса команды 2025", "accounting_adjustment": "Бухгалтерская корректировка", "credits_expired": "Кредиты истекли" - } + }, + "loadMore": "Загрузить больше", + "loadMoreFailed": "Не удалось загрузить больше", + "truncated": "Доступна более ранняя активность по кредитам." }, "hub": { "balance": "Баланс", @@ -3014,7 +3017,10 @@ "draft": "Черновик", "uncollectible": "Не подлежит взысканию", "unknown": "Неизвестно" - } + }, + "loadMore": "Загрузить больше", + "loadMoreFailed": "Не удалось загрузить больше", + "truncated": "Доступны более ранние счета." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/si.json b/apps/mobile/src/i18n/locales/si.json index 4d1ac821cf..09e99213e9 100644 --- a/apps/mobile/src/i18n/locales/si.json +++ b/apps/mobile/src/i18n/locales/si.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "2025 කණ්ඩායම් ඉහළ දැමීමේ ප්‍රසාදය", "accounting_adjustment": "ගිණුම්කරණ ගැලපීම", "credits_expired": "ණය කල් ඉකුත් විය" - } + }, + "loadMore": "තව පූරණය කරන්න", + "loadMoreFailed": "තවත් පූරණය කළ නොහැකි විය", + "truncated": "පැරණි ණය ක්රියාකාරකම් ලබා ගත හැක." }, "inviteMember": { "emailError": "වලංගු ඊමේල් ලිපිනයක් ඇතුළු කරන්න", @@ -2973,7 +2976,10 @@ "draft": "කෙටුම්පත", "uncollectible": "එකතු කළ නොහැකි", "unknown": "නොදන්නා" - } + }, + "loadMore": "තව පූරණය කරන්න", + "loadMoreFailed": "තවත් පූරණය කළ නොහැකි විය", + "truncated": "පැරණි ඉන්වොයිස් ලබා ගත හැක." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/sk.json b/apps/mobile/src/i18n/locales/sk.json index 687fb19384..37561e8729 100644 --- a/apps/mobile/src/i18n/locales/sk.json +++ b/apps/mobile/src/i18n/locales/sk.json @@ -2986,7 +2986,10 @@ "team-topup-bonus-2025": "Tímový bonus za doplnenie kreditov 2025", "accounting_adjustment": "Účtovná úprava", "credits_expired": "Kredity vypršali" - } + }, + "loadMore": "Načítať viac", + "loadMoreFailed": "Ďalšie sa nepodarilo načítať", + "truncated": "Staršia kreditná aktivita je k dispozícii." }, "inviteMember": { "emailError": "Zadajte platnú e-mailovú adresu", @@ -3013,7 +3016,10 @@ "draft": "Koncept", "uncollectible": "Nevymožiteľná", "unknown": "Neznáme" - } + }, + "loadMore": "Načítať viac", + "loadMoreFailed": "Ďalšie sa nepodarilo načítať", + "truncated": "Staršie faktúry sú k dispozícii." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/sl.json b/apps/mobile/src/i18n/locales/sl.json index cc7b4bc53b..8977c78303 100644 --- a/apps/mobile/src/i18n/locales/sl.json +++ b/apps/mobile/src/i18n/locales/sl.json @@ -2986,7 +2986,10 @@ "team-topup-bonus-2025": "Ekipni bonus za polnjenje 2025", "accounting_adjustment": "Računovodska prilagoditev", "credits_expired": "Potečeni krediti" - } + }, + "loadMore": "Naloži več", + "loadMoreFailed": "Več ni bilo mogoče naložiti", + "truncated": "Starejša dejavnost kredita je na voljo." }, "inviteMember": { "emailError": "Vnesi veljaven e-poštni naslov", @@ -3013,7 +3016,10 @@ "draft": "Osnutek", "uncollectible": "Neizterljivo", "unknown": "Neznano" - } + }, + "loadMore": "Naloži več", + "loadMoreFailed": "Več ni bilo mogoče naložiti", + "truncated": "Starejši računi so na voljo." }, "lowBalanceAlert": { "emailPlaceholder": "ime@podjetje.com", diff --git a/apps/mobile/src/i18n/locales/so.json b/apps/mobile/src/i18n/locales/so.json index 82de33c130..f4682dc069 100644 --- a/apps/mobile/src/i18n/locales/so.json +++ b/apps/mobile/src/i18n/locales/so.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Bonuska kooxda ee dhaaminta 2025", "accounting_adjustment": "Hagaajinta xisaabta", "credits_expired": "Credits oo dhacay" - } + }, + "loadMore": "Soo qaad dheeri ah", + "loadMoreFailed": "In ka badan lama soo qaadin karin", + "truncated": "Hawlaha credit ee hore waa la heli karaa." }, "inviteMember": { "emailError": "Geli ciwaan email oo ansax ah", @@ -2973,7 +2976,10 @@ "draft": "Qabyo", "uncollectible": "Lama ururin karo", "unknown": "Lama garanayo" - } + }, + "loadMore": "Soo qaad dheeri ah", + "loadMoreFailed": "In ka badan lama soo qaadin karin", + "truncated": "Qaansheegyada hore waa la heli karaa." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/sq.json b/apps/mobile/src/i18n/locales/sq.json index 7804f3c866..7e538acf57 100644 --- a/apps/mobile/src/i18n/locales/sq.json +++ b/apps/mobile/src/i18n/locales/sq.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Bonusi i ekipit për rimbushje 2025", "accounting_adjustment": "Rregullim kontabël", "credits_expired": "Kredite të skaduara" - } + }, + "loadMore": "Ngarko më shumë", + "loadMoreFailed": "Nuk u arrit të ngarkohej më shumë", + "truncated": "Aktiviteti i vjetër i kredisë është i disponueshëm." }, "inviteMember": { "emailError": "Futni një adresë email-i të vlefshme", @@ -2973,7 +2976,10 @@ "draft": "Draft", "uncollectible": "E pakolektueshme", "unknown": "E panjohur" - } + }, + "loadMore": "Ngarko më shumë", + "loadMoreFailed": "Nuk u arrit të ngarkohej më shumë", + "truncated": "Faturat më të vjetra janë të disponueshme." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/sr.json b/apps/mobile/src/i18n/locales/sr.json index 65f44c22f2..41765a7026 100644 --- a/apps/mobile/src/i18n/locales/sr.json +++ b/apps/mobile/src/i18n/locales/sr.json @@ -2966,7 +2966,10 @@ "team-topup-bonus-2025": "Timski bonus za dopunu 2025", "accounting_adjustment": "Računovodstveno usklađivanje", "credits_expired": "Istekli krediti" - } + }, + "loadMore": "Učitaj još", + "loadMoreFailed": "Nije moguće učitati više", + "truncated": "Starija kreditna aktivnost je dostupna." }, "inviteMember": { "emailError": "Unesite važeću adresu e-pošte", @@ -2993,7 +2996,10 @@ "draft": "Nacrt", "uncollectible": "Nenaplativo", "unknown": "Nepoznato" - } + }, + "loadMore": "Učitaj još", + "loadMoreFailed": "Nije moguće učitati više", + "truncated": "Starije fakture su dostupne." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/sv.json b/apps/mobile/src/i18n/locales/sv.json index e96bec11a9..361f450ddb 100644 --- a/apps/mobile/src/i18n/locales/sv.json +++ b/apps/mobile/src/i18n/locales/sv.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Team-påfyllnadsbonus 2025", "accounting_adjustment": "Redovisningsjustering", "credits_expired": "Krediter upphörde" - } + }, + "loadMore": "Läs in mer", + "loadMoreFailed": "Kunde inte läsa in mer", + "truncated": "Äldre kreditaktivitet finns tillgänglig." }, "inviteMember": { "emailError": "Ange en giltig e-postadress", @@ -2973,7 +2976,10 @@ "draft": "Utkast", "uncollectible": "Oindrivbar", "unknown": "Okänd" - } + }, + "loadMore": "Läs in mer", + "loadMoreFailed": "Kunde inte läsa in mer", + "truncated": "Äldre fakturor finns tillgängliga." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/sw.json b/apps/mobile/src/i18n/locales/sw.json index 43a7275710..84b0d01f1b 100644 --- a/apps/mobile/src/i18n/locales/sw.json +++ b/apps/mobile/src/i18n/locales/sw.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Bonasi ya kujaza timu 2025", "accounting_adjustment": "Marekebisho ya uhasibu", "credits_expired": "Mikopo imeisha" - } + }, + "loadMore": "Pakia zaidi", + "loadMoreFailed": "Imeshindwa kupakia zaidi", + "truncated": "Shughuli za zamani za mkopo zinapatikana." }, "inviteMember": { "emailError": "Ingiza anwani halali ya barua pepe", @@ -2973,7 +2976,10 @@ "draft": "Rasimu", "uncollectible": "Isiyoweza kukusanywa", "unknown": "Isiyojulikana" - } + }, + "loadMore": "Pakia zaidi", + "loadMoreFailed": "Imeshindwa kupakia zaidi", + "truncated": "Ankara za zamani zinapatikana." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/ta.json b/apps/mobile/src/i18n/locales/ta.json index c6d74fc995..163998a6a6 100644 --- a/apps/mobile/src/i18n/locales/ta.json +++ b/apps/mobile/src/i18n/locales/ta.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "குழு டாப்-அப் போனஸ் 2025", "accounting_adjustment": "கணக்கியல் சரிசெய்தல்", "credits_expired": "கிரெடிட்கள் காலாவதியானது" - } + }, + "loadMore": "மேலும் ஏற்று", + "loadMoreFailed": "மேலும் ஏற்ற முடியவில்லை", + "truncated": "பழைய கிரெடிட் செயல்பாடு கிடைக்கிறது." }, "inviteMember": { "emailError": "செல்லுபடியாகும் மின்னஞ்சல் முகவரியை உள்ளிடவும்", @@ -2973,7 +2976,10 @@ "draft": "வரைவு", "uncollectible": "வசூலிக்க முடியாதது", "unknown": "தெரியவில்லை" - } + }, + "loadMore": "மேலும் ஏற்று", + "loadMoreFailed": "மேலும் ஏற்ற முடியவில்லை", + "truncated": "பழைய விலைப்பட்டியல்கள் கிடைக்கின்றன." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/te.json b/apps/mobile/src/i18n/locales/te.json index bdaf168113..609189c455 100644 --- a/apps/mobile/src/i18n/locales/te.json +++ b/apps/mobile/src/i18n/locales/te.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "టీమ్ టాప్-అప్ బోనస్ 2025", "accounting_adjustment": "అకౌంటింగ్ సర్దుబాటు", "credits_expired": "క్రెడిట్లు గడువు ముగిశాయి" - } + }, + "loadMore": "మరింత లోడ్ చేయండి", + "loadMoreFailed": "మరిన్ని లోడ్ చేయలేకపోయాము", + "truncated": "పాత క్రెడిట్ కార్యకలాపం అందుబాటులో ఉంది." }, "inviteMember": { "emailError": "చెల్లుబాటు అయ్యే ఇమెయిల్ చిరునామాను నమోదు చేయండి", @@ -2973,7 +2976,10 @@ "draft": "డ్రాఫ్ట్", "uncollectible": "వసూలు చేయలేనిది", "unknown": "తెలియదు" - } + }, + "loadMore": "మరింత లోడ్ చేయండి", + "loadMoreFailed": "మరిన్ని లోడ్ చేయలేకపోయాము", + "truncated": "పాత ఇన్వాయిస్లు అందుబాటులో ఉన్నాయి." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/th.json b/apps/mobile/src/i18n/locales/th.json index 5ee4e7b66c..f3fcf3f110 100644 --- a/apps/mobile/src/i18n/locales/th.json +++ b/apps/mobile/src/i18n/locales/th.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "โบนัสเติมเงินทีม 2025", "accounting_adjustment": "การปรับปรุงบัญชี", "credits_expired": "เครดิตหมดอายุ" - } + }, + "loadMore": "โหลดเพิ่ม", + "loadMoreFailed": "ไม่สามารถโหลดเพิ่มได้", + "truncated": "มีกิจกรรมเครดิตเก่ากว่าให้ดู" }, "inviteMember": { "emailError": "ป้อนที่อยู่อีเมลที่ถูกต้อง", @@ -2973,7 +2976,10 @@ "draft": "ฉบับร่าง", "uncollectible": "เก็บไม่ได้", "unknown": "ไม่ทราบ" - } + }, + "loadMore": "โหลดเพิ่ม", + "loadMoreFailed": "ไม่สามารถโหลดเพิ่มได้", + "truncated": "มีใบแจ้งหนี้เก่ากว่าให้ดู" }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/tr.json b/apps/mobile/src/i18n/locales/tr.json index b7aa3c595d..2c89799eb1 100644 --- a/apps/mobile/src/i18n/locales/tr.json +++ b/apps/mobile/src/i18n/locales/tr.json @@ -2938,7 +2938,10 @@ "team-topup-bonus-2025": "Ekip bakiye yükleme bonusu 2025", "accounting_adjustment": "Muhasebe düzeltmesi", "credits_expired": "Kredilerin süresi doldu" - } + }, + "loadMore": "Daha fazla yükle", + "loadMoreFailed": "Daha fazlası yüklenemedi", + "truncated": "Daha eski kredi hareketleri mevcut." }, "hub": { "balance": "Bakiye", @@ -2974,7 +2977,10 @@ "draft": "Taslak", "uncollectible": "Tahsil edilemez", "unknown": "BİLİNMİYOR" - } + }, + "loadMore": "Daha fazla yükle", + "loadMoreFailed": "Daha fazlası yüklenemedi", + "truncated": "Daha eski faturalar mevcut." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/uk.json b/apps/mobile/src/i18n/locales/uk.json index ce5b38cadf..1d9e53a4bd 100644 --- a/apps/mobile/src/i18n/locales/uk.json +++ b/apps/mobile/src/i18n/locales/uk.json @@ -2978,7 +2978,10 @@ "team-topup-bonus-2025": "Командний бонус поповнення 2025", "accounting_adjustment": "Бухгалтерське коригування", "credits_expired": "Термін дії кредитів минув" - } + }, + "loadMore": "Завантажити більше", + "loadMoreFailed": "Не вдалося завантажити більше", + "truncated": "Доступна старіша активність за кредитами." }, "hub": { "balance": "Баланс", @@ -3014,7 +3017,10 @@ "draft": "Чернетка", "uncollectible": "Безнадійний", "unknown": "Невідомо" - } + }, + "loadMore": "Завантажити більше", + "loadMoreFailed": "Не вдалося завантажити більше", + "truncated": "Доступні старіші рахунки-фактури." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/ur.json b/apps/mobile/src/i18n/locales/ur.json index cbb575b702..052eed3584 100644 --- a/apps/mobile/src/i18n/locales/ur.json +++ b/apps/mobile/src/i18n/locales/ur.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "ٹیم ٹاپ اپ بونس 2025", "accounting_adjustment": "اکاؤنٹنگ ایڈجسٹمنٹ", "credits_expired": "کریڈٹس کی میعاد ختم" - } + }, + "loadMore": "مزید لوڈ کریں", + "loadMoreFailed": "مزید لوڈ نہیں ہو سکا", + "truncated": "پرانے کریڈٹ کی سرگرمی دستیاب ہے۔" }, "inviteMember": { "emailError": "درست ای میل پتہ درج کریں", @@ -2973,7 +2976,10 @@ "draft": "ڈرافٹ", "uncollectible": "ناقابل وصول", "unknown": "نامعلوم" - } + }, + "loadMore": "مزید لوڈ کریں", + "loadMoreFailed": "مزید لوڈ نہیں ہو سکا", + "truncated": "پرانے انوائس دستیاب ہیں۔" }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/uz.json b/apps/mobile/src/i18n/locales/uz.json index b8fc3367f8..0207980b12 100644 --- a/apps/mobile/src/i18n/locales/uz.json +++ b/apps/mobile/src/i18n/locales/uz.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Buxgalteriya tuzatmasi", "credits_expired": "Kreditlar muddati tugadi" - } + }, + "loadMore": "Ko'proq yuklash", + "loadMoreFailed": "Ko'proq yuklab bo'lmadi", + "truncated": "Eski kredit faoliyati mavjud." }, "inviteMember": { "emailError": "To'g'ri email manzil kiriting", @@ -2973,7 +2976,10 @@ "draft": "Qoralama", "uncollectible": "Undirib bo'lmaydigan", "unknown": "Noma'lum" - } + }, + "loadMore": "Ko'proq yuklash", + "loadMoreFailed": "Ko'proq yuklab bo'lmadi", + "truncated": "Eski hisob-fakturalar mavjud." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/vi.json b/apps/mobile/src/i18n/locales/vi.json index 13bd98d52d..cd565f26a0 100644 --- a/apps/mobile/src/i18n/locales/vi.json +++ b/apps/mobile/src/i18n/locales/vi.json @@ -2938,7 +2938,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Điều chỉnh kế toán", "credits_expired": "Tín dụng đã hết hạn" - } + }, + "loadMore": "Tải thêm", + "loadMoreFailed": "Không thể tải thêm", + "truncated": "Hoạt động tín dụng cũ hơn có sẵn." }, "hub": { "balance": "Số dư", @@ -2974,7 +2977,10 @@ "draft": "Bản nháp", "uncollectible": "Không thể thu hồi", "unknown": "Không xác định" - } + }, + "loadMore": "Tải thêm", + "loadMoreFailed": "Không thể tải thêm", + "truncated": "Hóa đơn cũ hơn có sẵn." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/yo.json b/apps/mobile/src/i18n/locales/yo.json index 0f78475d21..c33b401a98 100644 --- a/apps/mobile/src/i18n/locales/yo.json +++ b/apps/mobile/src/i18n/locales/yo.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Àtúnṣe ìṣirò-owó", "credits_expired": "Àwọn kíredìtì ti parí" - } + }, + "loadMore": "Kojọ diẹ sii", + "loadMoreFailed": "Kò lè gbé síwájú síi", + "truncated": "Ìgbòkègbodò àwọn iṣẹ́ àwíjàre tó ti gbọjú wà." }, "inviteMember": { "emailError": "Tẹ adirẹsi imeeli ti o wulo", @@ -2973,7 +2976,10 @@ "draft": "Àkọsílẹ̀", "uncollectible": "Àìlègbà", "unknown": "Àìmọ̀" - } + }, + "loadMore": "Kojọ diẹ sii", + "loadMoreFailed": "Kò lè gbé síwájú síi", + "truncated": "Àwọn ìwé-owó tó ti gbọjú wà." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/zh-Hans.json b/apps/mobile/src/i18n/locales/zh-Hans.json index 3ace24036e..536fbe10ca 100644 --- a/apps/mobile/src/i18n/locales/zh-Hans.json +++ b/apps/mobile/src/i18n/locales/zh-Hans.json @@ -2938,7 +2938,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "会计调整", "credits_expired": "积分已过期" - } + }, + "loadMore": "加载更多", + "loadMoreFailed": "无法加载更多", + "truncated": "可查看更早的信用活动。" }, "hub": { "balance": "余额", @@ -2974,7 +2977,10 @@ "draft": "草稿", "uncollectible": "无法收回", "unknown": "未知" - } + }, + "loadMore": "加载更多", + "loadMoreFailed": "无法加载更多", + "truncated": "可查看更早的发票。" }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/zh-Hant.json b/apps/mobile/src/i18n/locales/zh-Hant.json index 15f4508da5..3ec3aa9608 100644 --- a/apps/mobile/src/i18n/locales/zh-Hant.json +++ b/apps/mobile/src/i18n/locales/zh-Hant.json @@ -2938,7 +2938,10 @@ "team-topup-bonus-2025": "團隊加值獎勵 2025", "accounting_adjustment": "會計調整", "credits_expired": "點數已到期" - } + }, + "loadMore": "載入更多", + "loadMoreFailed": "無法載入更多", + "truncated": "可查看較舊的額度活動。" }, "hub": { "balance": "餘額", @@ -2974,7 +2977,10 @@ "draft": "草稿", "uncollectible": "無法收回", "unknown": "未知" - } + }, + "loadMore": "載入更多", + "loadMoreFailed": "無法載入更多", + "truncated": "可查看較舊的發票。" }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/zu.json b/apps/mobile/src/i18n/locales/zu.json index c24915e8ff..97019036d1 100644 --- a/apps/mobile/src/i18n/locales/zu.json +++ b/apps/mobile/src/i18n/locales/zu.json @@ -2946,7 +2946,10 @@ "team-topup-bonus-2025": "Ibhonasi ye-team top-up 2025", "accounting_adjustment": "Ukulungiswa kwezimali", "credits_expired": "Amakhredithi aphelelwe yisikhathi" - } + }, + "loadMore": "Layisha okwengeziwe", + "loadMoreFailed": "Asikwazanga ukulayisha okwengeziwe", + "truncated": "Umsebenzi wekhredithi omdala uyatholakala." }, "inviteMember": { "emailError": "Faka ikheli le-imeyili elivumelekile", @@ -2973,7 +2976,10 @@ "draft": "Okusalungiswa", "uncollectible": "Okungakhokhiwa", "unknown": "Okungaziwa" - } + }, + "loadMore": "Layisha okwengeziwe", + "loadMoreFailed": "Asikwazanga ukulayisha okwengeziwe", + "truncated": "Ama-invoyisi amadala ayatholakala." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/lib/hooks/use-organization-queries.ts b/apps/mobile/src/lib/hooks/use-organization-queries.ts index 5a8acb5ea9..cc7dfe661b 100644 --- a/apps/mobile/src/lib/hooks/use-organization-queries.ts +++ b/apps/mobile/src/lib/hooks/use-organization-queries.ts @@ -1,10 +1,14 @@ import { canManageOrganizationBilling } from '@kilocode/app-shared/organizations'; -import { useQuery } from '@tanstack/react-query'; +import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; import { useAuth } from '@/lib/auth/auth-context'; import { useOrganization } from '@/lib/organization-context'; import { useTRPC } from '@/lib/trpc'; +type RouterOutputs = inferRouterOutputs; + /** * The current user's role in the active organization. `trpc.organizations.list` * requires auth (not an active org selection), so it's gated on the token @@ -139,28 +143,61 @@ export function useOrgUsageStats(organizationId: string | null) { ); } -export function useOrgCreditTransactions(organizationId: string | null) { +export type CreditTransaction = + RouterOutputs['organizations']['creditTransactionsPage']['entries'][number]; + +/** + * Cursor-paginated credit transactions for an organization. Mirrors the legacy + * `useOrgCreditTransactions` surface (flat `entries`) but pages through + * `organizations.creditTransactionsPage` with `useInfiniteQuery` so the screen + * can offer "Load more" instead of scanning every row at once. + */ +export function useOrgCreditTransactionsPage(organizationId: string | null) { const trpc = useTRPC(); - return useQuery( - trpc.organizations.creditTransactions.queryOptions( + const query = useInfiniteQuery( + trpc.organizations.creditTransactionsPage.infiniteQueryOptions( { organizationId: organizationId ?? '' }, - { enabled: organizationId != null } + { + enabled: organizationId != null, + getNextPageParam: lastPage => + lastPage.hasMore ? (lastPage.nextCursor ?? undefined) : undefined, + } ) ); + + const pages = query.data?.pages; + const entries = useMemo(() => (pages ?? []).flatMap(page => page.entries), [pages]); + const lastPage = pages != null && pages.length > 0 ? pages.at(-1) : undefined; + const hasMore = lastPage?.hasMore ?? false; + + return { query, entries, hasMore }; } -export type CreditTransaction = NonNullable< - ReturnType['data'] ->[number]; +export type OrgInvoice = RouterOutputs['organizations']['invoicesPage']['entries'][number]; -export function useOrgInvoices(organizationId: string | null) { +/** + * Cursor-paginated invoices for an organization. Mirrors the legacy + * `useOrgInvoices` surface (flat `entries`) but pages through + * `organizations.invoicesPage` with `useInfiniteQuery` so the screen can offer + * "Load more" instead of loading every invoice at once. + */ +export function useOrgInvoicesPage(organizationId: string | null) { const trpc = useTRPC(); - return useQuery( - trpc.organizations.invoices.queryOptions( + const query = useInfiniteQuery( + trpc.organizations.invoicesPage.infiniteQueryOptions( { organizationId: organizationId ?? '', period: 'year' }, - { enabled: organizationId != null } + { + enabled: organizationId != null, + getNextPageParam: lastPage => + lastPage.hasMore ? (lastPage.nextCursor ?? undefined) : undefined, + } ) ); -} -export type OrgInvoice = NonNullable['data']>[number]; + const pages = query.data?.pages; + const entries = useMemo(() => (pages ?? []).flatMap(page => page.entries), [pages]); + const lastPage = pages != null && pages.length > 0 ? pages.at(-1) : undefined; + const hasMore = lastPage?.hasMore ?? false; + + return { query, entries, hasMore }; +} diff --git a/apps/web/src/lib/creditTransactions.page.test.ts b/apps/web/src/lib/creditTransactions.page.test.ts new file mode 100644 index 0000000000..b015ce3147 --- /dev/null +++ b/apps/web/src/lib/creditTransactions.page.test.ts @@ -0,0 +1,151 @@ +import { describe, test, expect } from '@jest/globals'; +import { insertTestUser } from '../tests/helpers/user.helper'; +import { createTestOrganization } from '../tests/helpers/organization.helper'; + +import { + getCreditTransactionsForOrganization, + getCreditTransactionsForOrganizationPage, +} from '@/lib/creditTransactions'; +import { db, pool } from './drizzle'; +import { credit_transactions } from '@kilocode/db/schema'; + +function whereClause(text: string): string { + const match = text.match(/\bwhere\s+(.+?)\s+order by\s/); + return match ? match[1] : ''; +} + +describe('getCreditTransactionsForOrganizationPage', () => { + test('pages 26 transactions into 25 entries and matches the summary for the excluded set', async () => { + const user = await insertTestUser(); + const org = await createTestOrganization('page org', user.id, 0); + + const purchases = Array.from({ length: 26 }, () => ({ + kilo_user_id: user.id, + organization_id: org.id, + is_free: false, + amount_microdollars: 1_000_000, + description: 'purchase', + })); + await db.insert(credit_transactions).values(purchases); + + // kpo:consumption rows must be absent from both the page and the summary. + await db.insert(credit_transactions).values([ + { + kilo_user_id: user.id, + organization_id: org.id, + is_free: true, + amount_microdollars: 5_000_000, + credit_category: 'kpo:consumption:models', + description: 'consumption', + }, + { + kilo_user_id: user.id, + organization_id: org.id, + is_free: true, + amount_microdollars: 5_000_000, + credit_category: 'kpo:consumption:models', + description: 'consumption', + }, + ]); + + const page = await getCreditTransactionsForOrganizationPage(org.id); + + expect(page.entries).toHaveLength(25); + expect(page.hasMore).toBe(true); + expect(page.nextCursor).toBe(`${page.entries[24]!.created_at}|${page.entries[24]!.id}`); + expect(page.entries.every(entry => !entry.credit_category?.startsWith('kpo:consumption'))).toBe( + true + ); + + expect(page.summary).toEqual({ + total_promotional_musd: 0, + total_purchased_musd: 26_000_000, + credit_transaction_count: 26, + }); + }); + + test('returns empty entries, hasMore false, and zero summary for an empty organization', async () => { + const user = await insertTestUser(); + const org = await createTestOrganization('empty page org', user.id, 0); + + const page = await getCreditTransactionsForOrganizationPage(org.id); + + expect(page.entries).toEqual([]); + expect(page.hasMore).toBe(false); + expect(page.nextCursor).toBeNull(); + expect(page.summary).toEqual({ + total_promotional_musd: 0, + total_purchased_musd: 0, + credit_transaction_count: 0, + }); + }); + + test('page SQL keeps the old where clause and adds id ordering plus limit+1', async () => { + const user = await insertTestUser(); + const org = await createTestOrganization('sql page org', user.id, 0); + + const querySpy = jest.spyOn(pool, 'query'); + + await getCreditTransactionsForOrganization(org.id); + await getCreditTransactionsForOrganizationPage(org.id); + + const captured = (querySpy.mock.calls as unknown as unknown[][]).map(call => { + const first = call[0]; + const text = + typeof first === 'string' ? first : ((first as { text?: string } | null)?.text ?? ''); + return { text, params: (call[1] ?? []) as unknown[] }; + }); + + const oldQuery = captured.find(call => call.text.includes('from "credit_transactions"')); + const pageQuery = captured.find(call => call.text.includes('"id" desc')); + + expect(oldQuery).toBeDefined(); + expect(pageQuery).toBeDefined(); + + expect(whereClause(pageQuery!.text)).toBe(whereClause(oldQuery!.text)); + + expect(pageQuery!.text).toContain('"created_at" desc'); + expect(pageQuery!.text.indexOf('"created_at" desc')).toBeLessThan( + pageQuery!.text.indexOf('"id" desc') + ); + expect(oldQuery!.text).not.toContain('"id" desc'); + + expect(pageQuery!.params).toContain(26); + + querySpy.mockRestore(); + }); + + // An OFFSET cursor breaks here: a row inserted at the head between the two + // requests shifts every later page, so page 2 repeats a page-1 row. + test('keeps page 2 disjoint from page 1 when a new transaction lands between requests', async () => { + const user = await insertTestUser(); + const org = await createTestOrganization('stable page org', user.id, 0); + + await db.insert(credit_transactions).values( + Array.from({ length: 30 }, (_, index) => ({ + kilo_user_id: user.id, + organization_id: org.id, + is_free: false, + amount_microdollars: 1_000_000, + description: `purchase ${index}`, + })) + ); + + const first = await getCreditTransactionsForOrganizationPage(org.id); + expect(first.hasMore).toBe(true); + + await db.insert(credit_transactions).values({ + kilo_user_id: user.id, + organization_id: org.id, + is_free: false, + amount_microdollars: 9_000_000, + description: 'inserted between pages', + }); + + const second = await getCreditTransactionsForOrganizationPage(org.id, first.nextCursor); + + const firstIds = new Set(first.entries.map(entry => entry.id)); + expect(second.entries.some(entry => firstIds.has(entry.id))).toBe(false); + expect(second.entries).toHaveLength(5); + }); +}); diff --git a/apps/web/src/lib/creditTransactions.ts b/apps/web/src/lib/creditTransactions.ts index 03b289786d..d8e5d61c18 100644 --- a/apps/web/src/lib/creditTransactions.ts +++ b/apps/web/src/lib/creditTransactions.ts @@ -3,7 +3,7 @@ import { db, readDb, sql } from './drizzle'; import type { Organization } from '@kilocode/db/schema'; import { credit_transactions, kilo_pass_issuance_items, kilocode_users } from '@kilocode/db/schema'; -type CreditSummary = { +export type CreditSummary = { total_promotional_musd: number; total_purchased_musd: number; credit_transaction_count: number; @@ -35,6 +35,33 @@ export async function getCreditTransactionsSummaryByUserId( }; } +export async function getCreditTransactionsSummaryForOrganization( + organizationId: Organization['id'] +): Promise { + const { rows } = await db.execute( + sql` + select + coalesce(sum(amount_microdollars) filter (where is_free),0) :: bigint total_promotional_musd, + coalesce(sum(amount_microdollars) filter (where not is_free),0) :: bigint total_purchased_musd, + count(*) as credit_transaction_count + from public.credit_transactions + where organization_id = ${organizationId} + and (credit_category is null or credit_category not like 'kpo:consumption:%') + ` + ); + const result = rows[0] as { + total_promotional_musd: bigint; + total_purchased_musd: bigint; + credit_transaction_count: bigint; + }; + + return { + total_promotional_musd: Number(result.total_promotional_musd), + total_purchased_musd: Number(result.total_purchased_musd), + credit_transaction_count: Number(result.credit_transaction_count), + }; +} + export type CreditInfo = { balance: number; isDepleted: boolean; @@ -66,6 +93,7 @@ export async function summarizeUserPayments(kiloUserId: string, fromDb: typeof d )[0]; } +// old form: array capped at 100, no cursor; remove when every client pages. export async function getCreditTransactionsForOrganization(organizationId: Organization['id']) { return db .select({ @@ -100,6 +128,100 @@ export async function getCreditTransactionsForOrganization(organizationId: Organ .limit(100); } +const CREDIT_TRANSACTIONS_PAGE_SIZE = 25; + +type OrganizationCreditTransaction = Awaited< + ReturnType +>[number]; + +export type CreditTransactionsPage = { + entries: OrganizationCreditTransaction[]; + nextCursor: string | null; + hasMore: boolean; + summary: CreditSummary; +}; + +/** + * Opaque keyset cursor: the ordering key of the last row a page returned, + * as `|`. An OFFSET cursor is not stable here — the ledger + * grows at the head, so a row inserted between two requests shifts every + * later page and page 2 repeats a row page 1 already showed. + * + * `created_at` is read in `mode: 'string'`, so the value keeps the full + * Postgres microsecond precision a JS `Date` would round away. + */ +function encodeLedgerCursor(row: { created_at: string; id: string }): string { + return `${row.created_at}|${row.id}`; +} + +function decodeLedgerCursor(cursor: string): { createdAt: string; id: string } | null { + const separator = cursor.indexOf('|'); + if (separator <= 0 || separator === cursor.length - 1) { + return null; + } + return { createdAt: cursor.slice(0, separator), id: cursor.slice(separator + 1) }; +} + +export async function getCreditTransactionsForOrganizationPage( + organizationId: Organization['id'], + cursor?: string | null +): Promise { + // A malformed cursor reads as "start from the top" rather than throwing: the + // value is opaque to the client and a stale one must not break the screen. + const decoded = cursor ? decodeLedgerCursor(cursor) : null; + const [transactions, summary] = await Promise.all([ + db + .select({ + id: credit_transactions.id, + kilo_user_id: credit_transactions.kilo_user_id, + amount_microdollars: credit_transactions.amount_microdollars, + expiration_baseline_microdollars_used: + credit_transactions.expiration_baseline_microdollars_used, + original_baseline_microdollars_used: + credit_transactions.original_baseline_microdollars_used, + is_free: credit_transactions.is_free, + description: credit_transactions.description, + original_transaction_id: credit_transactions.original_transaction_id, + stripe_payment_id: credit_transactions.stripe_payment_id, + coinbase_credit_block_id: credit_transactions.coinbase_credit_block_id, + credit_category: credit_transactions.credit_category, + expiry_date: credit_transactions.expiry_date, + created_at: credit_transactions.created_at, + organization_id: credit_transactions.organization_id, + check_category_uniqueness: credit_transactions.check_category_uniqueness, + }) + .from(credit_transactions) + .where( + and( + eq(credit_transactions.organization_id, organizationId), + or( + isNull(credit_transactions.credit_category), + notLike(credit_transactions.credit_category, 'kpo:consumption:%') + ), + // Row-value comparison in the same (created_at desc, id desc) order, + // so later pages stay disjoint from the ones already shown. + decoded + ? sql`(${credit_transactions.created_at}, ${credit_transactions.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id}::uuid)` + : undefined + ) + ) + .orderBy(desc(credit_transactions.created_at), desc(credit_transactions.id)) + .limit(CREDIT_TRANSACTIONS_PAGE_SIZE + 1), + getCreditTransactionsSummaryForOrganization(organizationId), + ]); + + const hasMore = transactions.length > CREDIT_TRANSACTIONS_PAGE_SIZE; + const entries = transactions.slice(0, CREDIT_TRANSACTIONS_PAGE_SIZE); + const lastEntry = entries.at(-1); + + return { + entries, + nextCursor: hasMore && lastEntry ? encodeLedgerCursor(lastEntry) : null, + hasMore, + summary, + }; +} + export async function getAdminCreditTransactionsForOrganization( organizationId: Organization['id'] ) { diff --git a/apps/web/src/lib/stripe/index.test.ts b/apps/web/src/lib/stripe/index.test.ts index bdfb618ee4..2ae88348b9 100644 --- a/apps/web/src/lib/stripe/index.test.ts +++ b/apps/web/src/lib/stripe/index.test.ts @@ -3967,3 +3967,134 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', } ); }); + +describe('getStripeInvoicesPage', () => { + test('returns hasMore, entries, and nextCursor from the last invoice', async () => { + const invoices = [ + { + id: 'in_page_1', + object: 'invoice', + number: 'INV-1', + status: 'paid', + amount_due: 100, + currency: 'usd', + created: 1000, + hosted_invoice_url: null, + invoice_pdf: null, + lines: { data: [] }, + }, + { + id: 'in_page_2', + object: 'invoice', + number: 'INV-2', + status: 'paid', + amount_due: 200, + currency: 'usd', + created: 2000, + hosted_invoice_url: null, + invoice_pdf: null, + lines: { data: [] }, + }, + ] as unknown as Stripe.Invoice[]; + + try { + jest.resetModules(); + await jest.isolateModulesAsync(async () => { + const stripe = await import('@/lib/stripe'); + const { client } = await import('@/lib/stripe-client'); + + const listSpy = jest.spyOn(client.invoices, 'list').mockResolvedValue({ + data: invoices, + has_more: true, + } as unknown as Awaited>); + + const result = await stripe.getStripeInvoicesPage('cus_page_test'); + + expect(listSpy).toHaveBeenCalledWith( + expect.objectContaining({ customer: 'cus_page_test', limit: 25 }) + ); + expect(result.hasMore).toBe(true); + expect(result.entries).toHaveLength(2); + expect(result.nextCursor).toBe('in_page_2'); + + listSpy.mockRestore(); + }); + } finally { + jest.resetModules(); + } + }); + + test('returns no cursor on a full final page', async () => { + const invoices = [ + { + id: 'in_final', + object: 'invoice', + number: 'INV-9', + status: 'paid', + amount_due: 100, + currency: 'usd', + created: 1000, + hosted_invoice_url: null, + invoice_pdf: null, + lines: { data: [] }, + }, + ] as unknown as Stripe.Invoice[]; + + try { + jest.resetModules(); + await jest.isolateModulesAsync(async () => { + const stripe = await import('@/lib/stripe'); + const { client } = await import('@/lib/stripe-client'); + + const listSpy = jest.spyOn(client.invoices, 'list').mockResolvedValue({ + data: invoices, + has_more: false, + } as unknown as Awaited>); + + const result = await stripe.getStripeInvoicesPage('cus_page_test'); + + expect(result.hasMore).toBe(false); + expect(result.entries).toHaveLength(1); + expect(result.nextCursor).toBeNull(); + + listSpy.mockRestore(); + }); + } finally { + jest.resetModules(); + } + }); + + test('passes starting_after and date threshold through to Stripe', async () => { + try { + jest.resetModules(); + await jest.isolateModulesAsync(async () => { + const stripe = await import('@/lib/stripe'); + const { client } = await import('@/lib/stripe-client'); + + const listSpy = jest.spyOn(client.invoices, 'list').mockResolvedValue({ + data: [], + has_more: false, + } as unknown as Awaited>); + + const threshold = new Date('2026-01-01T00:00:00.000Z'); + const result = await stripe.getStripeInvoicesPage('cus_page_test', threshold, 'in_cursor'); + + expect(listSpy).toHaveBeenCalledWith( + expect.objectContaining({ + customer: 'cus_page_test', + limit: 25, + starting_after: 'in_cursor', + created: { gte: Math.floor(threshold.getTime() / 1000) }, + }) + ); + expect(result.hasMore).toBe(false); + expect(result.entries).toEqual([]); + expect(result.nextCursor).toBeNull(); + + listSpy.mockRestore(); + }); + } finally { + jest.resetModules(); + } + }); +}); diff --git a/apps/web/src/lib/stripe/index.ts b/apps/web/src/lib/stripe/index.ts index 15dffb87a9..dd6a131302 100644 --- a/apps/web/src/lib/stripe/index.ts +++ b/apps/web/src/lib/stripe/index.ts @@ -656,6 +656,7 @@ async function recordKiloclawEarlybirdPurchase(user: User, charge: Stripe.Charge } } +// old form: array limit 100, no hasMore; remove when every client pages. export async function getStripeInvoices( stripeCustomerId: string, dateThreshold?: Date | null @@ -675,7 +676,11 @@ export async function getStripeInvoices( const invoices = await client.invoices.list(listParams); const invoiceData: Stripe.Invoice[] = invoices.data; - return invoiceData.map(invoice => { + return mapStripeInvoicesToUnified(invoiceData); +} + +function mapStripeInvoicesToUnified(invoices: Stripe.Invoice[]): UnifiedInvoice[] { + return invoices.map(invoice => { // Classify as 'seats' if any line item has seats metadata or a known paid seat price ID const isSeatInvoice = invoice.lines?.data?.some(line => { @@ -703,6 +708,47 @@ export async function getStripeInvoices( }); } +export type StripeInvoicesPage = { + entries: UnifiedInvoice[]; + hasMore: boolean; + nextCursor: string | null; +}; + +export async function getStripeInvoicesPage( + stripeCustomerId: string, + dateThreshold?: Date | null, + startingAfter?: string | null +): Promise { + const listParams: Stripe.InvoiceListParams = { + customer: stripeCustomerId, + limit: 25, + expand: ['data.payment_intent', 'data.lines.data'], + }; + + if (dateThreshold) { + listParams.created = { + gte: Math.floor(dateThreshold.getTime() / 1000), // Convert to Unix timestamp + }; + } + + if (startingAfter) { + listParams.starting_after = startingAfter; + } + + const invoices = await client.invoices.list(listParams); + const entries = mapStripeInvoicesToUnified(invoices.data); + const lastInvoice = invoices.data[invoices.data.length - 1]; + + // Tie the cursor to Stripe's own continuation signal. A full final page has + // a last invoice but no next page, and advertising its id as a cursor makes + // the caller fetch an empty page it can never end on. + return { + entries, + hasMore: invoices.has_more, + nextCursor: invoices.has_more ? (lastInvoice?.id ?? null) : null, + }; +} + async function handlePaymentMethodEvent( event: | Stripe.PaymentMethodAttachedEvent diff --git a/apps/web/src/routers/organizations/organization-router.ts b/apps/web/src/routers/organizations/organization-router.ts index 2cce2120e8..fec7c18a5b 100644 --- a/apps/web/src/routers/organizations/organization-router.ts +++ b/apps/web/src/routers/organizations/organization-router.ts @@ -31,7 +31,7 @@ import { } from '@/lib/organizations/organizations'; import { getOrCreateStripeCustomerIdForOrganization } from '@/lib/organizations/organization-billing'; import { resolveEffectiveOrganizationSsoPolicy } from '@/lib/organizations/organization-sso-policy'; -import { getStripeInvoices } from '@/lib/stripe'; +import { getStripeInvoices, getStripeInvoicesPage } from '@/lib/stripe'; import { adminProcedure, baseProcedure, createTRPCRouter } from '@/lib/trpc/init'; import { OrganizationIdInputSchema, @@ -48,7 +48,10 @@ import { organizationsUsageDetailsRouter } from '@/routers/organizations/organiz import { TRPCError } from '@trpc/server'; import { and, asc, count, desc, eq, inArray, isNull, sql } from 'drizzle-orm'; import * as z from 'zod'; -import { getCreditTransactionsForOrganization } from '@/lib/creditTransactions'; +import { + getCreditTransactionsForOrganization, + getCreditTransactionsForOrganizationPage, +} from '@/lib/creditTransactions'; import { getCreditBlocks } from '@/lib/getCreditBlocks'; import { processOrganizationExpirations } from '@/lib/creditExpiration'; import { credit_transactions } from '@kilocode/db/schema'; @@ -105,6 +108,14 @@ const OrganizationInvoicesInputSchema = OrganizationIdInputSchema.extend({ period: TimePeriodSchema.optional().default('month'), }); +const OrganizationTransactionsPageInputSchema = OrganizationIdInputSchema.extend({ + cursor: z.string().optional(), +}); + +const OrganizationInvoicesPageInputSchema = OrganizationInvoicesInputSchema.extend({ + cursor: z.string().optional(), +}); + function daysAgo(days: number): Date { const now = new Date(); return new Date(now.getTime() - days * 24 * 60 * 60 * 1000); @@ -583,6 +594,15 @@ export const organizationsRouter = createTRPCRouter({ return await getCreditTransactionsForOrganization(opts.input.organizationId); }), + creditTransactionsPage: organizationMemberProcedure + .input(OrganizationTransactionsPageInputSchema) + .query(async opts => { + return await getCreditTransactionsForOrganizationPage( + opts.input.organizationId, + opts.input.cursor + ); + }), + getCreditBlocks: organizationMemberProcedure.query(async opts => { const now = new Date(); const organizationId = opts.input.organizationId; @@ -648,4 +668,25 @@ export const organizationsRouter = createTRPCRouter({ const invoices = await getStripeInvoices(stripeId, dateThreshold); return invoices; }), + + invoicesPage: organizationBillingProcedure + .input(OrganizationInvoicesPageInputSchema) + .query(async opts => { + const organization = await getOrganizationById(opts.input.organizationId); + if (!organization) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Organization not found', + }); + } + + const dateThreshold = getDateThreshold(opts.input.period); + + let stripeId = organization.stripe_customer_id; + if (!stripeId) { + stripeId = await getOrCreateStripeCustomerIdForOrganization(opts.input.organizationId); + } + + return await getStripeInvoicesPage(stripeId, dateThreshold, opts.input.cursor); + }), });