From 2b2e41a7f14eb61754389b0ee62e39aedf4b2fe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 25 Aug 2026 02:51:54 +0200 Subject: [PATCH 01/22] fix(mobile): parse raw HTTP and route params --- .../[owner]/[repo]/[number]/index.tsx | 15 ++++- .../agents/mobile-session-manager.test.ts | 32 +++++++++++ .../agents/mobile-session-manager.ts | 19 +++++-- apps/mobile/src/lib/auth/admission.test.ts | 23 ++++++++ apps/mobile/src/lib/auth/admission.ts | 4 +- .../lib/hooks/use-available-models.test.ts | 56 ++++++++++++++++++- .../src/lib/hooks/use-available-models.ts | 32 ++++++++++- 7 files changed, 169 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/index.tsx b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/index.tsx index b38a5c9be7..6d03b828fd 100644 --- a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/index.tsx +++ b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/index.tsx @@ -1,6 +1,8 @@ -import { Stack, useLocalSearchParams } from 'expo-router'; +import { type Href, Stack, useLocalSearchParams } from 'expo-router'; +import { InvalidRouteState } from '@/components/invalid-route-state'; import { PrReviewScreen } from '@/components/pr-review/pr-review-screen'; +import { parseParam } from '@/lib/route-params'; type Params = { owner: string; @@ -10,12 +12,19 @@ type Params = { export default function PrReviewNumberIndexRoute() { const { owner, repo, number } = useLocalSearchParams(); - const numberValue = Number.parseInt(number, 10); + const parsedOwner = parseParam(owner); + const parsedRepo = parseParam(repo); + const rawNumber = parseParam(number); + const numberValue = rawNumber ? Number.parseInt(rawNumber, 10) : Number.NaN; + + if (!parsedOwner || !parsedRepo || !Number.isInteger(numberValue) || numberValue <= 0) { + return ; + } return ( <> - + ); } diff --git a/apps/mobile/src/components/agents/mobile-session-manager.test.ts b/apps/mobile/src/components/agents/mobile-session-manager.test.ts index 742095ce49..aa98bf3635 100644 --- a/apps/mobile/src/components/agents/mobile-session-manager.test.ts +++ b/apps/mobile/src/components/agents/mobile-session-manager.test.ts @@ -87,6 +87,7 @@ const { fetchSessionWithNotFoundRetry, isCloudPrepareRetryableError, readFetchSessionErrorCode, + StreamTicketResponseSchema, } = await import('@/components/agents/mobile-session-manager'); const SESSION_ID = 'ses_test_session_id_0000000001' as KiloSessionId; @@ -372,6 +373,37 @@ describe('fetchSessionWithNotFoundRetry', () => { }); }); +describe('StreamTicketResponseSchema', () => { + it('parses a valid body with a numeric expiresAt', () => { + expect( + StreamTicketResponseSchema.parse({ ticket: 'ticket-123', expiresAt: 1_700_000_000 }) + ).toEqual({ ticket: 'ticket-123', expiresAt: 1_700_000_000 }); + }); + + it('rejects a non-string ticket', () => { + expect(() => StreamTicketResponseSchema.parse({ ticket: 123, expiresAt: 1 })).toThrow(); + }); + + it('rejects a non-numeric expiresAt', () => { + expect(() => + StreamTicketResponseSchema.parse({ ticket: 'ticket-123', expiresAt: 'soon' }) + ).toThrow(); + }); + + it('permits missing optional fields — the required-field check runs after parse', () => { + expect(StreamTicketResponseSchema.parse({})).toEqual({}); + }); + + it('ignores extra fields', () => { + expect( + StreamTicketResponseSchema.parse({ ticket: 'ticket-123', expiresAt: 1, extra: true }) + ).toEqual({ + ticket: 'ticket-123', + expiresAt: 1, + }); + }); +}); + describe('getTicket', () => { const CLOUD_AGENT_ID = 'agent_12345678-1234-1234-1234-123456789abc'; diff --git a/apps/mobile/src/components/agents/mobile-session-manager.ts b/apps/mobile/src/components/agents/mobile-session-manager.ts index 6a45cb6dd4..175226a2ce 100644 --- a/apps/mobile/src/components/agents/mobile-session-manager.ts +++ b/apps/mobile/src/components/agents/mobile-session-manager.ts @@ -27,6 +27,7 @@ import { createNativeUserWebConnectionLifecycleHooks } from '@/lib/user-web-conn import { cacheToolAttachment } from '@/components/agents/tool-card-image-cache'; import { cacheFilePart } from '@/components/agents/file-part-cache'; import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; +import * as z from 'zod'; import { i18n } from '@/i18n'; type SessionWithRuntimeState = @@ -59,6 +60,18 @@ const CLOUD_PREPARE_TRANSIENT_CODES = new Set([ /** Stable message the ledger returns on a same-key in-flight duplicate (plan P1-A-08b). */ const CLOUD_PREPARE_IN_PROGRESS_MESSAGE = 'creation_in_progress'; +/** + * Wire contract for the cloud-agent stream-ticket endpoint. `expiresAt` is the + * Unix-epoch number `signStreamTicket` returns. All fields are optional here; + * the required-field check below rejects an otherwise-valid object missing + * `ticket` or `expiresAt`. + */ +export const StreamTicketResponseSchema = z.object({ + ticket: z.string().optional(), + expiresAt: z.number().optional(), + error: z.string().optional(), +}); + /** * True when a `prepareSession` failure may be retried with the SAME * `operationKey`: `creation_in_progress`, a transient 5xx, or a codeless @@ -207,11 +220,7 @@ export function createMobileAgentSessionManager({ body: JSON.stringify(body), } ); - const data = (await response.json()) as { - ticket?: string; - expiresAt?: number; - error?: string; - }; + const data = StreamTicketResponseSchema.parse(await response.json()); if (!response.ok) { throw new Error(data.error ?? 'Failed to get stream ticket'); } diff --git a/apps/mobile/src/lib/auth/admission.test.ts b/apps/mobile/src/lib/auth/admission.test.ts index 3558f3beea..1f185a2709 100644 --- a/apps/mobile/src/lib/auth/admission.test.ts +++ b/apps/mobile/src/lib/auth/admission.test.ts @@ -7,6 +7,7 @@ import { Platform } from 'react-native'; import { ADMISSION_CHALLENGE_FAILED, + AdmissionChallengeResponseSchema, clearAttestKeyOnRefusal, hasAttestationCapability, } from './admission'; @@ -78,6 +79,28 @@ function invalidKeyError() { }); } +describe('AdmissionChallengeResponseSchema', () => { + it('parses a valid challenge body', () => { + expect(AdmissionChallengeResponseSchema.parse({ challenge: 'server-challenge' })).toEqual({ + challenge: 'server-challenge', + }); + }); + + it('rejects a missing challenge field', () => { + expect(() => AdmissionChallengeResponseSchema.parse({})).toThrow(); + }); + + it('rejects an empty challenge string', () => { + expect(() => AdmissionChallengeResponseSchema.parse({ challenge: '' })).toThrow(); + }); + + it('ignores extra fields', () => { + expect( + AdmissionChallengeResponseSchema.parse({ challenge: 'server-challenge', extra: true }) + ).toEqual({ challenge: 'server-challenge' }); + }); +}); + describe('hasAttestationCapability', () => { afterEach(() => { vi.mocked(Platform).OS = 'ios'; diff --git a/apps/mobile/src/lib/auth/admission.ts b/apps/mobile/src/lib/auth/admission.ts index 4bc74f0598..d04a54b660 100644 --- a/apps/mobile/src/lib/auth/admission.ts +++ b/apps/mobile/src/lib/auth/admission.ts @@ -49,9 +49,11 @@ async function requestChallenge(): Promise<{ challenge: string }> { throw new Error(ADMISSION_CHALLENGE_FAILED); } - return response.json() as Promise<{ challenge: string }>; + return AdmissionChallengeResponseSchema.parse(await response.json()); } +export const AdmissionChallengeResponseSchema = z.object({ challenge: z.string().min(1) }); + const invalidKeyErrorSchema = z.object({ code: z.literal('ERR_APP_INTEGRITY_INVALID_KEY') }); function isInvalidKeyError(error: unknown): boolean { diff --git a/apps/mobile/src/lib/hooks/use-available-models.test.ts b/apps/mobile/src/lib/hooks/use-available-models.test.ts index aceb1df4a6..b7cffba752 100644 --- a/apps/mobile/src/lib/hooks/use-available-models.test.ts +++ b/apps/mobile/src/lib/hooks/use-available-models.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; -import { toModelOptions } from './use-available-models'; +import { + OpenRouterModelsResponseSchema, + OrganizationDefaultsResponseSchema, + toModelOptions, +} from './use-available-models'; // Stub native/expo modules so pure-node Vitest can resolve the // module graph when importing from use-available-models.ts. @@ -34,3 +38,53 @@ describe('toModelOptions', () => { expect(toModelOptions(undefined)).toEqual([]); }); }); + +describe('OpenRouterModelsResponseSchema', () => { + it('parses a valid body with a data array', () => { + const body = { + data: [ + { + id: 'priced/model', + name: 'Display: Priced Model', + pricing: { prompt: '0.00000175', completion: '0.000014' }, + preferredIndex: 0, + opencode: { variants: { high: {} } }, + }, + ], + }; + expect(OpenRouterModelsResponseSchema.parse(body)).toEqual(body); + }); + + it('rejects a body missing the data field', () => { + expect(() => OpenRouterModelsResponseSchema.parse({})).toThrow(); + }); + + it('rejects a data array entry missing the id field', () => { + expect(() => OpenRouterModelsResponseSchema.parse({ data: [{ name: 'Noid' }] })).toThrow(); + }); + + it('ignores extra fields on a data entry', () => { + const parsed = OpenRouterModelsResponseSchema.parse({ + data: [{ id: 'a', name: 'A', someUnknown: true }], + }); + expect(parsed.data[0]).toEqual({ id: 'a', name: 'A' }); + }); +}); + +describe('OrganizationDefaultsResponseSchema', () => { + it('parses a valid defaultModel body', () => { + expect(OrganizationDefaultsResponseSchema.parse({ defaultModel: 'priced/model' })).toEqual({ + defaultModel: 'priced/model', + }); + }); + + it('rejects a body missing defaultModel', () => { + expect(() => OrganizationDefaultsResponseSchema.parse({})).toThrow(); + }); + + it('ignores extra fields', () => { + expect(OrganizationDefaultsResponseSchema.parse({ defaultModel: 'm', extra: true })).toEqual({ + defaultModel: 'm', + }); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-available-models.ts b/apps/mobile/src/lib/hooks/use-available-models.ts index 09d87491c0..15e403d760 100644 --- a/apps/mobile/src/lib/hooks/use-available-models.ts +++ b/apps/mobile/src/lib/hooks/use-available-models.ts @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; +import * as z from 'zod'; import { API_BASE_URL } from '@/lib/config'; import { getAuthTokenForRequest } from '@/lib/auth/token-owner'; @@ -101,6 +102,33 @@ export function thinkingEffortLabel(variant: string): string { const MODEL_REQUEST_TIMEOUT_MS = 15_000; +/** + * Wire contract for the openrouter / org models endpoints. The response is a + * `data` array of model descriptors matching the `ModelResponse` fields. + */ +export const OpenRouterModelsResponseSchema = z.object({ + data: z.array( + z.object({ + id: z.string(), + name: z.string(), + isFree: z.boolean().optional(), + mayTrainOnYourPrompts: z.boolean().optional(), + hasUserByokAvailable: z.boolean().optional(), + context_length: z.number().nullable().optional(), + preferredIndex: z.number().optional(), + pricing: z + .object({ prompt: z.string().optional(), completion: z.string().optional() }) + .optional(), + opencode: z.object({ variants: z.record(z.string(), z.unknown()).optional() }).optional(), + }) + ), +}); + +/** + * Wire contract for the organization defaults endpoint. + */ +export const OrganizationDefaultsResponseSchema = z.object({ defaultModel: z.string() }); + async function fetchModels(organizationId: string | undefined): Promise { const token = await getAuthTokenForRequest(); const url = organizationId @@ -123,7 +151,7 @@ async function fetchModels(organizationId: string | undefined): Promise Date: Tue, 25 Aug 2026 04:07:09 +0200 Subject: [PATCH 02/22] refactor(mobile): infer review contracts and scope platforms --- .../pr-review/merge/pr-merge-sheet.tsx | 25 ++---- .../pr-review/pr-review-checks-section.tsx | 12 +-- .../src/lib/code-reviewer-config.test.ts | 48 +++++++++-- apps/mobile/src/lib/code-reviewer-config.ts | 82 +++++++++++++------ .../mobile/src/lib/hooks/use-code-reviewer.ts | 16 ++-- apps/mobile/src/lib/hooks/use-code-reviews.ts | 10 +-- .../lib/hooks/use-reviewer-route-params.ts | 12 +-- .../pr-review/merge/merge-blocked-reasons.ts | 39 ++------- .../lib/pr-review/merge/merge-result-gate.ts | 14 +++- .../pr-review/merge/use-pr-merge-mutations.ts | 13 +-- .../lib/pr-review/use-pr-review-mutations.ts | 37 ++------- 11 files changed, 148 insertions(+), 160 deletions(-) diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx index 4a083f18cf..2de1156233 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx @@ -18,6 +18,8 @@ import { Alert, Keyboard, ScrollView, type TextInput, useWindowDimensions } from import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { type inferRouterInputs, type MobileRouter } from '@kilocode/trpc/mobile'; + import { PrFormSheetHeader } from '@/components/pr-review/pr-form-sheet-chrome'; import { type AllowedMergeMethod, @@ -73,26 +75,9 @@ type PrMergeSheetProps = Readonly<{ onDismiss: () => void; }>; -type MergePullRequestInput = { - owner: string; - repo: string; - number: number; - method: 'merge' | 'squash' | 'rebase'; - commitTitle?: string; - commitMessage?: string; - deleteBranch: boolean; - expectedHeadSha: string; -}; - -type AutoMergeInput = { - owner: string; - repo: string; - number: number; - prNodeId: string; - method?: 'MERGE' | 'SQUASH' | 'REBASE'; - commitTitle?: string; - commitMessage?: string; -}; +type RouterInputs = inferRouterInputs; +type MergePullRequestInput = RouterInputs['githubPrReview']['mergePullRequest']; +type AutoMergeInput = RouterInputs['githubPrReview']['enableAutoMerge']; /** * Wraps an uncontrolled-input ref so every `.current` write (the parts file's diff --git a/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx b/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx index 7232d7bdc9..abf6cf935b 100644 --- a/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query'; +import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; import { AlertTriangle, CheckCircle2, @@ -22,6 +23,9 @@ import { useTRPC } from '@/lib/trpc'; import { cn } from '@/lib/utils'; import { openExternalUrl } from '@/lib/external-link'; +type RouterOutputs = inferRouterOutputs; +type CheckRun = RouterOutputs['githubPrReview']['listChecks']['checkRuns'][number]; + type PrReviewChecksSectionProps = { readonly owner: string; readonly repo: string; @@ -30,14 +34,6 @@ type PrReviewChecksSectionProps = { readonly headSha: string; }; -type CheckRun = { - name: string; - status: string; - conclusion: string | null; - detailsUrl: string | null; - appName: string | null; -}; - type CheckTone = 'success' | 'failure' | 'pending' | 'skipped' | 'neutral' | 'warning'; function classifyCheckTone(status: string, conclusion: string | null): CheckTone { diff --git a/apps/mobile/src/lib/code-reviewer-config.test.ts b/apps/mobile/src/lib/code-reviewer-config.test.ts index 8eb700eebd..ab52b42a4c 100644 --- a/apps/mobile/src/lib/code-reviewer-config.test.ts +++ b/apps/mobile/src/lib/code-reviewer-config.test.ts @@ -1,17 +1,35 @@ import { describe, expect, it } from 'vitest'; -import { parseReviewerPlatform, PERSONAL_SCOPE } from './code-reviewer-config'; +import { parseReviewerPlatform, PERSONAL_SCOPE, toPersonalPlatform } from './code-reviewer-config'; describe('parseReviewerPlatform', () => { - it('allows every platform for an organization scope', () => { - expect(parseReviewerPlatform('org-1', 'github')).toBe('github'); - expect(parseReviewerPlatform('org-1', 'gitlab')).toBe('gitlab'); - expect(parseReviewerPlatform('org-1', 'bitbucket')).toBe('bitbucket'); + it('returns an org platform object for every platform', () => { + expect(parseReviewerPlatform('org-1', 'github')).toEqual({ + kind: 'org', + organizationId: 'org-1', + platform: 'github', + }); + expect(parseReviewerPlatform('org-1', 'gitlab')).toEqual({ + kind: 'org', + organizationId: 'org-1', + platform: 'gitlab', + }); + expect(parseReviewerPlatform('org-1', 'bitbucket')).toEqual({ + kind: 'org', + organizationId: 'org-1', + platform: 'bitbucket', + }); }); - it('allows github and gitlab for the personal scope', () => { - expect(parseReviewerPlatform(PERSONAL_SCOPE, 'github')).toBe('github'); - expect(parseReviewerPlatform(PERSONAL_SCOPE, 'gitlab')).toBe('gitlab'); + it('returns a personal platform object for github and gitlab', () => { + expect(parseReviewerPlatform(PERSONAL_SCOPE, 'github')).toEqual({ + kind: 'personal', + platform: 'github', + }); + expect(parseReviewerPlatform(PERSONAL_SCOPE, 'gitlab')).toEqual({ + kind: 'personal', + platform: 'gitlab', + }); }); it('rejects bitbucket for the personal scope (org-only platform)', () => { @@ -28,3 +46,17 @@ describe('parseReviewerPlatform', () => { expect(parseReviewerPlatform('org-1', ['github', 'gitlab'])).toBeNull(); }); }); + +describe('toPersonalPlatform', () => { + it('passes github and gitlab through unchanged', () => { + expect(toPersonalPlatform('github')).toBe('github'); + expect(toPersonalPlatform('gitlab')).toBe('gitlab'); + }); + + it('throws on bitbucket instead of rewriting it to github', () => { + // A personal-scope config must never alias Bitbucket to GitHub; the + // narrowing helper throws so the bad argument cannot silently target + // another platform's config. + expect(() => toPersonalPlatform('bitbucket')).toThrow(); + }); +}); diff --git a/apps/mobile/src/lib/code-reviewer-config.ts b/apps/mobile/src/lib/code-reviewer-config.ts index 4f42366d5d..d89ddada26 100644 --- a/apps/mobile/src/lib/code-reviewer-config.ts +++ b/apps/mobile/src/lib/code-reviewer-config.ts @@ -1,8 +1,10 @@ import { + type CodeReviewConfigPatch, type CodeReviewPlatform, type RepositoryModelOverrideInput, } from '@kilocode/app-shared/code-review'; -import { type CodeReviewActionRequiredState } from '@kilocode/app-shared/code-reviews'; + +import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; import { parseParam } from '@/lib/route-params'; @@ -66,6 +68,16 @@ export function reviewerPlatformLabel(platform: string): string { : platform; } +/** + * A route's validated scope+platform combination, as a discriminated union. + * Personal Bitbucket is impossible (`bitbucket` is org-only per + * PLATFORM_CAPABILITIES); the `kind: 'personal'` variant carries only + * `github | gitlab`, so a personal+bitbucket object is not representable. + */ +export type ReviewerScopePlatform = + | { kind: 'personal'; platform: 'github' | 'gitlab' } + | { kind: 'org'; organizationId: string; platform: ReviewerPlatform }; + /** * Strictly parses a route's platform segment against the supported * scope+platform combinations. Replaces the old `asReviewerPlatform` @@ -78,38 +90,56 @@ export function reviewerPlatformLabel(platform: string): string { export function parseReviewerPlatform( scope: string, rawPlatform: string | string[] | undefined -): ReviewerPlatform | null { +): ReviewerScopePlatform | null { const platform = parseParam(rawPlatform, REVIEWER_PLATFORMS); - if (platform && PLATFORM_CAPABILITIES[platform].scopes === 'org' && scope === PERSONAL_SCOPE) { + if (!platform) { return null; } + if (scope === PERSONAL_SCOPE) { + // Bitbucket is org-only; a personal-scope Bitbucket route is invalid. + if (platform === 'bitbucket') { + return null; + } + return { kind: 'personal', platform }; + } + return { kind: 'org', organizationId: scope, platform }; +} + +/** + * Narrows a `ReviewerPlatform` to what the personal procedures accept: the + * personal router only serves github/gitlab (bitbucket is org-only by UI + * construction). This must never alias bitbucket to github — a + * personal+bitbucket argument is a programming error (the route validator + * rejects it upstream), so it throws rather than silently read or mutate + * another platform's config. + */ +export function toPersonalPlatform(platform: ReviewerPlatform): 'github' | 'gitlab' { + if (platform === 'bitbucket') { + throw new Error('Bitbucket is not available for personal code review'); + } return platform; } -export type ReviewConfigData = { - isEnabled: boolean; - reviewStyle: 'strict' | 'balanced' | 'lenient' | 'roast'; - focusAreas: string[]; - customInstructions: string | null; - modelSlug: string; - thinkingEffort: string | null; - gateThreshold: 'off' | 'all' | 'warning' | 'critical'; - repositorySelectionMode: 'all' | 'selected'; +type RouterOutputs = inferRouterOutputs; + +type PersonalReviewConfig = RouterOutputs['personalReviewAgent']['getReviewConfig']; +type OrgReviewConfig = RouterOutputs['organizations']['reviewAgent']['getReviewConfig']; + +// The two getReviewConfig outputs differ only in their id-carrying fields: +// personal GitHub/GitLab ids are numeric, while org Bitbucket ids are UUID +// strings. Every other field is shared. The optimistic cache writes keep a +// single mixed `(number | string)[]` selection (and mixed-id model overrides) +// for both scopes, so derive the union from the router outputs and widen just +// those two fields to the form the cache already uses. +type ReviewConfigIdFields = { selectedRepositoryIds: (number | string)[]; repositoryModelOverrides: RepositoryModelOverrideInput[]; - disableReviewMd: boolean; - actionRequired: CodeReviewActionRequiredState | null; }; -export type ConfigPatch = Partial<{ - reviewStyle: ReviewConfigData['reviewStyle']; - focusAreas: string[]; - customInstructions: string; - modelSlug: string; - thinkingEffort: string | null; - gateThreshold: ReviewConfigData['gateThreshold']; - repositorySelectionMode: ReviewConfigData['repositorySelectionMode']; - selectedRepositoryIds: (number | string)[]; - repositoryModelOverrides: RepositoryModelOverrideInput[]; - disableReviewMd: boolean; -}>; +export type ReviewConfigData = + | (Omit & ReviewConfigIdFields) + | (Omit & ReviewConfigIdFields); + +// The save/optimistic-cache patch. Kept as the shared app-shared contract so +// the personal and org save paths cannot drift apart. +export type ConfigPatch = CodeReviewConfigPatch; diff --git a/apps/mobile/src/lib/hooks/use-code-reviewer.ts b/apps/mobile/src/lib/hooks/use-code-reviewer.ts index cff2d3f298..f411e59d55 100644 --- a/apps/mobile/src/lib/hooks/use-code-reviewer.ts +++ b/apps/mobile/src/lib/hooks/use-code-reviewer.ts @@ -12,6 +12,7 @@ import { PERSONAL_SCOPE, type ReviewConfigData, type ReviewerPlatform, + toPersonalPlatform, } from '@/lib/code-reviewer-config'; import { isLatestMutationGeneration, @@ -21,7 +22,7 @@ import { chainSave } from '@/lib/hooks/save-chain'; import { trpcClient, useTRPC } from '@/lib/trpc'; import { pick } from '@/lib/utils'; -export { PERSONAL_SCOPE }; +export { PERSONAL_SCOPE, toPersonalPlatform }; export function isPersonal(scope: string) { return scope === PERSONAL_SCOPE; @@ -33,14 +34,6 @@ export function isPersonal(scope: string) { // save-chain.ts). It's module-level there (not per-hook-instance) so it // holds across remounts of the same screen. -// The personal router only serves github/gitlab (bitbucket is org-only by UI -// construction). This narrows a ReviewerPlatform down to what the personal -// procedures accept, without an `as` cast — the 'bitbucket' branch is dead -// whenever scope is actually personal. -export function toPersonalPlatform(platform: ReviewerPlatform): 'github' | 'gitlab' { - return platform === 'bitbucket' ? 'github' : platform; -} - /** * Narrows a mixed `number | string` id array down to numeric ids. The * personal schema only accepts numeric repository IDs (bitbucket, the only @@ -124,7 +117,10 @@ export function useReviewConfig( const trpc = useTRPC(); const personal = useQuery({ ...trpc.personalReviewAgent.getReviewConfig.queryOptions({ - platform: toPersonalPlatform(platform), + // toPersonalPlatform throws on bitbucket, so only invoke it for the + // actually-active personal scope. The personal query under an org scope + // is disabled and never sent; its input is a harmless default. + platform: isPersonal(scope) ? toPersonalPlatform(platform) : 'github', }), enabled: isPersonal(scope), }); diff --git a/apps/mobile/src/lib/hooks/use-code-reviews.ts b/apps/mobile/src/lib/hooks/use-code-reviews.ts index fc587187e2..a0c55c7c1e 100644 --- a/apps/mobile/src/lib/hooks/use-code-reviews.ts +++ b/apps/mobile/src/lib/hooks/use-code-reviews.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { hasInFlightReview, isInFlightReviewStatus } from '@kilocode/app-shared/code-review'; +import { type inferRouterInputs, type MobileRouter } from '@kilocode/trpc/mobile'; import { announcingToast } from '@/lib/a11y/announcing-toast'; import { PERSONAL_SCOPE } from '@/lib/hooks/use-code-reviewer'; import { trpcClient, useTRPC } from '@/lib/trpc'; @@ -116,13 +117,8 @@ export function useRetriggerReview(scope: string) { }); } -type CreateManualReviewInput = { - platform: 'github' | 'gitlab'; - url: string; - modelSlug: string; - thinkingEffort?: string | null; - instructions?: string; -}; +type RouterInputs = inferRouterInputs; +type CreateManualReviewInput = RouterInputs['personalReviewAgent']['createManualReviewJob']; export async function createManualReviewMutationFn(scope: string, vars: CreateManualReviewInput) { // Same typed-error pattern: a domain failure throws so the screen's diff --git a/apps/mobile/src/lib/hooks/use-reviewer-route-params.ts b/apps/mobile/src/lib/hooks/use-reviewer-route-params.ts index 4221292436..ebedd7fc4d 100644 --- a/apps/mobile/src/lib/hooks/use-reviewer-route-params.ts +++ b/apps/mobile/src/lib/hooks/use-reviewer-route-params.ts @@ -1,6 +1,6 @@ import { useLocalSearchParams } from 'expo-router'; -import { parseReviewerPlatform, type ReviewerPlatform } from '@/lib/code-reviewer-config'; +import { parseReviewerPlatform, type ReviewerScopePlatform } from '@/lib/code-reviewer-config'; import { parseParam } from '@/lib/route-params'; /** @@ -10,18 +10,14 @@ import { parseParam } from '@/lib/route-params'; * `parseReviewerPlatform`), so callers can render a single invalid-route * fallback instead of duplicating this parse+guard preamble per screen. */ -export function useValidatedReviewerRouteParams(): { - scope: string; - platform: ReviewerPlatform; -} | null { +export function useValidatedReviewerRouteParams(): ReviewerScopePlatform | null { const { scope: rawScope, platform: rawPlatform } = useLocalSearchParams<{ scope: string; platform: string; }>(); const scope = parseParam(rawScope); - const platform = scope ? parseReviewerPlatform(scope, rawPlatform) : null; - if (!scope || !platform) { + if (!scope) { return null; } - return { scope, platform }; + return parseReviewerPlatform(scope, rawPlatform); } diff --git a/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.ts b/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.ts index 7c8542aa37..2879a3c18f 100644 --- a/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.ts +++ b/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.ts @@ -6,37 +6,16 @@ // pulling in lucide-react-native (whose ESM build uses `import.meta` in // ways the repo's vitest setup doesn't transform). -export type PrMergeMethod = 'merge' | 'squash' | 'rebase'; -type PrReviewDecision = 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | null; +import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; -export type PrOverviewRepoSettings = { - allowMergeCommit: boolean; - allowSquashMerge: boolean; - allowRebaseMerge: boolean; - allowAutoMerge: boolean; - deleteBranchOnMerge: boolean; - allowUpdateBranch: boolean; - viewerCanPush: boolean; - viewerCanAdmin: boolean; -}; +export type PrMergeMethod = 'merge' | 'squash' | 'rebase'; -export type PrOverviewDto = { - state: 'open' | 'closed' | 'merged'; - draft: boolean; - baseRef: string; - headRef: string; - isCrossRepo: boolean; - headSha: string; - prNodeId: string; - title: string; - bodyMarkdown: string | null; - number: number; - mergeable: boolean | null; - mergeableState: string | null; - autoMerge: { method: string } | null; - reviewDecision: PrReviewDecision; - repo: PrOverviewRepoSettings; -}; +type RouterOutputs = inferRouterOutputs; +export type PrOverviewDto = RouterOutputs['githubPrReview']['getPullRequest']; +// The repo settings the merge surfaces consume. Derived from the overview DTO; +// `viewerLogin` is carried on the wire but never read by mobile, so it is +// dropped here (keeping the existing test/mock object literals valid). +export type PrOverviewRepoSettings = Omit; type MergeabilityStatus = 'unknown' | 'blocked' | 'mergeable' | 'terminal'; @@ -65,7 +44,7 @@ export type MergeBlockedReasonsArgs = { draft: PrOverviewDto['draft']; mergeable: PrOverviewDto['mergeable']; mergeableState: PrOverviewDto['mergeableState']; - reviewDecision: PrReviewDecision; + reviewDecision: PrOverviewDto['reviewDecision']; allowUpdateBranch: boolean; }; diff --git a/apps/mobile/src/lib/pr-review/merge/merge-result-gate.ts b/apps/mobile/src/lib/pr-review/merge/merge-result-gate.ts index 1557931bad..645df429cf 100644 --- a/apps/mobile/src/lib/pr-review/merge/merge-result-gate.ts +++ b/apps/mobile/src/lib/pr-review/merge/merge-result-gate.ts @@ -28,11 +28,21 @@ // server widens `merged = Boolean(response.data.merged)` and the // subsequent branches only narrow the other fields. +import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; + import { MergeNotCompletedError } from './merge-result-error'; +type RouterOutputs = inferRouterOutputs; + +// The inferred `mergePullRequest` output simplifies to the two variants with +// `merged: boolean` / `merged: true` — the server's `branchDeleteError` variant +// is a structural subtype of the first, so declaration emit folds it away. The +// merge gate narrows on `'branchDeleteError' in result` to surface the +// merged-but-branch-delete-failed banner, so that variant is re-added +// explicitly; the base stays derived from the router so the two stable shapes +// cannot drift from the server contract. export type MergePullRequestResult = - | { merged: boolean; sha: string; branchDeleted: false } - | { merged: true; sha: string; branchDeleted: true } + | RouterOutputs['githubPrReview']['mergePullRequest'] | { merged: true; sha: string; branchDeleted: false; branchDeleteError: string }; type MergeResultGate = diff --git a/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.ts b/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.ts index 0632298f5d..4851f27b46 100644 --- a/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.ts +++ b/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.ts @@ -15,6 +15,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { prIntentFingerprint } from '@kilocode/app-shared/pr-review'; +import { type inferRouterInputs, type MobileRouter } from '@kilocode/trpc/mobile'; import { announceForA11y } from '@/lib/a11y/announce'; import { announcingToast } from '@/lib/a11y/announcing-toast'; @@ -33,16 +34,8 @@ import { type PrRef = { owner: string; repo: string; number: number }; -type MergePullRequestInput = { - owner: string; - repo: string; - number: number; - method: 'merge' | 'squash' | 'rebase'; - commitTitle?: string; - commitMessage?: string; - deleteBranch: boolean; - expectedHeadSha: string; -}; +type RouterInputs = inferRouterInputs; +type MergePullRequestInput = RouterInputs['githubPrReview']['mergePullRequest']; function usePrRefKeys(ref: PrRef) { const trpc = useTRPC(); diff --git a/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts b/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts index 3dccffc711..9a317a2e24 100644 --- a/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts +++ b/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts @@ -23,6 +23,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { prIntentFingerprint } from '@kilocode/app-shared/pr-review'; +import { type inferRouterInputs, type MobileRouter } from '@kilocode/trpc/mobile'; import { announceForA11y } from '@/lib/a11y/announce'; import { announcingToast } from '@/lib/a11y/announcing-toast'; @@ -54,18 +55,11 @@ async function invalidateReviewCaches( ]); } -export type CreateReviewCommentInput = { - owner: string; - repo: string; - number: number; - body: string; - path: string; - line: number; - side: 'LEFT' | 'RIGHT'; - startLine?: number; - startSide?: 'LEFT' | 'RIGHT'; - commitSha: string; -}; +type RouterInputs = inferRouterInputs; + +export type CreateReviewCommentInput = RouterInputs['githubPrReview']['createReviewComment']; +export type SubmitReviewInput = RouterInputs['githubPrReview']['submitReview']; +export type SubmitReviewComment = NonNullable[number]; export function useCreateReviewCommentMutation(ref: PrRef) { const queryClient = useQueryClient(); @@ -103,25 +97,6 @@ export function useCreateReviewCommentMutation(ref: PrRef) { }); } -export type SubmitReviewComment = { - path: string; - line: number; - side: 'LEFT' | 'RIGHT'; - startLine?: number; - startSide?: 'LEFT' | 'RIGHT'; - body: string; -}; - -export type SubmitReviewInput = { - owner: string; - repo: string; - number: number; - event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT'; - body?: string; - commitSha: string; - comments?: SubmitReviewComment[]; -}; - export function useSubmitReviewMutation(ref: PrRef) { const queryClient = useQueryClient(); const keys = usePrRefKeys(ref); From 68211dc6d8a9b9e12b8bd64a6838c9f0a08f8847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 25 Aug 2026 05:07:56 +0200 Subject: [PATCH 03/22] feat(web): add org credit and invoice page procedures --- .../src/lib/creditTransactions.page.test.ts | 117 ++++++++++++++++++ apps/web/src/lib/creditTransactions.ts | 95 +++++++++++++- apps/web/src/lib/stripe/index.test.ts | 76 ++++++++++++ apps/web/src/lib/stripe/index.ts | 68 ++++++++++ .../organizations/organization-router.ts | 45 ++++++- 5 files changed, 398 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/lib/creditTransactions.page.test.ts 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..ec3a94205c --- /dev/null +++ b/apps/web/src/lib/creditTransactions.page.test.ts @@ -0,0 +1,117 @@ +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(25); + 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(); + }); +}); diff --git a/apps/web/src/lib/creditTransactions.ts b/apps/web/src/lib/creditTransactions.ts index 03b289786d..cdaf367ac3 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,71 @@ 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: number | null; + hasMore: boolean; + summary: CreditSummary; +}; + +export async function getCreditTransactionsForOrganizationPage( + organizationId: Organization['id'], + cursor: number = 0 +): Promise { + 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:%') + ) + ) + ) + .orderBy(desc(credit_transactions.created_at), desc(credit_transactions.id)) + .limit(CREDIT_TRANSACTIONS_PAGE_SIZE + 1) + .offset(cursor), + getCreditTransactionsSummaryForOrganization(organizationId), + ]); + + const hasMore = transactions.length > CREDIT_TRANSACTIONS_PAGE_SIZE; + const entries = transactions.slice(0, CREDIT_TRANSACTIONS_PAGE_SIZE); + + return { + entries, + nextCursor: hasMore ? cursor + CREDIT_TRANSACTIONS_PAGE_SIZE : 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..2f010834e7 100644 --- a/apps/web/src/lib/stripe/index.test.ts +++ b/apps/web/src/lib/stripe/index.test.ts @@ -62,6 +62,7 @@ import { processStripePaymentEventHook, handleSuccessfulChargeWithPayment, isCardFingerprintEligibleForFreeCredits, + getStripeInvoicesPage, } from '@/lib/stripe'; import { type User, @@ -3967,3 +3968,78 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', } ); }); + +describe('getStripeInvoicesPage', () => { + test('returns hasMore, entries, and nextCursor from the last invoice', async () => { + const { client } = await import('@/lib/stripe-client'); + + 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[]; + + const listSpy = jest.spyOn(client.invoices, 'list').mockResolvedValue({ + data: invoices, + has_more: true, + } as unknown as Awaited>); + + const result = await 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(); + }); + + test('passes starting_after and date threshold through to Stripe', async () => { + 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 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(); + }); +}); diff --git a/apps/web/src/lib/stripe/index.ts b/apps/web/src/lib/stripe/index.ts index 817dd6dcfa..0d6d0d6bb0 100644 --- a/apps/web/src/lib/stripe/index.ts +++ b/apps/web/src/lib/stripe/index.ts @@ -657,6 +657,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 @@ -704,6 +705,73 @@ export async function getStripeInvoices( }); } +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 => { + const hasSeatsMetadata = + line.metadata != null && Object.prototype.hasOwnProperty.call(line.metadata, 'seats'); + const priceId = line.pricing?.price_details?.price; + const hasSeatPriceId = priceId != null && KNOWN_SEAT_PRICE_IDS.has(priceId); + return hasSeatsMetadata || hasSeatPriceId; + }) ?? false; + + const firstLineDescription = invoice.lines?.data?.[0]?.description || null; + + return { + id: invoice.id || '', + number: invoice.number, + status: invoice.status || 'unknown', + amount_due: invoice.amount_due || 0, + currency: invoice.currency || 'usd', + created: invoice.created || 0, + hosted_invoice_url: invoice.hosted_invoice_url || null, + invoice_pdf: invoice.invoice_pdf || null, + invoice_type: isSeatInvoice ? 'seats' : 'topup', + description: firstLineDescription, + }; + }); +} + +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]; + + return { + entries, + hasMore: invoices.has_more, + nextCursor: lastInvoice ? lastInvoice.id : 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 12c8b006bf..8e5b803d61 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'; @@ -103,6 +106,14 @@ const OrganizationInvoicesInputSchema = OrganizationIdInputSchema.extend({ period: TimePeriodSchema.optional().default('month'), }); +const OrganizationTransactionsPageInputSchema = OrganizationIdInputSchema.extend({ + cursor: z.number().int().min(0).default(0), +}); + +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); @@ -579,6 +590,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; @@ -644,4 +664,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); + }), }); From 90b370214d21708f82908689aa79c94f7a9692cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 25 Aug 2026 06:44:35 +0200 Subject: [PATCH 04/22] fix(mobile): key credits queries by user id --- .../profile-credits-card.mounted.test.tsx | 221 ++++++++++++++---- .../src/components/profile-credits-card.tsx | 69 ++++-- 2 files changed, 228 insertions(+), 62 deletions(-) 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 5013109ae8..c4176dfc3c 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 && ( From eed9d2921a599dbefcb8f0e4d81e31c32e6a65a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 25 Aug 2026 07:27:13 +0200 Subject: [PATCH 05/22] feat(mobile): page org credits and invoices --- .../credit-activity-screen.mounted.test.tsx | 335 ++++++++++++++++++ .../organization/credit-activity-screen.tsx | 83 ++++- .../invoices-screen.mounted.test.tsx | 322 +++++++++++++++++ .../organization/invoices-screen.tsx | 79 ++++- apps/mobile/src/i18n/locales/en.json | 19 +- .../src/lib/hooks/use-organization-queries.ts | 57 ++- 6 files changed, 876 insertions(+), 19 deletions(-) create mode 100644 apps/mobile/src/components/organization/credit-activity-screen.mounted.test.tsx create mode 100644 apps/mobile/src/components/organization/invoices-screen.mounted.test.tsx 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..80b0cef88f --- /dev/null +++ b/apps/mobile/src/components/organization/credit-activity-screen.mounted.test.tsx @@ -0,0 +1,335 @@ +/* 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, + 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.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.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); + }); +}); diff --git a/apps/mobile/src/components/organization/credit-activity-screen.tsx b/apps/mobile/src/components/organization/credit-activity-screen.tsx index cf5309711b..aad760ffaa 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'; @@ -118,8 +120,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 || @@ -137,9 +144,28 @@ 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. + const isLaterPageError = query.isError && hasLoadedPages; let body: ReactNode = null; if (isLoading) { @@ -150,13 +176,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..af1e3bfef8 --- /dev/null +++ b/apps/mobile/src/components/organization/invoices-screen.mounted.test.tsx @@ -0,0 +1,322 @@ +/* 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, + 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.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.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); + }); +}); diff --git a/apps/mobile/src/components/organization/invoices-screen.tsx b/apps/mobile/src/components/organization/invoices-screen.tsx index cfa2fd3dd4..255051f535 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, @@ -179,16 +181,36 @@ 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. + const isLaterPageError = query.isError && hasLoadedPages; let body: ReactNode = null; if (isLoading) { @@ -199,13 +221,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/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index 00df8e4461..b4a63bfa7a 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -1781,15 +1781,24 @@ "newSession": { "title": "New session", "repository": "Repository", + "recentlyUsed": "Recently used", "couldNotLoadRepositories": "Couldn't load repositories", "connectGithub": "Connect GitHub", "connectGithubDescription": "Connect GitHub in your browser, then return here to pick a repository.", + "connectGitlab": "Connect GitLab", + "connectGitlabDescription": "Connect GitLab in your browser, then return here to pick a repository.", + "connectBitbucket": "Connect Bitbucket", + "connectBitbucketDescription": "Connect Bitbucket in your browser, then return here to pick a repository.", "openGithub": "Open GitHub", + "openGitlab": "Open GitLab", + "openBitbucket": "Open Bitbucket", "refreshRepositories": "Refresh repositories", "githubConnectionNotVisible": "We can't see your GitHub connection yet", "githubConnectionNotVisibleDescription": "If you installed or configured the Kilo GitHub App, check again — or make sure it was installed for this account/organization.", "checkAgain": "Check again", "githubConnected": "GitHub connected", + "gitlabConnected": "GitLab connected", + "bitbucketConnected": "Bitbucket connected", "noRepositoriesVisible": "No repositories visible. Check repository access for the Kilo GitHub App, then refresh.", "runOnWithTarget": "Run on: {{target}}", "environment": "Environment", @@ -2538,8 +2547,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" + "transactionFallback": "Credit transaction", + "truncated": "Older credit activity is available." }, "inviteMember": { "emailError": "Enter a valid email address", @@ -2556,10 +2568,13 @@ "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.", "statusOpen": "Open", "statusPaid": "Paid", "statusVoid": "Void", - "title": "Invoices" + "title": "Invoices", + "truncated": "Older invoices are available." }, "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..483ccfe664 100644 --- a/apps/mobile/src/lib/hooks/use-organization-queries.ts +++ b/apps/mobile/src/lib/hooks/use-organization-queries.ts @@ -1,5 +1,6 @@ import { canManageOrganizationBilling } from '@kilocode/app-shared/organizations'; -import { useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; import { useAuth } from '@/lib/auth/auth-context'; import { useOrganization } from '@/lib/organization-context'; @@ -153,6 +154,33 @@ export type CreditTransaction = NonNullable< ReturnType['data'] >[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(); + const query = useInfiniteQuery( + trpc.organizations.creditTransactionsPage.infiniteQueryOptions( + { organizationId: organizationId ?? '' }, + { + 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 function useOrgInvoices(organizationId: string | null) { const trpc = useTRPC(); return useQuery( @@ -164,3 +192,30 @@ export function useOrgInvoices(organizationId: string | null) { } export type OrgInvoice = NonNullable['data']>[number]; + +/** + * 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(); + const query = useInfiniteQuery( + trpc.organizations.invoicesPage.infiniteQueryOptions( + { organizationId: organizationId ?? '', period: 'year' }, + { + 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 }; +} From a6581492f53da64e6e3076c7d6eda4284cdaca41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 25 Aug 2026 08:00:59 +0200 Subject: [PATCH 06/22] fix(mobile): route list freshness and single home sessions query --- .../code-reviewer/review-list-screen.tsx | 2 ++ .../home/agent-sessions-section.test.ts | 9 -------- .../home/agent-sessions-section.tsx | 21 +++++++++--------- .../home/home-screen.mounted.test.tsx | 3 ++- .../src/components/home/home-screen.tsx | 22 +++++++++++++++++-- .../security-agent/finding-list-screen.tsx | 2 ++ 6 files changed, 37 insertions(+), 22 deletions(-) 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 c24affb144..53cced88ea 100644 --- a/apps/mobile/src/components/code-reviewer/review-list-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-list-screen.tsx @@ -18,6 +18,7 @@ import { Text } from '@/components/ui/text'; import { TabScreenScrollView } from '@/components/tab-screen'; 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; labels come from the shared @@ -50,6 +51,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/home/agent-sessions-section.test.ts b/apps/mobile/src/components/home/agent-sessions-section.test.ts index be0a804419..cd2a1bf02d 100644 --- a/apps/mobile/src/components/home/agent-sessions-section.test.ts +++ b/apps/mobile/src/components/home/agent-sessions-section.test.ts @@ -28,15 +28,6 @@ vi.mock('@/components/ui/text', () => ({ Text: () => null, })); -vi.mock('@/lib/hooks/use-agent-sessions', () => ({ - useAgentSessions: () => ({ - activeSessions: [], - storedSessions: [], - activeSessionIds: new Set(), - activeIsError: false, - }), -})); - function makeActive(over: Partial = {}): ActiveSession { return { id: 'a1', diff --git a/apps/mobile/src/components/home/agent-sessions-section.tsx b/apps/mobile/src/components/home/agent-sessions-section.tsx index 9778dfe142..8a8987acd7 100644 --- a/apps/mobile/src/components/home/agent-sessions-section.tsx +++ b/apps/mobile/src/components/home/agent-sessions-section.tsx @@ -8,11 +8,7 @@ import { expandPlatformFilter } from '@/components/agents/session-list-helpers'; import { StoredSessionRow } from '@/components/agents/session-row'; import { SectionHeader } from '@/components/home/section-header'; import { Text } from '@/components/ui/text'; -import { - type ActiveSession, - type StoredSession, - useAgentSessions, -} from '@/lib/hooks/use-agent-sessions'; +import { type ActiveSession, type StoredSession } from '@/lib/hooks/use-agent-sessions'; import { parseTimestamp } from '@/lib/utils'; const MAX_ROWS = 3; @@ -98,15 +94,20 @@ export function hasDisplayableAgentSessions( } type AgentSessionsSectionProps = { - organizationId: string | null; + activeSessions: ActiveSession[]; + storedSessions: StoredSession[]; + activeSessionIds: Set; + activeIsError: boolean; }; -export function AgentSessionsSection({ organizationId }: Readonly) { +export function AgentSessionsSection({ + activeSessions, + storedSessions, + activeSessionIds, + activeIsError, +}: Readonly) { const router = useRouter(); const { t } = useTranslation(); - const { activeSessions, storedSessions, activeSessionIds, activeIsError } = useAgentSessions({ - organizationId, - }); const navigateToSession = useAgentSessionNavigator(); const rows = buildRows({ activeSessions, storedSessions, activeSessionIds }); diff --git a/apps/mobile/src/components/home/home-screen.mounted.test.tsx b/apps/mobile/src/components/home/home-screen.mounted.test.tsx index dddb23b39d..4bb00384a2 100644 --- a/apps/mobile/src/components/home/home-screen.mounted.test.tsx +++ b/apps/mobile/src/components/home/home-screen.mounted.test.tsx @@ -57,9 +57,10 @@ vi.mock('@/components/ui/skeleton', () => ({ })); vi.mock('@/lib/hooks/use-agent-sessions', () => ({ useAgentSessions: () => ({ + storedSessions: [{}], activeSessions: [], + activeSessionIds: new Set(), isLoading: sessionsLoading.value, - storedSessions: [{}], storedIsError: storedIsError.value, storedIsSuccess: storedIsSuccess.value, activeIsError: activeIsError.value, diff --git a/apps/mobile/src/components/home/home-screen.tsx b/apps/mobile/src/components/home/home-screen.tsx index f3cec1887f..61e9f3d585 100644 --- a/apps/mobile/src/components/home/home-screen.tsx +++ b/apps/mobile/src/components/home/home-screen.tsx @@ -18,7 +18,11 @@ import { ProductChoices } from '@/components/home/product-choices'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { Skeleton } from '@/components/ui/skeleton'; -import { useAgentSessions } from '@/lib/hooks/use-agent-sessions'; +import { + type ActiveSession, + type StoredSession, + useAgentSessions, +} from '@/lib/hooks/use-agent-sessions'; import { useOrganization } from '@/lib/organization-context'; export function HomeScreen() { @@ -31,6 +35,7 @@ export function HomeScreen() { const { storedSessions, activeSessions, + activeSessionIds, isLoading: sessionsLoading, storedIsError, storedIsSuccess, @@ -84,6 +89,9 @@ export function HomeScreen() { {renderSessionsOrPromo({ hasAnySession, organizationId, + activeSessions, + storedSessions, + activeSessionIds, sessionsError: storedIsError, sessionsLoadedEmpty: storedIsSuccess && !hasAnySession, activeIsError, @@ -109,6 +117,9 @@ export function HomeScreen() { function renderSessionsOrPromo(params: { hasAnySession: boolean; organizationId: string | null; + activeSessions: ActiveSession[]; + storedSessions: StoredSession[]; + activeSessionIds: Set; sessionsError: boolean; sessionsLoadedEmpty: boolean; activeIsError: boolean; @@ -120,7 +131,14 @@ function renderSessionsOrPromo(params: { // have. The first-use promo only appears after a confirmed empty // response, never merely because the fetch hasn't succeeded yet. if (params.hasAnySession) { - return ; + return ( + + ); } if (params.sessionsError) { return ( 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..b29b60f4c2 100644 --- a/apps/mobile/src/components/security-agent/finding-list-screen.tsx +++ b/apps/mobile/src/components/security-agent/finding-list-screen.tsx @@ -27,6 +27,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 +68,7 @@ export function FindingListScreen({ scope, routeParams }: Readonly toSecurityFindingQuery(filters), [filters]); const findings = useSecurityFindings(scope, query); const capacity = useSecurityAnalysisCapacity(scope); + useRouteForegroundRefresh([[['securityAgent']]]); const slaEnabled = config.data?.slaEnabled ?? true; const hasAnalysisCapacity = From 560a62da03a056b1e8c97f7e11832bd2b5ea0f3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 25 Aug 2026 22:39:13 +0200 Subject: [PATCH 07/22] fix(mobile): narrow actionRequired reason across base merge --- apps/mobile/src/lib/code-reviewer-config.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/lib/code-reviewer-config.ts b/apps/mobile/src/lib/code-reviewer-config.ts index d89ddada26..7aa35990fe 100644 --- a/apps/mobile/src/lib/code-reviewer-config.ts +++ b/apps/mobile/src/lib/code-reviewer-config.ts @@ -3,6 +3,7 @@ import { type CodeReviewPlatform, type RepositoryModelOverrideInput, } from '@kilocode/app-shared/code-review'; +import { type CodeReviewActionRequiredState } from '@kilocode/app-shared/code-reviews'; import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; @@ -137,8 +138,10 @@ type ReviewConfigIdFields = { }; export type ReviewConfigData = - | (Omit & ReviewConfigIdFields) - | (Omit & ReviewConfigIdFields); + | (Omit & + ReviewConfigIdFields & { actionRequired: CodeReviewActionRequiredState | null }) + | (Omit & + ReviewConfigIdFields & { actionRequired: CodeReviewActionRequiredState | null }); // The save/optimistic-cache patch. Kept as the shared app-shared contract so // the personal and org save paths cannot drift apart. From 2fe9e8ee5862bf0846a117b18dfd3e026e1b3d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 25 Aug 2026 22:40:52 +0200 Subject: [PATCH 08/22] fix(mobile): narrow actionRequired reason across base merge --- apps/mobile/src/lib/code-reviewer-config.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/lib/code-reviewer-config.ts b/apps/mobile/src/lib/code-reviewer-config.ts index d89ddada26..7aa35990fe 100644 --- a/apps/mobile/src/lib/code-reviewer-config.ts +++ b/apps/mobile/src/lib/code-reviewer-config.ts @@ -3,6 +3,7 @@ import { type CodeReviewPlatform, type RepositoryModelOverrideInput, } from '@kilocode/app-shared/code-review'; +import { type CodeReviewActionRequiredState } from '@kilocode/app-shared/code-reviews'; import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; @@ -137,8 +138,10 @@ type ReviewConfigIdFields = { }; export type ReviewConfigData = - | (Omit & ReviewConfigIdFields) - | (Omit & ReviewConfigIdFields); + | (Omit & + ReviewConfigIdFields & { actionRequired: CodeReviewActionRequiredState | null }) + | (Omit & + ReviewConfigIdFields & { actionRequired: CodeReviewActionRequiredState | null }); // The save/optimistic-cache patch. Kept as the shared app-shared contract so // the personal and org save paths cannot drift apart. From 5ae0fb342fd598505b2db5d0886c57d6db3c2c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 25 Aug 2026 22:44:00 +0200 Subject: [PATCH 09/22] fix(mobile): narrow actionRequired reason across base merge --- apps/mobile/src/lib/code-reviewer-config.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/lib/code-reviewer-config.ts b/apps/mobile/src/lib/code-reviewer-config.ts index d89ddada26..7aa35990fe 100644 --- a/apps/mobile/src/lib/code-reviewer-config.ts +++ b/apps/mobile/src/lib/code-reviewer-config.ts @@ -3,6 +3,7 @@ import { type CodeReviewPlatform, type RepositoryModelOverrideInput, } from '@kilocode/app-shared/code-review'; +import { type CodeReviewActionRequiredState } from '@kilocode/app-shared/code-reviews'; import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; @@ -137,8 +138,10 @@ type ReviewConfigIdFields = { }; export type ReviewConfigData = - | (Omit & ReviewConfigIdFields) - | (Omit & ReviewConfigIdFields); + | (Omit & + ReviewConfigIdFields & { actionRequired: CodeReviewActionRequiredState | null }) + | (Omit & + ReviewConfigIdFields & { actionRequired: CodeReviewActionRequiredState | null }); // The save/optimistic-cache patch. Kept as the shared app-shared contract so // the personal and org save paths cannot drift apart. From 00c5edffd08dc531c6ffe99e6746f53754fabfc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 26 Aug 2026 00:44:34 +0200 Subject: [PATCH 10/22] test(web): fix stripe invoice page test spy isolation --- apps/web/src/lib/stripe/index.test.ts | 89 ++++++++++++++++----------- 1 file changed, 52 insertions(+), 37 deletions(-) diff --git a/apps/web/src/lib/stripe/index.test.ts b/apps/web/src/lib/stripe/index.test.ts index 2f010834e7..ba5832f8cf 100644 --- a/apps/web/src/lib/stripe/index.test.ts +++ b/apps/web/src/lib/stripe/index.test.ts @@ -62,7 +62,6 @@ import { processStripePaymentEventHook, handleSuccessfulChargeWithPayment, isCardFingerprintEligibleForFreeCredits, - getStripeInvoicesPage, } from '@/lib/stripe'; import { type User, @@ -3971,8 +3970,6 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', describe('getStripeInvoicesPage', () => { test('returns hasMore, entries, and nextCursor from the last invoice', async () => { - const { client } = await import('@/lib/stripe-client'); - const invoices = [ { id: 'in_page_1', @@ -4000,46 +3997,64 @@ describe('getStripeInvoicesPage', () => { }, ] as unknown as Stripe.Invoice[]; - const listSpy = jest.spyOn(client.invoices, 'list').mockResolvedValue({ - data: invoices, - has_more: true, - } as unknown as Awaited>); + try { + jest.resetModules(); + await jest.isolateModulesAsync(async () => { + const stripe = await import('@/lib/stripe'); + const { client } = await import('@/lib/stripe-client'); - const result = await getStripeInvoicesPage('cus_page_test'); + const listSpy = jest.spyOn(client.invoices, 'list').mockResolvedValue({ + data: invoices, + has_more: true, + } as unknown as Awaited>); - 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'); + 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(); + listSpy.mockRestore(); + }); + } finally { + jest.resetModules(); + } }); test('passes starting_after and date threshold through to Stripe', async () => { - 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 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(); + 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(); + listSpy.mockRestore(); + }); + } finally { + jest.resetModules(); + } }); }); From d0b6b13708d2d11545ca394d800a3c7c0b0cac1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 26 Aug 2026 01:12:15 +0200 Subject: [PATCH 11/22] fix(mobile): fix org ledger i18n format and unused exports --- apps/mobile/src/i18n/locales/en.json | 4 ++-- apps/mobile/src/lib/hooks/use-organization-queries.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index 21cc1c4f56..5d6d890454 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -2946,7 +2946,7 @@ "loadMoreFailed": "Couldn't load more.", "title": "Credit activity", "transactionFallback": "Credit transaction", -"truncated": "Older credit activity is available.", + "truncated": "Older credit activity is available.", "category": { "organization_custom": "Organization custom", "parent_to_child_transfer_in": "Parent to child transfer in", @@ -2973,7 +2973,7 @@ "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", + "loadMore": "Load more", "loadMoreFailed": "Couldn't load more.", "truncated": "Older invoices are available.", "title": "Invoices", diff --git a/apps/mobile/src/lib/hooks/use-organization-queries.ts b/apps/mobile/src/lib/hooks/use-organization-queries.ts index 483ccfe664..4693cc79ad 100644 --- a/apps/mobile/src/lib/hooks/use-organization-queries.ts +++ b/apps/mobile/src/lib/hooks/use-organization-queries.ts @@ -140,7 +140,7 @@ export function useOrgUsageStats(organizationId: string | null) { ); } -export function useOrgCreditTransactions(organizationId: string | null) { +function useOrgCreditTransactions(organizationId: string | null) { const trpc = useTRPC(); return useQuery( trpc.organizations.creditTransactions.queryOptions( @@ -181,7 +181,7 @@ export function useOrgCreditTransactionsPage(organizationId: string | null) { return { query, entries, hasMore }; } -export function useOrgInvoices(organizationId: string | null) { +function useOrgInvoices(organizationId: string | null) { const trpc = useTRPC(); return useQuery( trpc.organizations.invoices.queryOptions( From 74618aca42542af568ab8339351f56a18e9afacf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 26 Aug 2026 01:49:20 +0200 Subject: [PATCH 12/22] feat(i18n): translate org credit and invoice keys --- apps/mobile/src/i18n/locales/af.json | 10 ++++++++-- apps/mobile/src/i18n/locales/am.json | 10 ++++++++-- apps/mobile/src/i18n/locales/ar.json | 10 ++++++++-- apps/mobile/src/i18n/locales/az.json | 10 ++++++++-- apps/mobile/src/i18n/locales/be.json | 10 ++++++++-- apps/mobile/src/i18n/locales/bg.json | 10 ++++++++-- apps/mobile/src/i18n/locales/bn.json | 10 ++++++++-- apps/mobile/src/i18n/locales/bs.json | 10 ++++++++-- apps/mobile/src/i18n/locales/ca.json | 10 ++++++++-- apps/mobile/src/i18n/locales/ckb.json | 10 ++++++++-- apps/mobile/src/i18n/locales/cs.json | 10 ++++++++-- apps/mobile/src/i18n/locales/cy.json | 10 ++++++++-- apps/mobile/src/i18n/locales/da.json | 10 ++++++++-- apps/mobile/src/i18n/locales/de.json | 10 ++++++++-- apps/mobile/src/i18n/locales/el.json | 10 ++++++++-- apps/mobile/src/i18n/locales/es.json | 10 ++++++++-- apps/mobile/src/i18n/locales/et.json | 10 ++++++++-- apps/mobile/src/i18n/locales/eu.json | 10 ++++++++-- apps/mobile/src/i18n/locales/fa.json | 10 ++++++++-- apps/mobile/src/i18n/locales/fi.json | 10 ++++++++-- apps/mobile/src/i18n/locales/fil.json | 10 ++++++++-- apps/mobile/src/i18n/locales/fr.json | 10 ++++++++-- apps/mobile/src/i18n/locales/ga.json | 10 ++++++++-- apps/mobile/src/i18n/locales/gl.json | 10 ++++++++-- apps/mobile/src/i18n/locales/gu.json | 10 ++++++++-- apps/mobile/src/i18n/locales/ha.json | 10 ++++++++-- apps/mobile/src/i18n/locales/he.json | 10 ++++++++-- apps/mobile/src/i18n/locales/hi.json | 10 ++++++++-- apps/mobile/src/i18n/locales/hr.json | 10 ++++++++-- apps/mobile/src/i18n/locales/ht.json | 10 ++++++++-- apps/mobile/src/i18n/locales/hu.json | 10 ++++++++-- apps/mobile/src/i18n/locales/hy.json | 10 ++++++++-- apps/mobile/src/i18n/locales/id.json | 10 ++++++++-- apps/mobile/src/i18n/locales/ig.json | 10 ++++++++-- apps/mobile/src/i18n/locales/is.json | 10 ++++++++-- apps/mobile/src/i18n/locales/it.json | 10 ++++++++-- apps/mobile/src/i18n/locales/ja.json | 10 ++++++++-- apps/mobile/src/i18n/locales/ka.json | 10 ++++++++-- apps/mobile/src/i18n/locales/kk.json | 10 ++++++++-- apps/mobile/src/i18n/locales/km.json | 10 ++++++++-- apps/mobile/src/i18n/locales/kn.json | 10 ++++++++-- apps/mobile/src/i18n/locales/ko.json | 10 ++++++++-- apps/mobile/src/i18n/locales/lo.json | 10 ++++++++-- apps/mobile/src/i18n/locales/lt.json | 10 ++++++++-- apps/mobile/src/i18n/locales/lv.json | 10 ++++++++-- apps/mobile/src/i18n/locales/mg.json | 10 ++++++++-- apps/mobile/src/i18n/locales/mi.json | 10 ++++++++-- apps/mobile/src/i18n/locales/mk.json | 10 ++++++++-- apps/mobile/src/i18n/locales/ml.json | 10 ++++++++-- apps/mobile/src/i18n/locales/mn.json | 10 ++++++++-- apps/mobile/src/i18n/locales/mr.json | 10 ++++++++-- apps/mobile/src/i18n/locales/ms.json | 10 ++++++++-- apps/mobile/src/i18n/locales/mt.json | 10 ++++++++-- apps/mobile/src/i18n/locales/my.json | 10 ++++++++-- apps/mobile/src/i18n/locales/nb.json | 10 ++++++++-- apps/mobile/src/i18n/locales/ne.json | 10 ++++++++-- apps/mobile/src/i18n/locales/nl.json | 10 ++++++++-- apps/mobile/src/i18n/locales/om.json | 10 ++++++++-- apps/mobile/src/i18n/locales/or.json | 10 ++++++++-- apps/mobile/src/i18n/locales/pa.json | 10 ++++++++-- apps/mobile/src/i18n/locales/pl.json | 10 ++++++++-- apps/mobile/src/i18n/locales/ps.json | 10 ++++++++-- apps/mobile/src/i18n/locales/pt-BR.json | 10 ++++++++-- apps/mobile/src/i18n/locales/pt.json | 10 ++++++++-- apps/mobile/src/i18n/locales/ro.json | 10 ++++++++-- apps/mobile/src/i18n/locales/ru.json | 10 ++++++++-- apps/mobile/src/i18n/locales/si.json | 10 ++++++++-- apps/mobile/src/i18n/locales/sk.json | 10 ++++++++-- apps/mobile/src/i18n/locales/sl.json | 10 ++++++++-- apps/mobile/src/i18n/locales/so.json | 10 ++++++++-- apps/mobile/src/i18n/locales/sq.json | 10 ++++++++-- apps/mobile/src/i18n/locales/sr.json | 10 ++++++++-- apps/mobile/src/i18n/locales/sv.json | 10 ++++++++-- apps/mobile/src/i18n/locales/sw.json | 10 ++++++++-- apps/mobile/src/i18n/locales/ta.json | 10 ++++++++-- apps/mobile/src/i18n/locales/te.json | 10 ++++++++-- apps/mobile/src/i18n/locales/th.json | 10 ++++++++-- apps/mobile/src/i18n/locales/tr.json | 10 ++++++++-- apps/mobile/src/i18n/locales/uk.json | 10 ++++++++-- apps/mobile/src/i18n/locales/ur.json | 10 ++++++++-- apps/mobile/src/i18n/locales/uz.json | 10 ++++++++-- apps/mobile/src/i18n/locales/vi.json | 10 ++++++++-- apps/mobile/src/i18n/locales/yo.json | 10 ++++++++-- apps/mobile/src/i18n/locales/zh-Hans.json | 10 ++++++++-- apps/mobile/src/i18n/locales/zh-Hant.json | 10 ++++++++-- apps/mobile/src/i18n/locales/zu.json | 10 ++++++++-- 86 files changed, 688 insertions(+), 172 deletions(-) diff --git a/apps/mobile/src/i18n/locales/af.json b/apps/mobile/src/i18n/locales/af.json index 179eb5574f..8fe4f18c82 100644 --- a/apps/mobile/src/i18n/locales/af.json +++ b/apps/mobile/src/i18n/locales/af.json @@ -2944,7 +2944,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", @@ -2971,7 +2974,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 1f3a1d7709..bc1dc27c33 100644 --- a/apps/mobile/src/i18n/locales/am.json +++ b/apps/mobile/src/i18n/locales/am.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "የቡድን ሙሌት ጉርሻ-2025", "accounting_adjustment": "የሂሳብ ማስተካከያ", "credits_expired": "ክሬዲቶች ጊዜያቸው አልፏል" - } + }, + "loadMore": "ተጨማሪ ጫን", + "loadMoreFailed": "ተጨማሪ መጫን አልተቻለም።", + "truncated": "የቆዩ የክሬዲት እንቅስቃሴዎች ይገኛሉ።" }, "inviteMember": { "emailError": "ትክክለኛ የኢሜይል አድራሻ ያስገቡ", @@ -2971,7 +2974,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 0fdea59424..549a1e2298 100644 --- a/apps/mobile/src/i18n/locales/ar.json +++ b/apps/mobile/src/i18n/locales/ar.json @@ -3010,7 +3010,10 @@ "team-topup-bonus-2025": "مكافأة شحن الفريق 2025", "accounting_adjustment": "تعديل محاسبي", "credits_expired": "انتهت صلاحية الأرصدة" - } + }, + "loadMore": "تحميل المزيد", + "loadMoreFailed": "تعذّر تحميل المزيد.", + "truncated": "تتوفر نشاطات رصيد أقدم." }, "hub": { "balance": "الرصيد", @@ -3046,7 +3049,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 2a098b38f8..13aa8a5866 100644 --- a/apps/mobile/src/i18n/locales/az.json +++ b/apps/mobile/src/i18n/locales/az.json @@ -2944,7 +2944,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": "Yüklə", + "loadMoreFailed": "Daha çox yüklənmədi.", + "truncated": "Köhnə kredit fəaliyyəti mövcuddur." }, "inviteMember": { "emailError": "Etibarlı e-poçt ünvanı daxil edin", @@ -2971,7 +2974,10 @@ "draft": "Qaralama", "uncollectible": "Yığıla bilməyən", "unknown": "Naməlum" - } + }, + "loadMore": "Yüklə", + "loadMoreFailed": "Daha çox yüklənmə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 682d9f8275..72b0148a58 100644 --- a/apps/mobile/src/i18n/locales/be.json +++ b/apps/mobile/src/i18n/locales/be.json @@ -2984,7 +2984,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Бухгалтарская карэкціроўка", "credits_expired": "Крэдыты скончыліся" - } + }, + "loadMore": "Загрузіць яшчэ", + "loadMoreFailed": "Не атрымалася загрузіць больш.", + "truncated": "Даступная больш ранняя актыўнасць па крэдыту." }, "inviteMember": { "emailError": "Увядзіце сапраўдны адрас электроннай пошты", @@ -3011,7 +3014,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 7f41626925..3e1d2fd3e0 100644 --- a/apps/mobile/src/i18n/locales/bg.json +++ b/apps/mobile/src/i18n/locales/bg.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Счетоводна корекция", "credits_expired": "Изтекли кредити" - } + }, + "loadMore": "Зареди още", + "loadMoreFailed": "Не можа да се зареди още.", + "truncated": "Налична е по-стара кредитна активност." }, "inviteMember": { "emailError": "Въведете валиден имейл адрес", @@ -2971,7 +2974,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 08204183f5..9b5cf4a08a 100644 --- a/apps/mobile/src/i18n/locales/bn.json +++ b/apps/mobile/src/i18n/locales/bn.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "অ্যাকাউন্টিং সমন্বয়", "credits_expired": "ক্রেডিটের মেয়াদ শেষ" - } + }, + "loadMore": "আরও লোড করুন", + "loadMoreFailed": "আরও লোড করা যায়নি।", + "truncated": "পুরোনো ক্রেডিট কার্যকলাপ উপলব্ধ।" }, "inviteMember": { "emailError": "একটি বৈধ ইমেইল ঠিকানা লিখুন", @@ -2971,7 +2974,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 b1af1fd817..9d727d5dab 100644 --- a/apps/mobile/src/i18n/locales/bs.json +++ b/apps/mobile/src/i18n/locales/bs.json @@ -2964,7 +2964,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Računovodstvena korekcija", "credits_expired": "Istekli krediti" - } + }, + "loadMore": "Učitaj više", + "loadMoreFailed": "Ne mogu učitati više.", + "truncated": "Starija aktivnost kredita je dostupna." }, "inviteMember": { "emailError": "Unesite važeću email adresu", @@ -2991,7 +2994,10 @@ "draft": "Naert", "uncollectible": "Nenaplativo", "unknown": "Nepoznato" - } + }, + "loadMore": "Učitaj više", + "loadMoreFailed": "Ne mogu učitati više.", + "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 45ca358412..5d744543c2 100644 --- a/apps/mobile/src/i18n/locales/ca.json +++ b/apps/mobile/src/i18n/locales/ca.json @@ -2964,7 +2964,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Ajust comptable", "credits_expired": "Crèdits caducats" - } + }, + "loadMore": "Carrega'n 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", @@ -2991,7 +2994,10 @@ "draft": "Esborrany", "uncollectible": "No cobrable", "unknown": "Desconegut" - } + }, + "loadMore": "Carrega'n 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 a9f94bb7ce..346e6e406b 100644 --- a/apps/mobile/src/i18n/locales/ckb.json +++ b/apps/mobile/src/i18n/locales/ckb.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "بۆنەی زیادکردنی تیم 2025", "accounting_adjustment": "ڕێکخستنی هەژماری", "credits_expired": "بەسەرچوونی کرێدت" - } + }, + "loadMore": "بارکردنی زیاتر", + "loadMoreFailed": "نەتوانرا زیاتر بار بکرێت.", + "truncated": "چالاکی بەرایی کۆنیتر بەردەستە." }, "inviteMember": { "emailError": "ناونیشانی ئیمەیڵی دروست بنووسە", @@ -2971,7 +2974,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 9d1353bec0..cbc18dd905 100644 --- a/apps/mobile/src/i18n/locales/cs.json +++ b/apps/mobile/src/i18n/locales/cs.json @@ -2984,7 +2984,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": "Nepodařilo se načíst další.", + "truncated": "Starší aktivita kreditů je k dispozici." }, "inviteMember": { "emailError": "Zadejte platnou e-mailovou adresu", @@ -3011,7 +3014,10 @@ "draft": "Koncept", "uncollectible": "Nedobytné", "unknown": "Neznámé" - } + }, + "loadMore": "Načíst více", + "loadMoreFailed": "Nepodařilo se načíst další.", + "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 ec08d0a88a..9ea697f2ca 100644 --- a/apps/mobile/src/i18n/locales/cy.json +++ b/apps/mobile/src/i18n/locales/cy.json @@ -3024,7 +3024,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": "Methwyd llwytho mwy.", + "truncated": "Mae gweithgaredd credyd hŷn ar gael." }, "inviteMember": { "emailError": "Nodwch gyfeiriad e-bost dilys", @@ -3051,7 +3054,10 @@ "draft": "Drafft", "uncollectible": "Anghasgladwy", "unknown": "Anhysbys" - } + }, + "loadMore": "Llwytho mwy", + "loadMoreFailed": "Methwyd 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 610558c4a3..1398541a1e 100644 --- a/apps/mobile/src/i18n/locales/da.json +++ b/apps/mobile/src/i18n/locales/da.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Regnskabsmæssig justering", "credits_expired": "Udløbne kreditter" - } + }, + "loadMore": "Indlæs mere", + "loadMoreFailed": "Det lykkedes ikke at indlæse mere.", + "truncated": "Ældre kreditaktivitet er tilgængelig." }, "inviteMember": { "emailError": "Angiv en gyldig e-mailadresse", @@ -2971,7 +2974,10 @@ "draft": "Kladde", "uncollectible": "Uinddrivelig", "unknown": "Ukendt" - } + }, + "loadMore": "Indlæs mere", + "loadMoreFailed": "Det lykkedes ikke at 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 20587c6f96..30a4e2f9c0 100644 --- a/apps/mobile/src/i18n/locales/de.json +++ b/apps/mobile/src/i18n/locales/de.json @@ -2930,7 +2930,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Buchhaltungsanpassung", "credits_expired": "Abgelaufene Credits" - } + }, + "loadMore": "Mehr laden", + "loadMoreFailed": "Konnte nicht mehr laden.", + "truncated": "Ältere Guthabenaktivität ist verfügbar." }, "hub": { "balance": "Guthaben", @@ -2966,7 +2969,10 @@ "draft": "Entwurf", "uncollectible": "Nicht einziehbar", "unknown": "Unbekannt" - } + }, + "loadMore": "Mehr laden", + "loadMoreFailed": "Konnte nicht mehr laden.", + "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 3187e81a0a..2a6ba6e5dd 100644 --- a/apps/mobile/src/i18n/locales/el.json +++ b/apps/mobile/src/i18n/locales/el.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Λογιστική προσαρμογή", "credits_expired": "Ληγμένες πιστώσεις" - } + }, + "loadMore": "Φόρτωση περισσότερων", + "loadMoreFailed": "Δεν ήταν δυνατή η φόρτωση περισσότερων.", + "truncated": "Παλαιότερη δραστηριότητα πιστώσεων είναι διαθέσιμη." }, "inviteMember": { "emailError": "Εισαγάγετε μια έγκυρη διεύθυνση email", @@ -2971,7 +2974,10 @@ "draft": "Πρόχειρο", "uncollectible": "Μη εισπράξιμο", "unknown": "Άγνωστο" - } + }, + "loadMore": "Φόρτωση περισσότερων", + "loadMoreFailed": "Δεν ήταν δυνατή η φόρτωση περισσότερων.", + "truncated": "Παλαιότερα τιμολόγια είναι διαθέσιμα." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", diff --git a/apps/mobile/src/i18n/locales/es.json b/apps/mobile/src/i18n/locales/es.json index 62ca5ce446..1d0239c4e0 100644 --- a/apps/mobile/src/i18n/locales/es.json +++ b/apps/mobile/src/i18n/locales/es.json @@ -2950,7 +2950,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", @@ -2986,7 +2989,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 8b84b06de0..d6dfc9f8c6 100644 --- a/apps/mobile/src/i18n/locales/et.json +++ b/apps/mobile/src/i18n/locales/et.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Meeskonna täiendusboonus 2025", "accounting_adjustment": "Raamatupidamiskorrigeerimine", "credits_expired": "Krediidid aegusid" - } + }, + "loadMore": "Laadi rohkem", + "loadMoreFailed": "Rohkem ei õnnestunud laadida.", + "truncated": "Varasem krediidiajalugu on saadaval." }, "inviteMember": { "emailError": "Sisestage kehtiv e-posti aadress", @@ -2971,7 +2974,10 @@ "draft": "Mustand", "uncollectible": "Sissenõudmatu", "unknown": "Teadmata" - } + }, + "loadMore": "Laadi rohkem", + "loadMoreFailed": "Rohkem ei õnnestunud laadida.", + "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 7147fe6c11..022ddd4163 100644 --- a/apps/mobile/src/i18n/locales/eu.json +++ b/apps/mobile/src/i18n/locales/eu.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Taldearen topup-bonua 2025", "accounting_adjustment": "Kontabilitate-doikuntza", "credits_expired": "Kredituak iraungita" - } + }, + "loadMore": "Kargatu gehiago", + "loadMoreFailed": "Ezin izan da gehiago kargatu.", + "truncated": "Kreditu-jarduera zaharragoa eskuragarri dago." }, "inviteMember": { "emailError": "Sartu posta-helbide baliozko bat", @@ -2971,7 +2974,10 @@ "draft": "Zirriborroa", "uncollectible": "Kobratu ezina", "unknown": "Ezezaguna" - } + }, + "loadMore": "Kargatu gehiago", + "loadMoreFailed": "Ezin izan da 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 4842f3291e..c3e0a2904c 100644 --- a/apps/mobile/src/i18n/locales/fa.json +++ b/apps/mobile/src/i18n/locales/fa.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "پاداش شارژ تیم 2025", "accounting_adjustment": "تعدیل حسابداری", "credits_expired": "اعتبارها منقضی شدند" - } + }, + "loadMore": "بارگذاری بیشتر", + "loadMoreFailed": "نمیتوان بیشتر بارگذاری کرد.", + "truncated": "فعالیت اعتبار قدیمیتر در دسترس است." }, "inviteMember": { "emailError": "یک آدرس ایمیل معتبر وارد کنید", @@ -2971,7 +2974,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 5e80ef31c4..edc9e3273a 100644 --- a/apps/mobile/src/i18n/locales/fi.json +++ b/apps/mobile/src/i18n/locales/fi.json @@ -2944,7 +2944,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", @@ -2971,7 +2974,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 5b4b0890c4..4c492d881c 100644 --- a/apps/mobile/src/i18n/locales/fil.json +++ b/apps/mobile/src/i18n/locales/fil.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Team top-up bonus 2025", "accounting_adjustment": "Pagsasaayos ng accounting", "credits_expired": "Nag-expire na credits" - } + }, + "loadMore": "Mag-load pa", + "loadMoreFailed": "Hindi makapag-load pa.", + "truncated": "Magagamit ang mas lumang aktibidad ng kredito." }, "inviteMember": { "emailError": "Maglagay ng wastong email address", @@ -2971,7 +2974,10 @@ "draft": "Draft", "uncollectible": "Hindi makokolekta", "unknown": "Hindi alam" - } + }, + "loadMore": "Mag-load pa", + "loadMoreFailed": "Hindi makapag-load 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 0bf00bcac6..6633c4cc42 100644 --- a/apps/mobile/src/i18n/locales/fr.json +++ b/apps/mobile/src/i18n/locales/fr.json @@ -2950,7 +2950,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", @@ -2986,7 +2989,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 603abe730b..0d57583a62 100644 --- a/apps/mobile/src/i18n/locales/ga.json +++ b/apps/mobile/src/i18n/locales/ga.json @@ -3004,7 +3004,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í", @@ -3031,7 +3034,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 866a18691c..2274c9e759 100644 --- a/apps/mobile/src/i18n/locales/gl.json +++ b/apps/mobile/src/i18n/locales/gl.json @@ -2944,7 +2944,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 puido cargar máis.", + "truncated": "Hai dispoñible actividade de crédito máis antiga." }, "inviteMember": { "emailError": "Introduce un enderezo de correo válido", @@ -2971,7 +2974,10 @@ "draft": "Borrador", "uncollectible": "Incobrable", "unknown": "Descoñecido" - } + }, + "loadMore": "Cargar máis", + "loadMoreFailed": "Non se puido 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 c3a428ca02..5249f33237 100644 --- a/apps/mobile/src/i18n/locales/gu.json +++ b/apps/mobile/src/i18n/locales/gu.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "ટીમ ટોપઅપ બોનસ 2025", "accounting_adjustment": "એકાઉન્ટિંગ ગોઠવણ", "credits_expired": "ક્રેડિટ સમાપ્ત થઈ" - } + }, + "loadMore": "વધુ લોડ કરો", + "loadMoreFailed": "વધુ લોડ કરી શકાયું નથી.", + "truncated": "જૂની ક્રેડિટ પ્રવૃત્તિ ઉપલબ્ધ છે." }, "inviteMember": { "emailError": "માન્ય ઇમેઇલ સરનામું દાખલ કરો", @@ -2971,7 +2974,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 388ed226fd..cf1ed15f4f 100644 --- a/apps/mobile/src/i18n/locales/ha.json +++ b/apps/mobile/src/i18n/locales/ha.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Kyautar Topup na Ƙungiya 2025", "accounting_adjustment": "Daidaitawar lissafin kuɗi", "credits_expired": "Credits sun ƙare" - } + }, + "loadMore": "Ɗauke ƙari", + "loadMoreFailed": "Ba a iya Ɗauke ƙari ba.", + "truncated": "Ayyukan bashi na baya suna nan." }, "inviteMember": { "emailError": "Shigar da ingantacciyar adireshin imel", @@ -2971,7 +2974,10 @@ "draft": "Daftari", "uncollectible": "Ba za a iya karɓa ba", "unknown": "Ba a sani ba" - } + }, + "loadMore": "Ɗauke ƙari", + "loadMoreFailed": "Ba a iya Ɗauke ƙari 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 9fca20e1be..f1cdd3b813 100644 --- a/apps/mobile/src/i18n/locales/he.json +++ b/apps/mobile/src/i18n/locales/he.json @@ -2950,7 +2950,10 @@ "team-topup-bonus-2025": "בונוס טופ-אפ של צוות 2025", "accounting_adjustment": "התאמה חשבונאית", "credits_expired": "הקרדיטים פגו" - } + }, + "loadMore": "טען עוד", + "loadMoreFailed": "לא ניתן היה לטעון עוד.", + "truncated": "קיימת פעילות אשראי ישנה יותר." }, "hub": { "balance": "יתרה", @@ -2986,7 +2989,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 94107c0c25..88cfa34c4c 100644 --- a/apps/mobile/src/i18n/locales/hi.json +++ b/apps/mobile/src/i18n/locales/hi.json @@ -2930,7 +2930,10 @@ "team-topup-bonus-2025": "टीम टॉप-अप बोनस 2025", "accounting_adjustment": "लेखा समायोजन", "credits_expired": "क्रेडिट समाप्त हुए" - } + }, + "loadMore": "और लोड करें", + "loadMoreFailed": "अधिक लोड नहीं कर सके।", + "truncated": "पुरानी क्रेडिट गतिविधि उपलब्ध है।" }, "hub": { "balance": "बैलेंस", @@ -2966,7 +2969,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 a326f7c7f8..378c17e033 100644 --- a/apps/mobile/src/i18n/locales/hr.json +++ b/apps/mobile/src/i18n/locales/hr.json @@ -2964,7 +2964,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", @@ -2991,7 +2994,10 @@ "draft": "Nacrt", "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 1623abeae8..e8e1da0f69 100644 --- a/apps/mobile/src/i18n/locales/ht.json +++ b/apps/mobile/src/i18n/locales/ht.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Ajisteman kontab", "credits_expired": "Kredi ekspire" - } + }, + "loadMore": "Chaje plis", + "loadMoreFailed": "Nou pa t ka chaje plis.", + "truncated": "Gen plis aktivite kredi ki pi ansyen disponib." }, "inviteMember": { "emailError": "Antre yon adrès imel valid", @@ -2971,7 +2974,10 @@ "draft": "Brouyon", "uncollectible": "Enkobrèb", "unknown": "Enkoni" - } + }, + "loadMore": "Chaje plis", + "loadMoreFailed": "Nou 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 68344e07ca..9a2ae859d1 100644 --- a/apps/mobile/src/i18n/locales/hu.json +++ b/apps/mobile/src/i18n/locales/hu.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Számviteli kiigazítás", "credits_expired": "Lejárt kreditek" - } + }, + "loadMore": "Továbbiak betöltése", + "loadMoreFailed": "A továbbiak betöltése nem sikerült.", + "truncated": "Korábbi hitelaktivitás érhető el." }, "inviteMember": { "emailError": "Adj meg érvényes e-mail címet", @@ -2971,7 +2974,10 @@ "draft": "Piszkozat", "uncollectible": "Behajthatatlan", "unknown": "Ismeretlen" - } + }, + "loadMore": "Továbbiak betöltése", + "loadMoreFailed": "A továbbiak betöltése nem sikerült.", + "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 1ef4ba1003..a2c5893e9a 100644 --- a/apps/mobile/src/i18n/locales/hy.json +++ b/apps/mobile/src/i18n/locales/hy.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Հաշվապահական ճշգրտում", "credits_expired": "Սպառված կրեդիտներ" - } + }, + "loadMore": "Բեռնել ավելին", + "loadMoreFailed": "Չհաջողվեց բեռնել ավելին։", + "truncated": "Հին վարկային գործունեությունը հասանելի է։" }, "inviteMember": { "emailError": "Մուտքագրեք վավեր էլփոստի հասցե", @@ -2971,7 +2974,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 a37c7dabea..2628a9b9a9 100644 --- a/apps/mobile/src/i18n/locales/id.json +++ b/apps/mobile/src/i18n/locales/id.json @@ -2930,7 +2930,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", @@ -2966,7 +2969,10 @@ "draft": "Draf", "uncollectible": "Tidak tertagih", "unknown": "Tidak dikenal" - } + }, + "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 6648d92eaa..7d9ba09423 100644 --- a/apps/mobile/src/i18n/locales/ig.json +++ b/apps/mobile/src/i18n/locales/ig.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Mmezi ndekọ ego", "credits_expired": "Kredit emebiwo" - } + }, + "loadMore": "Bufuo ihe ndị ọzọ", + "loadMoreFailed": "Enweghị ike ibufu ihe ndị ọzọ.", + "truncated": "Ọrụ kredit ochie dị." }, "inviteMember": { "emailError": "Tinye adres email ziri ezi", @@ -2971,7 +2974,10 @@ "draft": "Ihe odide", "uncollectible": "Enweghị ike ịnakọta", "unknown": "Amaghị" - } + }, + "loadMore": "Bufuo ihe ndị ọzọ", + "loadMoreFailed": "Enweghị ike ibufu ihe ndị ọ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 0edb0a5e81..212621da6e 100644 --- a/apps/mobile/src/i18n/locales/is.json +++ b/apps/mobile/src/i18n/locales/is.json @@ -2944,7 +2944,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", @@ -2971,7 +2974,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 a79bcc768d..294b6c8b1d 100644 --- a/apps/mobile/src/i18n/locales/it.json +++ b/apps/mobile/src/i18n/locales/it.json @@ -2950,7 +2950,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Rettifica contabile", "credits_expired": "Crediti scaduti" - } + }, + "loadMore": "Carica altri", + "loadMoreFailed": "Impossibile caricare altri.", + "truncated": "Sono disponibili attività di credito più vecchie." }, "hub": { "balance": "Saldo", @@ -2986,7 +2989,10 @@ "draft": "Bozza", "uncollectible": "Non riscuotibile", "unknown": "Sconosciuto" - } + }, + "loadMore": "Carica altri", + "loadMoreFailed": "Impossibile caricare altri.", + "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 86643aa019..895e19ba18 100644 --- a/apps/mobile/src/i18n/locales/ja.json +++ b/apps/mobile/src/i18n/locales/ja.json @@ -2930,7 +2930,10 @@ "team-topup-bonus-2025": "チームチャージボーナス2025", "accounting_adjustment": "会計調整", "credits_expired": "クレジット失効" - } + }, + "loadMore": "さらに読み込む", + "loadMoreFailed": "さらに読み込めませんでした。", + "truncated": "以前のクレジット利用状況が利用可能です。" }, "hub": { "balance": "残高", @@ -2966,7 +2969,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 6e5b5e15b9..d6c070ebc9 100644 --- a/apps/mobile/src/i18n/locales/ka.json +++ b/apps/mobile/src/i18n/locales/ka.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "გუნდის შევსების ბონუსი 2025", "accounting_adjustment": "ბუღალტრული კორექტირება", "credits_expired": "ვადაგასული კრედიტები" - } + }, + "loadMore": "მეტის ჩატვირთვა", + "loadMoreFailed": "მეტის ჩატვირთვა ვერ მოხერხდა.", + "truncated": "უფრო ძველი კრედიტის აქტივობა ხელმისაწვდომია." }, "inviteMember": { "emailError": "შეიყვანეთ მოქმედი ელფოსტის მისამართი", @@ -2971,7 +2974,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 3de9aff0e1..99424d04ac 100644 --- a/apps/mobile/src/i18n/locales/kk.json +++ b/apps/mobile/src/i18n/locales/kk.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Топты толтыру бонусы 2025", "accounting_adjustment": "Есептік түзету", "credits_expired": "Кредиттердің мерзімі аяқталды" - } + }, + "loadMore": "Көбірек жүктеу", + "loadMoreFailed": "Көбірек жүктелмеді.", + "truncated": "Ескі несие белсенділігі қолжетімді." }, "inviteMember": { "emailError": "Жарамды электрондық пошта мекенжайын енгізіңіз", @@ -2971,7 +2974,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 5a0f3fe509..19fed08424 100644 --- a/apps/mobile/src/i18n/locales/km.json +++ b/apps/mobile/src/i18n/locales/km.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "ប្រាក់រង្វាន់បន្ថែមក្រុម 2025", "accounting_adjustment": "ការកែតម្រូវគណនេយ្យ", "credits_expired": "ឥណទានផុតកំណត់" - } + }, + "loadMore": "ផ្ទុកបន្ថែម", + "loadMoreFailed": "មិនអាចផ្ទុកបន្ថែមទៀតបានទេ។", + "truncated": "សកម្មភាពឥណទានចាស់ៗអាចប្រើបាន។" }, "inviteMember": { "emailError": "បញ្ចូលអាសយដ្ឋានអ៊ីមែលត្រឹមត្រូវ", @@ -2971,7 +2974,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 8418878554..711141b57a 100644 --- a/apps/mobile/src/i18n/locales/kn.json +++ b/apps/mobile/src/i18n/locales/kn.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "ತಂಡದ ಟಾಪ್-ಅಪ್ ಬೋನಸ್ 2025", "accounting_adjustment": "ಲೆಕ್ಕಪತ್ರ ಹೊಂದಾಣಿಕೆ", "credits_expired": "ಕ್ರೆಡಿಟ್‌ಗಳು ಅವಧಿ ಮುಗಿದವು" - } + }, + "loadMore": "ಹೆಚ್ಚು ಲೋಡ್ ಮಾಡಿ", + "loadMoreFailed": "ಇನ್ನಷ್ಟು ಲೋಡ್ ಮಾಡಲಾಗಲಿಲ್ಲ.", + "truncated": "ಹಳೆಯ ಕ್ರೆಡಿಟ್ ಚಟುವಟಿಕೆ ಲಭ್ಯವಿದೆ." }, "inviteMember": { "emailError": "ಮಾನ್ಯ ಇಮೇಲ್ ವಿಳಾಸವನ್ನು ನಮೂದಿಸಿ", @@ -2971,7 +2974,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 a4e9241094..42e63224c4 100644 --- a/apps/mobile/src/i18n/locales/ko.json +++ b/apps/mobile/src/i18n/locales/ko.json @@ -2930,7 +2930,10 @@ "team-topup-bonus-2025": "팀 충전 보너스 2025", "accounting_adjustment": "회계 조정", "credits_expired": "크레딧 만료" - } + }, + "loadMore": "더 불러오기", + "loadMoreFailed": "더 불러올 수 없습니다.", + "truncated": "이전 크레딧 활동을 확인할 수 있습니다." }, "hub": { "balance": "잔액", @@ -2966,7 +2969,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 9e49fa8614..c376803de2 100644 --- a/apps/mobile/src/i18n/locales/lo.json +++ b/apps/mobile/src/i18n/locales/lo.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "ໂບນັດເຕີມເງິນທີມ 2025", "accounting_adjustment": "ການປັບບັນຊີ", "credits_expired": "ເຄຣດິດໝົດອາຍຸ" - } + }, + "loadMore": "ໂຫຼດເພີ່ມ", + "loadMoreFailed": "ບໍ່ສາມາດໂຫຼດເພີ່ມໄດ້.", + "truncated": "ກິດຈະກຳສິນເຊື່ອເກົ່າກວ່າມີໃຫ້." }, "inviteMember": { "emailError": "ໃສ່ທີ່ຢູ່ອີເມວທີ່ຖືກຕ້ອງ", @@ -2971,7 +2974,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 7fc514cef3..3dc350bd43 100644 --- a/apps/mobile/src/i18n/locales/lt.json +++ b/apps/mobile/src/i18n/locales/lt.json @@ -2984,7 +2984,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ą", @@ -3011,7 +3014,10 @@ "draft": "Juodraštis", "uncollectible": "Neišieškoma", "unknown": "Nežinoma" - } + }, + "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 a3cecd5137..9089e9cad7 100644 --- a/apps/mobile/src/i18n/locales/lv.json +++ b/apps/mobile/src/i18n/locales/lv.json @@ -2964,7 +2964,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", @@ -2991,7 +2994,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 146db4af7b..1aadc42821 100644 --- a/apps/mobile/src/i18n/locales/mg.json +++ b/apps/mobile/src/i18n/locales/mg.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Bonus top-up ekipa 2025", "accounting_adjustment": "Fanitsiana kaonty", "credits_expired": "Lany daty ny credits" - } + }, + "loadMore": "Havao bebe kokoa", + "loadMoreFailed": "Tsy afaka ny havaozina bebe kokoa.", + "truncated": "Misy ny fiasana crédit taloha kokoa." }, "inviteMember": { "emailError": "Ampidiro adiresy email sahaza", @@ -2971,7 +2974,10 @@ "draft": "Draft", "uncollectible": "Tsy azo angonina", "unknown": "Tsy fantatra" - } + }, + "loadMore": "Havao bebe kokoa", + "loadMoreFailed": "Tsy afaka ny havaozina bebe kokoa.", + "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 b95694790f..8739266163 100644 --- a/apps/mobile/src/i18n/locales/mi.json +++ b/apps/mobile/src/i18n/locales/mi.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Te top-up bonus rōpū 2025", "accounting_adjustment": "Te whakatikatika kaute", "credits_expired": "Kua pau ngā whiwhinga" - } + }, + "loadMore": "Utaina ētahi atu", + "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", @@ -2971,7 +2974,10 @@ "draft": "Hukitanga", "uncollectible": "Kāore e taea te kohi", "unknown": "Kāore e mōhiotia" - } + }, + "loadMore": "Utaina ētahi atu", + "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 575c19ec55..7b0446015d 100644 --- a/apps/mobile/src/i18n/locales/mk.json +++ b/apps/mobile/src/i18n/locales/mk.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Тимски top-up бонус 2025", "accounting_adjustment": "Сметководствена корекција", "credits_expired": "Истечени кредити" - } + }, + "loadMore": "Вчитај повеќе", + "loadMoreFailed": "Не можеше да се вчитаат повеќе.", + "truncated": "Постара активност на кредит е достапна." }, "inviteMember": { "emailError": "Внесете валидна е-пошта адреса", @@ -2971,7 +2974,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 b61686c9e5..f77380a45a 100644 --- a/apps/mobile/src/i18n/locales/ml.json +++ b/apps/mobile/src/i18n/locales/ml.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "ടീം-ടോപ്പ്-അപ്പ്-ബോണസ്-2025", "accounting_adjustment": "അക്കൗണ്ടിംഗ് ക്രമീകരണം", "credits_expired": "ക്രെഡിറ്റുകൾ കാലഹരണപ്പെട്ടു" - } + }, + "loadMore": "കൂടുതൽ ലോഡ് ചെയ്യുക", + "loadMoreFailed": "കൂടുതൽ ലോഡ് ചെയ്യാൻ കഴിഞ്ഞില്ല.", + "truncated": "പഴയ ക്രെഡിറ്റ് പ്രവർത്തനങ്ങൾ ലഭ്യമാണ്." }, "inviteMember": { "emailError": "സാധുവായ ഒരു ഇമെയിൽ വിലാസം നൽകുക", @@ -2971,7 +2974,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 416206c11e..e001169aeb 100644 --- a/apps/mobile/src/i18n/locales/mn.json +++ b/apps/mobile/src/i18n/locales/mn.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Багийн топ-ап бонус-2025", "accounting_adjustment": "Нягтлан бодох бүртгэлийн тохируулга", "credits_expired": "Кредит дууссан" - } + }, + "loadMore": "Цааш ачаалах", + "loadMoreFailed": "Цааш ачаалах боломжгүй.", + "truncated": "Хуучин кредит үйл ажиллагааг үзэх боломжтой." }, "inviteMember": { "emailError": "Хүчинтэй имэйл хаяг оруулна уу", @@ -2971,7 +2974,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 94fff6def3..86ef497b60 100644 --- a/apps/mobile/src/i18n/locales/mr.json +++ b/apps/mobile/src/i18n/locales/mr.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "टीम टॉप-अप बोनस-2025", "accounting_adjustment": "लेखा समायोजन", "credits_expired": "क्रेडिट कालबाह्य झाले" - } + }, + "loadMore": "अधिक लोड करा", + "loadMoreFailed": "अधिक लोड करता आले नाही.", + "truncated": "जुनी क्रेडिट क्रियाकलाप उपलब्ध आहे." }, "inviteMember": { "emailError": "वैध ईमेल पत्ता प्रविष्ट करा", @@ -2971,7 +2974,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 24685b4c3a..77a9127cab 100644 --- a/apps/mobile/src/i18n/locales/ms.json +++ b/apps/mobile/src/i18n/locales/ms.json @@ -2944,7 +2944,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", @@ -2971,7 +2974,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 91adac54cd..f0c50a3c0a 100644 --- a/apps/mobile/src/i18n/locales/mt.json +++ b/apps/mobile/src/i18n/locales/mt.json @@ -3004,7 +3004,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Aġġustament tal-kontabilità", "credits_expired": "Krediti skaduti" - } + }, + "loadMore": "Tagħbija aktar", + "loadMoreFailed": "Ma setgħux jitgħabbew aktar.", + "truncated": "Attività ta' kreditu aktar antika hija disponibbli." }, "inviteMember": { "emailError": "Daħħal indirizz ta' email validu", @@ -3031,7 +3034,10 @@ "draft": "Abbozz", "uncollectible": "Ma jistax jinġabar", "unknown": "Mhux magħruf" - } + }, + "loadMore": "Tagħbija aktar", + "loadMoreFailed": "Ma setgħux jitgħabbew 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 0a45c4f612..3775df08ef 100644 --- a/apps/mobile/src/i18n/locales/my.json +++ b/apps/mobile/src/i18n/locales/my.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "စာရင်းကိုင် ပြုပြင်မှု", "credits_expired": "သက်တမ်းကုန် ခရက်ဒစ်များ" - } + }, + "loadMore": "နောက်ထပ်ဖတ်ပါ", + "loadMoreFailed": "နောက်ထပ်ဖတ်၍မရပါ။", + "truncated": "ပိုဟောင်းသော ခရက်ဒစ်လုပ်ဆောင်ချက်များ ရနိုင်ပါသည်။" }, "inviteMember": { "emailError": "မှန်ကန်သော အီးမေးလ် လိပ်စာ ထည့်ပါ", @@ -2971,7 +2974,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 d5943f515b..884f21dc66 100644 --- a/apps/mobile/src/i18n/locales/nb.json +++ b/apps/mobile/src/i18n/locales/nb.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Regnskapsjustering", "credits_expired": "Kreditter utløpt" - } + }, + "loadMore": "Last inn flere", + "loadMoreFailed": "Kunne ikke laste inn flere.", + "truncated": "Tidligere kredittaktivitet er tilgjengelig." }, "inviteMember": { "emailError": "Angi en gyldig e-postadresse", @@ -2971,7 +2974,10 @@ "draft": "Kladd", "uncollectible": "Uinnkrevbar", "unknown": "Ukjent" - } + }, + "loadMore": "Last inn flere", + "loadMoreFailed": "Kunne ikke laste inn flere.", + "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 0903b6966c..8c8fa7e269 100644 --- a/apps/mobile/src/i18n/locales/ne.json +++ b/apps/mobile/src/i18n/locales/ne.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "लेखा समायोजन", "credits_expired": "म्याद सकिएका क्रेडिटहरू" - } + }, + "loadMore": "थप लोड गर्नुहोस्", + "loadMoreFailed": "थप लोड गर्न सकिएन।", + "truncated": "पुरानो क्रेडिट गतिविधि उपलब्ध छ।" }, "inviteMember": { "emailError": "मान्य इमेल ठेगाना प्रविष्ट गर्नुहोस्", @@ -2971,7 +2974,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 7a0dcc79ab..4910e161a6 100644 --- a/apps/mobile/src/i18n/locales/nl.json +++ b/apps/mobile/src/i18n/locales/nl.json @@ -2930,7 +2930,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Boekhoudkundige correctie", "credits_expired": "Credits verlopen" - } + }, + "loadMore": "Meer laden", + "loadMoreFailed": "Meer laden lukte niet.", + "truncated": "Oudere creditactiviteit is beschikbaar." }, "hub": { "balance": "Saldo", @@ -2966,7 +2969,10 @@ "draft": "Concept", "uncollectible": "Oninbaar", "unknown": "Onbekend" - } + }, + "loadMore": "Meer laden", + "loadMoreFailed": "Meer laden lukte niet.", + "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 0395e03a59..31cc5925cc 100644 --- a/apps/mobile/src/i18n/locales/om.json +++ b/apps/mobile/src/i18n/locales/om.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "Sirreeffama herregaa", "credits_expired": "Kiriiditiin xumuramte" - } + }, + "loadMore": "Itti dabalii", + "loadMoreFailed": "Dabalataan baachuu hin dandeenye.", + "truncated": "Sochii kireedii durii argachuun ni danda'ama." }, "inviteMember": { "emailError": "Teessoo email sirrii seeni", @@ -2971,7 +2974,10 @@ "draft": "Daraftii", "uncollectible": "Kan walitti hin qabamne", "unknown": "Hin beekamu" - } + }, + "loadMore": "Itti dabalii", + "loadMoreFailed": "Dabalataan baachuu 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 d8557abb26..d01c4e97d5 100644 --- a/apps/mobile/src/i18n/locales/or.json +++ b/apps/mobile/src/i18n/locales/or.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "ହିସାବ ସଂଶୋଧନ", "credits_expired": "କ୍ରେଡିଟ୍ ସମାପ୍ତ" - } + }, + "loadMore": "ଅଧିକ ଲୋଡ୍ କରନ୍ତୁ", + "loadMoreFailed": "ଅଧିକ ଲୋଡ୍ ହୋଇପାରିଲା ନାହିଁ।", + "truncated": "ପୁରୁଣା କ୍ରେଡିଟ୍ କାର୍ଯ୍ୟକଳାପ ଉପଲବ୍ଧ।" }, "inviteMember": { "emailError": "ଏକ ବୈଧ ଇମେଲ୍ ଠିକଣା ପ୍ରବେଶ କରନ୍ତୁ", @@ -2971,7 +2974,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 f3a5f5c699..d715644ed2 100644 --- a/apps/mobile/src/i18n/locales/pa.json +++ b/apps/mobile/src/i18n/locales/pa.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "ਲੇਖਾ ਸਮਾਯੋਜਨ", "credits_expired": "ਕ੍ਰੈਡਿਟ ਮਿਆਦ ਪੁੱਗ ਗਏ" - } + }, + "loadMore": "ਹੋਰ ਲੋਡ ਕਰੋ", + "loadMoreFailed": "ਹੋਰ ਲੋਡ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਿਆ।", + "truncated": "ਪੁਰਾਣੀ ਕ੍ਰੈਡਿਟ ਗਤੀਵਿਧੀ ਉਪਲਬਧ ਹੈ।" }, "inviteMember": { "emailError": "ਵੈਧ ਈਮੇਲ ਪਤਾ ਦਰਜ ਕਰੋ", @@ -2971,7 +2974,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 5da88aa35e..892dc4f2f7 100644 --- a/apps/mobile/src/i18n/locales/pl.json +++ b/apps/mobile/src/i18n/locales/pl.json @@ -2970,7 +2970,10 @@ "team-topup-bonus-2025": "Bonus doładowania zespołu 2025", "accounting_adjustment": "Korekta księgowa", "credits_expired": "Kredyty wygasły" - } + }, + "loadMore": "Pokaż więcej", + "loadMoreFailed": "Nie udało się wczytać więcej.", + "truncated": "Dostępna jest starsza historia kredytów." }, "hub": { "balance": "Saldo", @@ -3006,7 +3009,10 @@ "draft": "Wersja robocza", "uncollectible": "Nieściągalna", "unknown": "Nieznana" - } + }, + "loadMore": "Pokaż więcej", + "loadMoreFailed": "Nie udało się wczytać 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 739206755a..9fdce2c1eb 100644 --- a/apps/mobile/src/i18n/locales/ps.json +++ b/apps/mobile/src/i18n/locales/ps.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "د ټیم ټاپ اپ بونس 2025", "accounting_adjustment": "د محاسبې سمون", "credits_expired": "کریډیټونه منقضي شوي" - } + }, + "loadMore": "نور مه پورته کړئ", + "loadMoreFailed": "نور پورته کېدل ناشوني شول.", + "truncated": "پخوانۍ کریډیټي کړنې شته دي." }, "inviteMember": { "emailError": "یو سم بریښنالیک آدرس دننه کړئ", @@ -2971,7 +2974,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 1c9d7ba94d..b09e7a7844 100644 --- a/apps/mobile/src/i18n/locales/pt-BR.json +++ b/apps/mobile/src/i18n/locales/pt-BR.json @@ -2950,7 +2950,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", @@ -2986,7 +2989,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 60a4d727b9..9056bba5de 100644 --- a/apps/mobile/src/i18n/locales/pt.json +++ b/apps/mobile/src/i18n/locales/pt.json @@ -2964,7 +2964,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", @@ -2991,7 +2994,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 3557f51480..c660a4aad0 100644 --- a/apps/mobile/src/i18n/locales/ro.json +++ b/apps/mobile/src/i18n/locales/ro.json @@ -2964,7 +2964,10 @@ "team-topup-bonus-2025": "Bonus pentru reîncărcarea echipei 2025", "accounting_adjustment": "Ajustare contabilă", "credits_expired": "Credite expirate" - } + }, + "loadMore": "Încarcă mai multe", + "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ă", @@ -2991,7 +2994,10 @@ "draft": "Ciornă", "uncollectible": "Neîncasabilă", "unknown": "Necunoscut" - } + }, + "loadMore": "Încarcă mai multe", + "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 66bc2eeb13..c6da48ace8 100644 --- a/apps/mobile/src/i18n/locales/ru.json +++ b/apps/mobile/src/i18n/locales/ru.json @@ -2970,7 +2970,10 @@ "team-topup-bonus-2025": "Бонус за пополнение баланса команды 2025", "accounting_adjustment": "Бухгалтерская корректировка", "credits_expired": "Кредиты истекли" - } + }, + "loadMore": "Загрузить ещё", + "loadMoreFailed": "Не удалось загрузить.", + "truncated": "Доступна более ранняя активность по кредитам." }, "hub": { "balance": "Баланс", @@ -3006,7 +3009,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 20e21e0903..2e48bc0d6d 100644 --- a/apps/mobile/src/i18n/locales/si.json +++ b/apps/mobile/src/i18n/locales/si.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "2025 කණ්ඩායම් ඉහළ දැමීමේ ප්‍රසාදය", "accounting_adjustment": "ගිණුම්කරණ ගැලපීම", "credits_expired": "ණය කල් ඉකුත් විය" - } + }, + "loadMore": "තව බාගන්න", + "loadMoreFailed": "තව බාගත නොහැකි විය.", + "truncated": "පැරණි ණය ක්රියාකාරකම් ලබා ගත හැක." }, "inviteMember": { "emailError": "වලංගු ඊමේල් ලිපිනයක් ඇතුළු කරන්න", @@ -2971,7 +2974,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 d24ddeeb9a..6143ccfc3c 100644 --- a/apps/mobile/src/i18n/locales/sk.json +++ b/apps/mobile/src/i18n/locales/sk.json @@ -2984,7 +2984,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": "Nepodarilo sa načítať viac.", + "truncated": "Staršia kreditná aktivita je k dispozícii." }, "inviteMember": { "emailError": "Zadajte platnú e-mailovú adresu", @@ -3011,7 +3014,10 @@ "draft": "Koncept", "uncollectible": "Nevymožiteľná", "unknown": "Neznáme" - } + }, + "loadMore": "Načítať viac", + "loadMoreFailed": "Nepodarilo sa načítať viac.", + "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 2d75260db9..2acffd8767 100644 --- a/apps/mobile/src/i18n/locales/sl.json +++ b/apps/mobile/src/i18n/locales/sl.json @@ -2984,7 +2984,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": "Ni bilo mogoče naložiti več.", + "truncated": "Starejša dejavnost kredita je na voljo." }, "inviteMember": { "emailError": "Vnesi veljaven e-poštni naslov", @@ -3011,7 +3014,10 @@ "draft": "Osnutek", "uncollectible": "Neizterljivo", "unknown": "Neznano" - } + }, + "loadMore": "Naloži več", + "loadMoreFailed": "Ni bilo mogoče naložiti več.", + "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 d164157755..a70f9f900b 100644 --- a/apps/mobile/src/i18n/locales/so.json +++ b/apps/mobile/src/i18n/locales/so.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Bonuska kooxda ee dhaaminta 2025", "accounting_adjustment": "Hagaajinta xisaabta", "credits_expired": "Credits oo dhacay" - } + }, + "loadMore": "Wax badan", + "loadMoreFailed": "Kuma soo shuban karin wax badan.", + "truncated": "Hawlaha credit ee hore waa la heli karaa." }, "inviteMember": { "emailError": "Geli ciwaan email oo ansax ah", @@ -2971,7 +2974,10 @@ "draft": "Qabyo", "uncollectible": "Lama ururin karo", "unknown": "Lama garanayo" - } + }, + "loadMore": "Wax badan", + "loadMoreFailed": "Kuma soo shuban karin wax badan.", + "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 a7c6d60719..59676d1a59 100644 --- a/apps/mobile/src/i18n/locales/sq.json +++ b/apps/mobile/src/i18n/locales/sq.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Bonusi i ekipit për rimbushje 2025", "accounting_adjustment": "Rregullim kontabël", "credits_expired": "Kredite të skaduara" - } + }, + "loadMore": "Shkarko më shumë", + "loadMoreFailed": "Nuk u shkarkua dot më shumë.", + "truncated": "Aktiviteti i vjetër i kredisë është i disponueshëm." }, "inviteMember": { "emailError": "Futni një adresë email-i të vlefshme", @@ -2971,7 +2974,10 @@ "draft": "Draft", "uncollectible": "E pakolektueshme", "unknown": "E panjohur" - } + }, + "loadMore": "Shkarko më shumë", + "loadMoreFailed": "Nuk u shkarkua dot 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 417d06e9c5..99286351c1 100644 --- a/apps/mobile/src/i18n/locales/sr.json +++ b/apps/mobile/src/i18n/locales/sr.json @@ -2964,7 +2964,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": "Ne mogu da učitam još.", + "truncated": "Starija kreditna aktivnost je dostupna." }, "inviteMember": { "emailError": "Unesite važeću adresu e-pošte", @@ -2991,7 +2994,10 @@ "draft": "Nacrt", "uncollectible": "Nenaplativo", "unknown": "Nepoznato" - } + }, + "loadMore": "Učitaj još", + "loadMoreFailed": "Ne mogu da učitam još.", + "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 78b40dc984..507f80bcf7 100644 --- a/apps/mobile/src/i18n/locales/sv.json +++ b/apps/mobile/src/i18n/locales/sv.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Team-påfyllnadsbonus 2025", "accounting_adjustment": "Redovisningsjustering", "credits_expired": "Krediter upphörde" - } + }, + "loadMore": "Ladda fler", + "loadMoreFailed": "Kunde inte ladda mer.", + "truncated": "Äldre kreditaktivitet finns tillgänglig." }, "inviteMember": { "emailError": "Ange en giltig e-postadress", @@ -2971,7 +2974,10 @@ "draft": "Utkast", "uncollectible": "Oindrivbar", "unknown": "Okänd" - } + }, + "loadMore": "Ladda fler", + "loadMoreFailed": "Kunde inte ladda 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 2a1845532c..25be31a7d9 100644 --- a/apps/mobile/src/i18n/locales/sw.json +++ b/apps/mobile/src/i18n/locales/sw.json @@ -2944,7 +2944,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", @@ -2971,7 +2974,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 c6bf6952f0..843c13f5f8 100644 --- a/apps/mobile/src/i18n/locales/ta.json +++ b/apps/mobile/src/i18n/locales/ta.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "குழு டாப்-அப் போனஸ் 2025", "accounting_adjustment": "கணக்கியல் சரிசெய்தல்", "credits_expired": "கிரெடிட்கள் காலாவதியானது" - } + }, + "loadMore": "மேலும் ஏற்றவும்", + "loadMoreFailed": "மேலும் ஏற்ற முடியவில்லை.", + "truncated": "பழைய கிரெடிட் செயல்பாடு கிடைக்கிறது." }, "inviteMember": { "emailError": "செல்லுபடியாகும் மின்னஞ்சல் முகவரியை உள்ளிடவும்", @@ -2971,7 +2974,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 cfe5ee6d6c..96f14b1955 100644 --- a/apps/mobile/src/i18n/locales/te.json +++ b/apps/mobile/src/i18n/locales/te.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "టీమ్ టాప్-అప్ బోనస్ 2025", "accounting_adjustment": "అకౌంటింగ్ సర్దుబాటు", "credits_expired": "క్రెడిట్లు గడువు ముగిశాయి" - } + }, + "loadMore": "మరిన్ని లోడ్ చేయండి", + "loadMoreFailed": "మరిన్ని లోడ్ చేయలేకపోయాము.", + "truncated": "పాత క్రెడిట్ కార్యకలాపం అందుబాటులో ఉంది." }, "inviteMember": { "emailError": "చెల్లుబాటు అయ్యే ఇమెయిల్ చిరునామాను నమోదు చేయండి", @@ -2971,7 +2974,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 6f4ba0c805..0e4e5b0d9f 100644 --- a/apps/mobile/src/i18n/locales/th.json +++ b/apps/mobile/src/i18n/locales/th.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "โบนัสเติมเงินทีม 2025", "accounting_adjustment": "การปรับปรุงบัญชี", "credits_expired": "เครดิตหมดอายุ" - } + }, + "loadMore": "โหลดเพิ่ม", + "loadMoreFailed": "ไม่สามารถโหลดเพิ่มได้", + "truncated": "มีกิจกรรมเครดิตเก่ากว่าให้ดู" }, "inviteMember": { "emailError": "ป้อนที่อยู่อีเมลที่ถูกต้อง", @@ -2971,7 +2974,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 47e56220c7..8faba96a6d 100644 --- a/apps/mobile/src/i18n/locales/tr.json +++ b/apps/mobile/src/i18n/locales/tr.json @@ -2930,7 +2930,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 fazla yüklenemedi.", + "truncated": "Daha eski kredi hareketleri mevcut." }, "hub": { "balance": "Bakiye", @@ -2966,7 +2969,10 @@ "draft": "Taslak", "uncollectible": "Tahsil edilemez", "unknown": "Bilinmiyor" - } + }, + "loadMore": "Daha fazla yükle", + "loadMoreFailed": "Daha fazla 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 8cdac07bbf..6c2cbbd5ac 100644 --- a/apps/mobile/src/i18n/locales/uk.json +++ b/apps/mobile/src/i18n/locales/uk.json @@ -2970,7 +2970,10 @@ "team-topup-bonus-2025": "Командний бонус поповнення 2025", "accounting_adjustment": "Бухгалтерське коригування", "credits_expired": "Термін дії кредитів минув" - } + }, + "loadMore": "Завантажити ще", + "loadMoreFailed": "Не вдалося завантажити ще.", + "truncated": "Доступна старіша активність за кредитами." }, "hub": { "balance": "Баланс", @@ -3006,7 +3009,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 0e3729148d..78be2ce7a2 100644 --- a/apps/mobile/src/i18n/locales/ur.json +++ b/apps/mobile/src/i18n/locales/ur.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "ٹیم ٹاپ اپ بونس 2025", "accounting_adjustment": "اکاؤنٹنگ ایڈجسٹمنٹ", "credits_expired": "کریڈٹس کی میعاد ختم" - } + }, + "loadMore": "مزید لوڈ کریں", + "loadMoreFailed": "مزید لوڈ نہیں ہو سکا۔", + "truncated": "پرانے کریڈٹ کی سرگرمی دستیاب ہے۔" }, "inviteMember": { "emailError": "درست ای میل پتہ درج کریں", @@ -2971,7 +2974,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 92f2916dad..062077d974 100644 --- a/apps/mobile/src/i18n/locales/uz.json +++ b/apps/mobile/src/i18n/locales/uz.json @@ -2944,7 +2944,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", @@ -2971,7 +2974,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 455691db46..0cc8e6617f 100644 --- a/apps/mobile/src/i18n/locales/vi.json +++ b/apps/mobile/src/i18n/locales/vi.json @@ -2930,7 +2930,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ư", @@ -2966,7 +2969,10 @@ "draft": "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 e9e8b9b53a..0bb066a7ae 100644 --- a/apps/mobile/src/i18n/locales/yo.json +++ b/apps/mobile/src/i18n/locales/yo.json @@ -2944,7 +2944,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": "Ṣàgbé síwájú síi", + "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", @@ -2971,7 +2974,10 @@ "draft": "Àkọsílẹ̀", "uncollectible": "Àìlègbà", "unknown": "Àìmọ̀" - } + }, + "loadMore": "Ṣàgbé síwájú síi", + "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 e6f7067bd6..3b59c202a6 100644 --- a/apps/mobile/src/i18n/locales/zh-Hans.json +++ b/apps/mobile/src/i18n/locales/zh-Hans.json @@ -2930,7 +2930,10 @@ "team-topup-bonus-2025": "Team-topup-bonus-2025", "accounting_adjustment": "会计调整", "credits_expired": "积分已过期" - } + }, + "loadMore": "加载更多", + "loadMoreFailed": "无法加载更多。", + "truncated": "可查看更早的信用活动。" }, "hub": { "balance": "余额", @@ -2966,7 +2969,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 d05487d6ce..cbdfbf83f0 100644 --- a/apps/mobile/src/i18n/locales/zh-Hant.json +++ b/apps/mobile/src/i18n/locales/zh-Hant.json @@ -2930,7 +2930,10 @@ "team-topup-bonus-2025": "團隊加值獎勵 2025", "accounting_adjustment": "會計調整", "credits_expired": "點數已到期" - } + }, + "loadMore": "載入更多", + "loadMoreFailed": "無法載入更多。", + "truncated": "可查看較舊的額度活動。" }, "hub": { "balance": "餘額", @@ -2966,7 +2969,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 af6c8cdbaf..7401c429b6 100644 --- a/apps/mobile/src/i18n/locales/zu.json +++ b/apps/mobile/src/i18n/locales/zu.json @@ -2944,7 +2944,10 @@ "team-topup-bonus-2025": "Ibhonasi ye-team top-up 2025", "accounting_adjustment": "Ukulungiswa kwezimali", "credits_expired": "Amakhredithi aphelelwe yisikhathi" - } + }, + "loadMore": "Layisha okunye", + "loadMoreFailed": "Ayikwazanga ukulayisha okunye.", + "truncated": "Umsebenzi wekhredithi omdala uyatholakala." }, "inviteMember": { "emailError": "Faka ikheli le-imeyili elivumelekile", @@ -2971,7 +2974,10 @@ "draft": "Okusalungiswa", "uncollectible": "Okungakhokhiwa", "unknown": "Okungaziwa" - } + }, + "loadMore": "Layisha okunye", + "loadMoreFailed": "Ayikwazanga ukulayisha okunye.", + "truncated": "Ama-invoyisi amadala ayatholakala." }, "lowBalanceAlert": { "emailPlaceholder": "name@company.com", From 03280ca7900f23cdbf37f05b53d54089789c07ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 26 Aug 2026 02:06:26 +0200 Subject: [PATCH 13/22] fix(mobile): remove dead new-session keys from level 3 --- apps/mobile/src/i18n/locales/en.json | 9 --------- 1 file changed, 9 deletions(-) diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index 5d6d890454..83716cadc6 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -2143,24 +2143,15 @@ "newSession": { "title": "New session", "repository": "Repository", - "recentlyUsed": "Recently used", "couldNotLoadRepositories": "Couldn't load repositories", "connectGithub": "Connect GitHub", "connectGithubDescription": "Connect GitHub in your browser, then return here to pick a repository.", - "connectGitlab": "Connect GitLab", - "connectGitlabDescription": "Connect GitLab in your browser, then return here to pick a repository.", - "connectBitbucket": "Connect Bitbucket", - "connectBitbucketDescription": "Connect Bitbucket in your browser, then return here to pick a repository.", "openGithub": "Open GitHub", - "openGitlab": "Open GitLab", - "openBitbucket": "Open Bitbucket", "refreshRepositories": "Refresh repositories", "githubConnectionNotVisible": "We can't see your GitHub connection yet", "githubConnectionNotVisibleDescription": "If you installed or configured the Kilo GitHub App, check again — or make sure it was installed for this account/organization.", "checkAgain": "Check again", "githubConnected": "GitHub connected", - "gitlabConnected": "GitLab connected", - "bitbucketConnected": "Bitbucket connected", "noRepositoriesVisible": "No repositories visible. Check repository access for the Kilo GitHub App, then refresh.", "runOnWithTarget": "Run on: {{target}}", "environment": "Environment", From 3c850d44b9a2dcdc67fa6ee49f054f1b7273ef12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 26 Aug 2026 06:23:51 +0200 Subject: [PATCH 14/22] refactor(web): reuse unified invoice mapping in array path --- apps/web/src/lib/stripe/index.ts | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/apps/web/src/lib/stripe/index.ts b/apps/web/src/lib/stripe/index.ts index b0bb49ad54..24995c7af7 100644 --- a/apps/web/src/lib/stripe/index.ts +++ b/apps/web/src/lib/stripe/index.ts @@ -676,32 +676,7 @@ export async function getStripeInvoices( const invoices = await client.invoices.list(listParams); const invoiceData: Stripe.Invoice[] = invoices.data; - return invoiceData.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 => { - const hasSeatsMetadata = - line.metadata != null && Object.prototype.hasOwnProperty.call(line.metadata, 'seats'); - const priceId = line.pricing?.price_details?.price; - const hasSeatPriceId = priceId != null && KNOWN_SEAT_PRICE_IDS.has(priceId); - return hasSeatsMetadata || hasSeatPriceId; - }) ?? false; - - const firstLineDescription = invoice.lines?.data?.[0]?.description || null; - - return { - id: invoice.id || '', - number: invoice.number, - status: invoice.status || 'unknown', - amount_due: invoice.amount_due || 0, - currency: invoice.currency || 'usd', - created: invoice.created || 0, - hosted_invoice_url: invoice.hosted_invoice_url || null, - invoice_pdf: invoice.invoice_pdf || null, - invoice_type: isSeatInvoice ? 'seats' : 'topup', - description: firstLineDescription, - }; - }); + return mapStripeInvoicesToUnified(invoiceData); } function mapStripeInvoicesToUnified(invoices: Stripe.Invoice[]): UnifiedInvoice[] { From 97efebfe572cdf9b7d75da8420166831f8e2b523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 26 Aug 2026 06:31:48 +0200 Subject: [PATCH 15/22] refactor(mobile): derive org ledger types from page procedures --- .../src/lib/hooks/use-organization-queries.ts | 30 ++++--------------- 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/apps/mobile/src/lib/hooks/use-organization-queries.ts b/apps/mobile/src/lib/hooks/use-organization-queries.ts index 4693cc79ad..cc7dfe661b 100644 --- a/apps/mobile/src/lib/hooks/use-organization-queries.ts +++ b/apps/mobile/src/lib/hooks/use-organization-queries.ts @@ -1,4 +1,5 @@ import { canManageOrganizationBilling } from '@kilocode/app-shared/organizations'; +import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; @@ -6,6 +7,8 @@ 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 @@ -140,19 +143,8 @@ export function useOrgUsageStats(organizationId: string | null) { ); } -function useOrgCreditTransactions(organizationId: string | null) { - const trpc = useTRPC(); - return useQuery( - trpc.organizations.creditTransactions.queryOptions( - { organizationId: organizationId ?? '' }, - { enabled: organizationId != null } - ) - ); -} - -export type CreditTransaction = NonNullable< - ReturnType['data'] ->[number]; +export type CreditTransaction = + RouterOutputs['organizations']['creditTransactionsPage']['entries'][number]; /** * Cursor-paginated credit transactions for an organization. Mirrors the legacy @@ -181,17 +173,7 @@ export function useOrgCreditTransactionsPage(organizationId: string | null) { return { query, entries, hasMore }; } -function useOrgInvoices(organizationId: string | null) { - const trpc = useTRPC(); - return useQuery( - trpc.organizations.invoices.queryOptions( - { organizationId: organizationId ?? '', period: 'year' }, - { enabled: organizationId != null } - ) - ); -} - -export type OrgInvoice = NonNullable['data']>[number]; +export type OrgInvoice = RouterOutputs['organizations']['invoicesPage']['entries'][number]; /** * Cursor-paginated invoices for an organization. Mirrors the legacy From 914f82b9518f0be82f2128aba3ccc9be475100a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 26 Aug 2026 14:29:58 +0200 Subject: [PATCH 16/22] fix(i18n): align org ledger copy with existing prReview wording --- apps/mobile/src/i18n/locales/af.json | 4 ++-- apps/mobile/src/i18n/locales/am.json | 4 ++-- apps/mobile/src/i18n/locales/ar.json | 4 ++-- apps/mobile/src/i18n/locales/az.json | 8 ++++---- apps/mobile/src/i18n/locales/be.json | 8 ++++---- apps/mobile/src/i18n/locales/bg.json | 4 ++-- apps/mobile/src/i18n/locales/bn.json | 4 ++-- apps/mobile/src/i18n/locales/bs.json | 4 ++-- apps/mobile/src/i18n/locales/ca.json | 8 ++++---- apps/mobile/src/i18n/locales/ckb.json | 4 ++-- apps/mobile/src/i18n/locales/cs.json | 4 ++-- apps/mobile/src/i18n/locales/cy.json | 4 ++-- apps/mobile/src/i18n/locales/da.json | 4 ++-- apps/mobile/src/i18n/locales/de.json | 4 ++-- apps/mobile/src/i18n/locales/el.json | 4 ++-- apps/mobile/src/i18n/locales/es.json | 4 ++-- apps/mobile/src/i18n/locales/et.json | 4 ++-- apps/mobile/src/i18n/locales/eu.json | 4 ++-- apps/mobile/src/i18n/locales/fa.json | 4 ++-- apps/mobile/src/i18n/locales/fi.json | 4 ++-- apps/mobile/src/i18n/locales/fil.json | 8 ++++---- apps/mobile/src/i18n/locales/fr.json | 4 ++-- apps/mobile/src/i18n/locales/ga.json | 4 ++-- apps/mobile/src/i18n/locales/gl.json | 4 ++-- apps/mobile/src/i18n/locales/gu.json | 4 ++-- apps/mobile/src/i18n/locales/ha.json | 8 ++++---- apps/mobile/src/i18n/locales/he.json | 4 ++-- apps/mobile/src/i18n/locales/hi.json | 4 ++-- apps/mobile/src/i18n/locales/hr.json | 4 ++-- apps/mobile/src/i18n/locales/ht.json | 4 ++-- apps/mobile/src/i18n/locales/hu.json | 8 ++++---- apps/mobile/src/i18n/locales/hy.json | 4 ++-- apps/mobile/src/i18n/locales/id.json | 4 ++-- apps/mobile/src/i18n/locales/ig.json | 8 ++++---- apps/mobile/src/i18n/locales/is.json | 4 ++-- apps/mobile/src/i18n/locales/it.json | 4 ++-- apps/mobile/src/i18n/locales/ja.json | 4 ++-- apps/mobile/src/i18n/locales/ka.json | 4 ++-- apps/mobile/src/i18n/locales/kk.json | 4 ++-- apps/mobile/src/i18n/locales/km.json | 4 ++-- apps/mobile/src/i18n/locales/kn.json | 4 ++-- apps/mobile/src/i18n/locales/ko.json | 4 ++-- apps/mobile/src/i18n/locales/lo.json | 8 ++++---- apps/mobile/src/i18n/locales/lt.json | 4 ++-- apps/mobile/src/i18n/locales/lv.json | 4 ++-- apps/mobile/src/i18n/locales/mg.json | 8 ++++---- apps/mobile/src/i18n/locales/mi.json | 8 ++++---- apps/mobile/src/i18n/locales/mk.json | 4 ++-- apps/mobile/src/i18n/locales/ml.json | 4 ++-- apps/mobile/src/i18n/locales/mn.json | 8 ++++---- apps/mobile/src/i18n/locales/mr.json | 4 ++-- apps/mobile/src/i18n/locales/ms.json | 4 ++-- apps/mobile/src/i18n/locales/mt.json | 8 ++++---- apps/mobile/src/i18n/locales/my.json | 8 ++++---- apps/mobile/src/i18n/locales/nb.json | 8 ++++---- apps/mobile/src/i18n/locales/ne.json | 4 ++-- apps/mobile/src/i18n/locales/nl.json | 4 ++-- apps/mobile/src/i18n/locales/om.json | 8 ++++---- apps/mobile/src/i18n/locales/or.json | 4 ++-- apps/mobile/src/i18n/locales/pa.json | 4 ++-- apps/mobile/src/i18n/locales/pl.json | 8 ++++---- apps/mobile/src/i18n/locales/ps.json | 8 ++++---- apps/mobile/src/i18n/locales/pt-BR.json | 4 ++-- apps/mobile/src/i18n/locales/pt.json | 4 ++-- apps/mobile/src/i18n/locales/ro.json | 8 ++++---- apps/mobile/src/i18n/locales/ru.json | 8 ++++---- apps/mobile/src/i18n/locales/si.json | 8 ++++---- apps/mobile/src/i18n/locales/sk.json | 4 ++-- apps/mobile/src/i18n/locales/sl.json | 4 ++-- apps/mobile/src/i18n/locales/so.json | 8 ++++---- apps/mobile/src/i18n/locales/sq.json | 8 ++++---- apps/mobile/src/i18n/locales/sr.json | 4 ++-- apps/mobile/src/i18n/locales/sv.json | 8 ++++---- apps/mobile/src/i18n/locales/sw.json | 4 ++-- apps/mobile/src/i18n/locales/ta.json | 8 ++++---- apps/mobile/src/i18n/locales/te.json | 8 ++++---- apps/mobile/src/i18n/locales/tr.json | 4 ++-- apps/mobile/src/i18n/locales/uk.json | 8 ++++---- apps/mobile/src/i18n/locales/ur.json | 4 ++-- apps/mobile/src/i18n/locales/uz.json | 4 ++-- apps/mobile/src/i18n/locales/vi.json | 4 ++-- apps/mobile/src/i18n/locales/yo.json | 8 ++++---- apps/mobile/src/i18n/locales/zh-Hans.json | 4 ++-- apps/mobile/src/i18n/locales/zh-Hant.json | 4 ++-- apps/mobile/src/i18n/locales/zu.json | 8 ++++---- 85 files changed, 226 insertions(+), 226 deletions(-) diff --git a/apps/mobile/src/i18n/locales/af.json b/apps/mobile/src/i18n/locales/af.json index af9ced4a36..de00d297a2 100644 --- a/apps/mobile/src/i18n/locales/af.json +++ b/apps/mobile/src/i18n/locales/af.json @@ -2951,7 +2951,7 @@ "credits_expired": "Krediete verval" }, "loadMore": "Laai meer", - "loadMoreFailed": "Kon nie meer laai nie.", + "loadMoreFailed": "Kon nie meer laai nie", "truncated": "Ouer kredietaktiwiteit is beskikbaar." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "Onbekend" }, "loadMore": "Laai meer", - "loadMoreFailed": "Kon nie meer laai nie.", + "loadMoreFailed": "Kon nie meer laai nie", "truncated": "Ouer faktuur is beskikbaar." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/am.json b/apps/mobile/src/i18n/locales/am.json index 557ef4622b..1eeb708047 100644 --- a/apps/mobile/src/i18n/locales/am.json +++ b/apps/mobile/src/i18n/locales/am.json @@ -2951,7 +2951,7 @@ "credits_expired": "ክሬዲቶች ጊዜያቸው አልፏል" }, "loadMore": "ተጨማሪ ጫን", - "loadMoreFailed": "ተጨማሪ መጫን አልተቻለም።", + "loadMoreFailed": "ተጨማሪ መጫን አልተቻለም", "truncated": "የቆዩ የክሬዲት እንቅስቃሴዎች ይገኛሉ።" }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "ያልታወቀ" }, "loadMore": "ተጨማሪ ጫን", - "loadMoreFailed": "ተጨማሪ መጫን አልተቻለም።", + "loadMoreFailed": "ተጨማሪ መጫን አልተቻለም", "truncated": "የቆዩ ኢንቮይሶች ይገኛሉ።" }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/ar.json b/apps/mobile/src/i18n/locales/ar.json index 367116e6c4..92fa4ebe43 100644 --- a/apps/mobile/src/i18n/locales/ar.json +++ b/apps/mobile/src/i18n/locales/ar.json @@ -3017,7 +3017,7 @@ "credits_expired": "انتهت صلاحية الأرصدة" }, "loadMore": "تحميل المزيد", - "loadMoreFailed": "تعذّر تحميل المزيد.", + "loadMoreFailed": "تعذّر تحميل المزيد", "truncated": "تتوفر نشاطات رصيد أقدم." }, "hub": { @@ -3056,7 +3056,7 @@ "unknown": "غير معروف" }, "loadMore": "تحميل المزيد", - "loadMoreFailed": "تعذّر تحميل المزيد.", + "loadMoreFailed": "تعذّر تحميل المزيد", "truncated": "تتوفر فواتير أقدم." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/az.json b/apps/mobile/src/i18n/locales/az.json index 784398e109..0b7717d9db 100644 --- a/apps/mobile/src/i18n/locales/az.json +++ b/apps/mobile/src/i18n/locales/az.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "Mühasibat düzəlişi", "credits_expired": "Kreditlərin müddəti bitib" }, - "loadMore": "Yüklə", - "loadMoreFailed": "Daha çox yüklənmədi.", + "loadMore": "Daha çox yüklə", + "loadMoreFailed": "Daha çox yüklənə bilmədi", "truncated": "Köhnə kredit fəaliyyəti mövcuddur." }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "Yığıla bilməyən", "unknown": "Naməlum" }, - "loadMore": "Yüklə", - "loadMoreFailed": "Daha çox yüklənmədi.", + "loadMore": "Daha çox yüklə", + "loadMoreFailed": "Daha çox yüklənə bilmədi", "truncated": "Köhnə qaimələr mövcuddur." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/be.json b/apps/mobile/src/i18n/locales/be.json index f9560ab1bc..82b37db4fc 100644 --- a/apps/mobile/src/i18n/locales/be.json +++ b/apps/mobile/src/i18n/locales/be.json @@ -2990,8 +2990,8 @@ "accounting_adjustment": "Бухгалтарская карэкціроўка", "credits_expired": "Крэдыты скончыліся" }, - "loadMore": "Загрузіць яшчэ", - "loadMoreFailed": "Не атрымалася загрузіць больш.", + "loadMore": "Загрузіць больш", + "loadMoreFailed": "Не атрымалася загрузіць больш", "truncated": "Даступная больш ранняя актыўнасць па крэдыту." }, "inviteMember": { @@ -3020,8 +3020,8 @@ "uncollectible": "Неспагнаны", "unknown": "Невядомы" }, - "loadMore": "Загрузіць яшчэ", - "loadMoreFailed": "Не атрымалася загрузіць больш.", + "loadMore": "Загрузіць больш", + "loadMoreFailed": "Не атрымалася загрузіць больш", "truncated": "Даступныя больш раннія рахункі." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/bg.json b/apps/mobile/src/i18n/locales/bg.json index 5183b04c35..78b2dfd8fb 100644 --- a/apps/mobile/src/i18n/locales/bg.json +++ b/apps/mobile/src/i18n/locales/bg.json @@ -2951,7 +2951,7 @@ "credits_expired": "Изтекли кредити" }, "loadMore": "Зареди още", - "loadMoreFailed": "Не можа да се зареди още.", + "loadMoreFailed": "Неуспешно зареждане на още", "truncated": "Налична е по-стара кредитна активност." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "Неизвестна" }, "loadMore": "Зареди още", - "loadMoreFailed": "Не можа да се зареди още.", + "loadMoreFailed": "Неуспешно зареждане на още", "truncated": "Налични са по-стари фактури." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/bn.json b/apps/mobile/src/i18n/locales/bn.json index dca9b87d50..4d1a12742b 100644 --- a/apps/mobile/src/i18n/locales/bn.json +++ b/apps/mobile/src/i18n/locales/bn.json @@ -2951,7 +2951,7 @@ "credits_expired": "ক্রেডিটের মেয়াদ শেষ" }, "loadMore": "আরও লোড করুন", - "loadMoreFailed": "আরও লোড করা যায়নি।", + "loadMoreFailed": "আরও লোড করা যায়নি", "truncated": "পুরোনো ক্রেডিট কার্যকলাপ উপলব্ধ।" }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "অজানা" }, "loadMore": "আরও লোড করুন", - "loadMoreFailed": "আরও লোড করা যায়নি।", + "loadMoreFailed": "আরও লোড করা যায়নি", "truncated": "পুরোনো ইনভয়েস উপলব্ধ।" }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/bs.json b/apps/mobile/src/i18n/locales/bs.json index 5599b532e3..e01531d1ea 100644 --- a/apps/mobile/src/i18n/locales/bs.json +++ b/apps/mobile/src/i18n/locales/bs.json @@ -2971,7 +2971,7 @@ "credits_expired": "Istekli krediti" }, "loadMore": "Učitaj više", - "loadMoreFailed": "Ne mogu učitati više.", + "loadMoreFailed": "Više se nije moglo učitati", "truncated": "Starija aktivnost kredita je dostupna." }, "inviteMember": { @@ -3001,7 +3001,7 @@ "unknown": "Nepoznato" }, "loadMore": "Učitaj više", - "loadMoreFailed": "Ne mogu učitati više.", + "loadMoreFailed": "Više se nije moglo učitati", "truncated": "Starije fakture su dostupne." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/ca.json b/apps/mobile/src/i18n/locales/ca.json index 6c9892bf3a..de28d8460a 100644 --- a/apps/mobile/src/i18n/locales/ca.json +++ b/apps/mobile/src/i18n/locales/ca.json @@ -2970,8 +2970,8 @@ "accounting_adjustment": "Ajust comptable", "credits_expired": "Crèdits caducats" }, - "loadMore": "Carrega'n més", - "loadMoreFailed": "No s'ha pogut carregar més.", + "loadMore": "Carrega més", + "loadMoreFailed": "No s'ha pogut carregar més", "truncated": "Hi ha activitat de crèdit més antiga disponible." }, "inviteMember": { @@ -3000,8 +3000,8 @@ "uncollectible": "No cobrable", "unknown": "Desconegut" }, - "loadMore": "Carrega'n més", - "loadMoreFailed": "No s'ha pogut carregar més.", + "loadMore": "Carrega més", + "loadMoreFailed": "No s'ha pogut carregar més", "truncated": "Hi ha factures més antigues disponibles." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/ckb.json b/apps/mobile/src/i18n/locales/ckb.json index 9cb71d6135..b9e7840eef 100644 --- a/apps/mobile/src/i18n/locales/ckb.json +++ b/apps/mobile/src/i18n/locales/ckb.json @@ -2951,7 +2951,7 @@ "credits_expired": "بەسەرچوونی کرێدت" }, "loadMore": "بارکردنی زیاتر", - "loadMoreFailed": "نەتوانرا زیاتر بار بکرێت.", + "loadMoreFailed": "نەتوانرا زیاتر بار بکرێت", "truncated": "چالاکی بەرایی کۆنیتر بەردەستە." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "نەزانراو" }, "loadMore": "بارکردنی زیاتر", - "loadMoreFailed": "نەتوانرا زیاتر بار بکرێت.", + "loadMoreFailed": "نەتوانرا زیاتر بار بکرێت", "truncated": "فاکتورە کۆنەکان بەردەستن." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/cs.json b/apps/mobile/src/i18n/locales/cs.json index 215e9436d2..ff044086a2 100644 --- a/apps/mobile/src/i18n/locales/cs.json +++ b/apps/mobile/src/i18n/locales/cs.json @@ -2991,7 +2991,7 @@ "credits_expired": "Kredity s prošlou platností" }, "loadMore": "Načíst více", - "loadMoreFailed": "Nepodařilo se načíst další.", + "loadMoreFailed": "Další se nepodařilo načíst", "truncated": "Starší aktivita kreditů je k dispozici." }, "inviteMember": { @@ -3021,7 +3021,7 @@ "unknown": "Neznámé" }, "loadMore": "Načíst více", - "loadMoreFailed": "Nepodařilo se načíst další.", + "loadMoreFailed": "Další se nepodařilo načíst", "truncated": "Starší faktury jsou k dispozici." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/cy.json b/apps/mobile/src/i18n/locales/cy.json index 2bdcea9b1d..9870d32c91 100644 --- a/apps/mobile/src/i18n/locales/cy.json +++ b/apps/mobile/src/i18n/locales/cy.json @@ -3031,7 +3031,7 @@ "credits_expired": "Credydau wedi dod i ben" }, "loadMore": "Llwytho mwy", - "loadMoreFailed": "Methwyd llwytho mwy.", + "loadMoreFailed": "Ni ellid llwytho mwy", "truncated": "Mae gweithgaredd credyd hŷn ar gael." }, "inviteMember": { @@ -3061,7 +3061,7 @@ "unknown": "Anhysbys" }, "loadMore": "Llwytho mwy", - "loadMoreFailed": "Methwyd llwytho mwy.", + "loadMoreFailed": "Ni ellid llwytho mwy", "truncated": "Mae anfonebau hŷn ar gael." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/da.json b/apps/mobile/src/i18n/locales/da.json index 7e58af54dd..9b4dc50dbb 100644 --- a/apps/mobile/src/i18n/locales/da.json +++ b/apps/mobile/src/i18n/locales/da.json @@ -2951,7 +2951,7 @@ "credits_expired": "Udløbne kreditter" }, "loadMore": "Indlæs mere", - "loadMoreFailed": "Det lykkedes ikke at indlæse mere.", + "loadMoreFailed": "Kunne ikke indlæse mere", "truncated": "Ældre kreditaktivitet er tilgængelig." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "Ukendt" }, "loadMore": "Indlæs mere", - "loadMoreFailed": "Det lykkedes ikke at indlæse mere.", + "loadMoreFailed": "Kunne ikke indlæse mere", "truncated": "Ældre fakturaer er tilgængelige." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/de.json b/apps/mobile/src/i18n/locales/de.json index b5363c3fff..94af17f448 100644 --- a/apps/mobile/src/i18n/locales/de.json +++ b/apps/mobile/src/i18n/locales/de.json @@ -2937,7 +2937,7 @@ "credits_expired": "Abgelaufene Credits" }, "loadMore": "Mehr laden", - "loadMoreFailed": "Konnte nicht mehr laden.", + "loadMoreFailed": "Mehr konnte nicht geladen werden", "truncated": "Ältere Guthabenaktivität ist verfügbar." }, "hub": { @@ -2976,7 +2976,7 @@ "unknown": "Unbekannt" }, "loadMore": "Mehr laden", - "loadMoreFailed": "Konnte nicht mehr laden.", + "loadMoreFailed": "Mehr konnte nicht geladen werden", "truncated": "Ältere Rechnungen sind verfügbar." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/el.json b/apps/mobile/src/i18n/locales/el.json index 33619b660e..ee5f1ec2c9 100644 --- a/apps/mobile/src/i18n/locales/el.json +++ b/apps/mobile/src/i18n/locales/el.json @@ -2951,7 +2951,7 @@ "credits_expired": "Ληγμένες πιστώσεις" }, "loadMore": "Φόρτωση περισσότερων", - "loadMoreFailed": "Δεν ήταν δυνατή η φόρτωση περισσότερων.", + "loadMoreFailed": "Δεν ήταν δυνατή η φόρτωση περισσότερων", "truncated": "Παλαιότερη δραστηριότητα πιστώσεων είναι διαθέσιμη." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "Άγνωστο" }, "loadMore": "Φόρτωση περισσότερων", - "loadMoreFailed": "Δεν ήταν δυνατή η φόρτωση περισσότερων.", + "loadMoreFailed": "Δεν ήταν δυνατή η φόρτωση περισσότερων", "truncated": "Παλαιότερα τιμολόγια είναι διαθέσιμα." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/es.json b/apps/mobile/src/i18n/locales/es.json index 6379405a93..334693f6f8 100644 --- a/apps/mobile/src/i18n/locales/es.json +++ b/apps/mobile/src/i18n/locales/es.json @@ -2957,7 +2957,7 @@ "credits_expired": "Créditos caducados" }, "loadMore": "Cargar más", - "loadMoreFailed": "No se pudo cargar más.", + "loadMoreFailed": "No se pudo cargar más", "truncated": "Hay actividad de crédito más antigua disponible." }, "hub": { @@ -2996,7 +2996,7 @@ "unknown": "Desconocido" }, "loadMore": "Cargar más", - "loadMoreFailed": "No se pudo cargar más.", + "loadMoreFailed": "No se pudo cargar más", "truncated": "Hay facturas más antiguas disponibles." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/et.json b/apps/mobile/src/i18n/locales/et.json index 98b5cdee84..d2318aae80 100644 --- a/apps/mobile/src/i18n/locales/et.json +++ b/apps/mobile/src/i18n/locales/et.json @@ -2951,7 +2951,7 @@ "credits_expired": "Krediidid aegusid" }, "loadMore": "Laadi rohkem", - "loadMoreFailed": "Rohkem ei õnnestunud laadida.", + "loadMoreFailed": "Rohkemate laadimine ebaõnnestus", "truncated": "Varasem krediidiajalugu on saadaval." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "Teadmata" }, "loadMore": "Laadi rohkem", - "loadMoreFailed": "Rohkem ei õnnestunud laadida.", + "loadMoreFailed": "Rohkemate laadimine ebaõnnestus", "truncated": "Varasemad arved on saadaval." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/eu.json b/apps/mobile/src/i18n/locales/eu.json index 6603b8e958..b3f4a3a62b 100644 --- a/apps/mobile/src/i18n/locales/eu.json +++ b/apps/mobile/src/i18n/locales/eu.json @@ -2951,7 +2951,7 @@ "credits_expired": "Kredituak iraungita" }, "loadMore": "Kargatu gehiago", - "loadMoreFailed": "Ezin izan da gehiago kargatu.", + "loadMoreFailed": "Ezin izan dira gehiago kargatu", "truncated": "Kreditu-jarduera zaharragoa eskuragarri dago." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "Ezezaguna" }, "loadMore": "Kargatu gehiago", - "loadMoreFailed": "Ezin izan da gehiago kargatu.", + "loadMoreFailed": "Ezin izan dira gehiago kargatu", "truncated": "Faktura zaharrak eskuragarri daude." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/fa.json b/apps/mobile/src/i18n/locales/fa.json index 0a1eb303ab..0d49891762 100644 --- a/apps/mobile/src/i18n/locales/fa.json +++ b/apps/mobile/src/i18n/locales/fa.json @@ -2951,7 +2951,7 @@ "credits_expired": "اعتبارها منقضی شدند" }, "loadMore": "بارگذاری بیشتر", - "loadMoreFailed": "نمیتوان بیشتر بارگذاری کرد.", + "loadMoreFailed": "بارگذاری بیشتر ممکن نشد", "truncated": "فعالیت اعتبار قدیمیتر در دسترس است." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "ناشناخته" }, "loadMore": "بارگذاری بیشتر", - "loadMoreFailed": "نمیتوان بیشتر بارگذاری کرد.", + "loadMoreFailed": "بارگذاری بیشتر ممکن نشد", "truncated": "فاکتورهای قدیمیتر در دسترس هستند." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/fi.json b/apps/mobile/src/i18n/locales/fi.json index 10297dd0b6..4cab8031bc 100644 --- a/apps/mobile/src/i18n/locales/fi.json +++ b/apps/mobile/src/i18n/locales/fi.json @@ -2951,7 +2951,7 @@ "credits_expired": "Krediitit vanhentuivat" }, "loadMore": "Lataa lisää", - "loadMoreFailed": "Lisää ei voitu ladata.", + "loadMoreFailed": "Lisää ei voitu ladata", "truncated": "Vanhempaa luottotapahtumahistoriaa on saatavilla." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "Tuntematon" }, "loadMore": "Lataa lisää", - "loadMoreFailed": "Lisää ei voitu ladata.", + "loadMoreFailed": "Lisää ei voitu ladata", "truncated": "Vanhempia laskuja on saatavilla." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/fil.json b/apps/mobile/src/i18n/locales/fil.json index 606b9169ec..50a2261142 100644 --- a/apps/mobile/src/i18n/locales/fil.json +++ b/apps/mobile/src/i18n/locales/fil.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "Pagsasaayos ng accounting", "credits_expired": "Nag-expire na credits" }, - "loadMore": "Mag-load pa", - "loadMoreFailed": "Hindi makapag-load pa.", + "loadMore": "I-load pa", + "loadMoreFailed": "Hindi ma-load ang iba pa", "truncated": "Magagamit ang mas lumang aktibidad ng kredito." }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "Hindi makokolekta", "unknown": "Hindi alam" }, - "loadMore": "Mag-load pa", - "loadMoreFailed": "Hindi makapag-load pa.", + "loadMore": "I-load pa", + "loadMoreFailed": "Hindi ma-load ang iba pa", "truncated": "Magagamit ang mas lumang mga invoice." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/fr.json b/apps/mobile/src/i18n/locales/fr.json index 2eb0de0405..7ea1185753 100644 --- a/apps/mobile/src/i18n/locales/fr.json +++ b/apps/mobile/src/i18n/locales/fr.json @@ -2957,7 +2957,7 @@ "credits_expired": "Crédits expirés" }, "loadMore": "Charger plus", - "loadMoreFailed": "Impossible de charger plus.", + "loadMoreFailed": "Impossible de charger plus", "truncated": "L'activité de crédit plus ancienne est disponible." }, "hub": { @@ -2996,7 +2996,7 @@ "unknown": "Inconnu" }, "loadMore": "Charger plus", - "loadMoreFailed": "Impossible de charger plus.", + "loadMoreFailed": "Impossible de charger plus", "truncated": "Les factures plus anciennes sont disponibles." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/ga.json b/apps/mobile/src/i18n/locales/ga.json index e968c8d03d..b966f9a793 100644 --- a/apps/mobile/src/i18n/locales/ga.json +++ b/apps/mobile/src/i18n/locales/ga.json @@ -3011,7 +3011,7 @@ "credits_expired": "Creidmheasanna imithe in éag" }, "loadMore": "Lódáil tuilleadh", - "loadMoreFailed": "Níorbh fhéidir tuilleadh a lódáil.", + "loadMoreFailed": "Níorbh fhéidir tuilleadh a lódáil", "truncated": "Tá gníomhaíocht chreidmheasa níos sine ar fáil." }, "inviteMember": { @@ -3041,7 +3041,7 @@ "unknown": "Anaithnid" }, "loadMore": "Lódáil tuilleadh", - "loadMoreFailed": "Níorbh fhéidir tuilleadh a lódáil.", + "loadMoreFailed": "Níorbh fhéidir tuilleadh a lódáil", "truncated": "Tá sonraisc níos sine ar fáil." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/gl.json b/apps/mobile/src/i18n/locales/gl.json index 1c1b31cb52..a1629ccf55 100644 --- a/apps/mobile/src/i18n/locales/gl.json +++ b/apps/mobile/src/i18n/locales/gl.json @@ -2951,7 +2951,7 @@ "credits_expired": "Créditos caducados" }, "loadMore": "Cargar máis", - "loadMoreFailed": "Non se puido cargar máis.", + "loadMoreFailed": "Non se puideron cargar máis", "truncated": "Hai dispoñible actividade de crédito máis antiga." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "Descoñecido" }, "loadMore": "Cargar máis", - "loadMoreFailed": "Non se puido cargar máis.", + "loadMoreFailed": "Non se puideron cargar máis", "truncated": "Hai facturas máis antigas dispoñibles." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/gu.json b/apps/mobile/src/i18n/locales/gu.json index f6c8aa011c..7d6d523b85 100644 --- a/apps/mobile/src/i18n/locales/gu.json +++ b/apps/mobile/src/i18n/locales/gu.json @@ -2951,7 +2951,7 @@ "credits_expired": "ક્રેડિટ સમાપ્ત થઈ" }, "loadMore": "વધુ લોડ કરો", - "loadMoreFailed": "વધુ લોડ કરી શકાયું નથી.", + "loadMoreFailed": "વધુ લોડ કરી શકાયું નહીં", "truncated": "જૂની ક્રેડિટ પ્રવૃત્તિ ઉપલબ્ધ છે." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "અજ્ઞાત" }, "loadMore": "વધુ લોડ કરો", - "loadMoreFailed": "વધુ લોડ કરી શકાયું નથી.", + "loadMoreFailed": "વધુ લોડ કરી શકાયું નહીં", "truncated": "જૂના ઇન્વૉઇસ ઉપલબ્ધ છે." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/ha.json b/apps/mobile/src/i18n/locales/ha.json index 9e4ead863f..3c97001b54 100644 --- a/apps/mobile/src/i18n/locales/ha.json +++ b/apps/mobile/src/i18n/locales/ha.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "Daidaitawar lissafin kuɗi", "credits_expired": "Credits sun ƙare" }, - "loadMore": "Ɗauke ƙari", - "loadMoreFailed": "Ba a iya Ɗauke ƙari ba.", + "loadMore": "Ɗauki ƙari", + "loadMoreFailed": "Ba a iya ƙara ɗora ba", "truncated": "Ayyukan bashi na baya suna nan." }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "Ba za a iya karɓa ba", "unknown": "Ba a sani ba" }, - "loadMore": "Ɗauke ƙari", - "loadMoreFailed": "Ba a iya Ɗauke ƙari ba.", + "loadMore": "Ɗauki ƙari", + "loadMoreFailed": "Ba a iya ƙara ɗora ba", "truncated": "Lissafin kuɗi na baya suna nan." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/he.json b/apps/mobile/src/i18n/locales/he.json index 792e42af40..4478b0e7b6 100644 --- a/apps/mobile/src/i18n/locales/he.json +++ b/apps/mobile/src/i18n/locales/he.json @@ -2957,7 +2957,7 @@ "credits_expired": "הקרדיטים פגו" }, "loadMore": "טען עוד", - "loadMoreFailed": "לא ניתן היה לטעון עוד.", + "loadMoreFailed": "לא ניתן היה לטעון עוד", "truncated": "קיימת פעילות אשראי ישנה יותר." }, "hub": { @@ -2996,7 +2996,7 @@ "unknown": "לא ידוע" }, "loadMore": "טען עוד", - "loadMoreFailed": "לא ניתן היה לטעון עוד.", + "loadMoreFailed": "לא ניתן היה לטעון עוד", "truncated": "קיימות חשבוניות ישנות יותר." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/hi.json b/apps/mobile/src/i18n/locales/hi.json index 87d4746f85..72067d167c 100644 --- a/apps/mobile/src/i18n/locales/hi.json +++ b/apps/mobile/src/i18n/locales/hi.json @@ -2937,7 +2937,7 @@ "credits_expired": "क्रेडिट समाप्त हुए" }, "loadMore": "और लोड करें", - "loadMoreFailed": "अधिक लोड नहीं कर सके।", + "loadMoreFailed": "और लोड नहीं हो सका", "truncated": "पुरानी क्रेडिट गतिविधि उपलब्ध है।" }, "hub": { @@ -2976,7 +2976,7 @@ "unknown": "अज्ञात" }, "loadMore": "और लोड करें", - "loadMoreFailed": "अधिक लोड नहीं कर सके।", + "loadMoreFailed": "और लोड नहीं हो सका", "truncated": "पुराने चालान उपलब्ध हैं।" }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/hr.json b/apps/mobile/src/i18n/locales/hr.json index 2a47b0f1c8..acd3f27146 100644 --- a/apps/mobile/src/i18n/locales/hr.json +++ b/apps/mobile/src/i18n/locales/hr.json @@ -2971,7 +2971,7 @@ "credits_expired": "Istekli krediti" }, "loadMore": "Učitaj više", - "loadMoreFailed": "Nije moguće učitati više.", + "loadMoreFailed": "Nije moguće učitati više", "truncated": "Dostupna je starija aktivnost kredita." }, "inviteMember": { @@ -3001,7 +3001,7 @@ "unknown": "Nepoznato" }, "loadMore": "Učitaj više", - "loadMoreFailed": "Nije moguće učitati više.", + "loadMoreFailed": "Nije moguće učitati više", "truncated": "Dostupne su starije fakture." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/ht.json b/apps/mobile/src/i18n/locales/ht.json index c017d11ce4..1875d7cf7b 100644 --- a/apps/mobile/src/i18n/locales/ht.json +++ b/apps/mobile/src/i18n/locales/ht.json @@ -2951,7 +2951,7 @@ "credits_expired": "Kredi ekspire" }, "loadMore": "Chaje plis", - "loadMoreFailed": "Nou pa t ka chaje plis.", + "loadMoreFailed": "Pa t ka chaje plis", "truncated": "Gen plis aktivite kredi ki pi ansyen disponib." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "Enkoni" }, "loadMore": "Chaje plis", - "loadMoreFailed": "Nou pa t ka chaje plis.", + "loadMoreFailed": "Pa t ka chaje plis", "truncated": "Gen plis fakti ki pi ansyen disponib." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/hu.json b/apps/mobile/src/i18n/locales/hu.json index 273719c5b5..22c81de4c2 100644 --- a/apps/mobile/src/i18n/locales/hu.json +++ b/apps/mobile/src/i18n/locales/hu.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "Számviteli kiigazítás", "credits_expired": "Lejárt kreditek" }, - "loadMore": "Továbbiak betöltése", - "loadMoreFailed": "A továbbiak betöltése nem sikerült.", + "loadMore": "További betöltése", + "loadMoreFailed": "Nem sikerült betölteni továbbiakat", "truncated": "Korábbi hitelaktivitás érhető el." }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "Behajthatatlan", "unknown": "Ismeretlen" }, - "loadMore": "Továbbiak betöltése", - "loadMoreFailed": "A továbbiak betöltése nem sikerült.", + "loadMore": "További betöltése", + "loadMoreFailed": "Nem sikerült betölteni továbbiakat", "truncated": "Korábbi számlák érhetők el." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/hy.json b/apps/mobile/src/i18n/locales/hy.json index 84fd4ac44f..1b9519975f 100644 --- a/apps/mobile/src/i18n/locales/hy.json +++ b/apps/mobile/src/i18n/locales/hy.json @@ -2951,7 +2951,7 @@ "credits_expired": "Սպառված կրեդիտներ" }, "loadMore": "Բեռնել ավելին", - "loadMoreFailed": "Չհաջողվեց բեռնել ավելին։", + "loadMoreFailed": "Չհաջողվեց բեռնել ավելին", "truncated": "Հին վարկային գործունեությունը հասանելի է։" }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "Անհայտ" }, "loadMore": "Բեռնել ավելին", - "loadMoreFailed": "Չհաջողվեց բեռնել ավելին։", + "loadMoreFailed": "Չհաջողվեց բեռնել ավելին", "truncated": "Հին հաշիվ-ապրանքագրերը հասանելի են։" }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/id.json b/apps/mobile/src/i18n/locales/id.json index 586c794632..2bb4dac78c 100644 --- a/apps/mobile/src/i18n/locales/id.json +++ b/apps/mobile/src/i18n/locales/id.json @@ -2937,7 +2937,7 @@ "credits_expired": "Kredit kedaluwarsa" }, "loadMore": "Muat lebih banyak", - "loadMoreFailed": "Tidak dapat memuat lebih banyak.", + "loadMoreFailed": "Tidak dapat memuat lebih banyak", "truncated": "Aktivitas kredit yang lebih lama tersedia." }, "hub": { @@ -2976,7 +2976,7 @@ "unknown": "Tidak diketahui" }, "loadMore": "Muat lebih banyak", - "loadMoreFailed": "Tidak dapat memuat lebih banyak.", + "loadMoreFailed": "Tidak dapat memuat lebih banyak", "truncated": "Faktur yang lebih lama tersedia." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/ig.json b/apps/mobile/src/i18n/locales/ig.json index 2d7bcdf403..cb3d83ed40 100644 --- a/apps/mobile/src/i18n/locales/ig.json +++ b/apps/mobile/src/i18n/locales/ig.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "Mmezi ndekọ ego", "credits_expired": "Kredit emebiwo" }, - "loadMore": "Bufuo ihe ndị ọzọ", - "loadMoreFailed": "Enweghị ike ibufu ihe ndị ọzọ.", + "loadMore": "Bugo ọzọ", + "loadMoreFailed": "Enweghị ike ibugo ọzọ", "truncated": "Ọrụ kredit ochie dị." }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "Enweghị ike ịnakọta", "unknown": "Amaghị" }, - "loadMore": "Bufuo ihe ndị ọzọ", - "loadMoreFailed": "Enweghị ike ibufu ihe ndị ọzọ.", + "loadMore": "Bugo ọzọ", + "loadMoreFailed": "Enweghị ike ibugo ọzọ", "truncated": "Akwụkwọ ọnụahịa ochie dị." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/is.json b/apps/mobile/src/i18n/locales/is.json index 73cfac9059..b3fe41f642 100644 --- a/apps/mobile/src/i18n/locales/is.json +++ b/apps/mobile/src/i18n/locales/is.json @@ -2951,7 +2951,7 @@ "credits_expired": "Kredítur runnu út" }, "loadMore": "Hlaða meira", - "loadMoreFailed": "Ekki tókst að hlaða meira.", + "loadMoreFailed": "Ekki tókst að hlaða meira", "truncated": "Eldri kreditfærsla er í boði." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "Óþekkt" }, "loadMore": "Hlaða meira", - "loadMoreFailed": "Ekki tókst að hlaða meira.", + "loadMoreFailed": "Ekki tókst að hlaða meira", "truncated": "Eldri reikningar eru í boði." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/it.json b/apps/mobile/src/i18n/locales/it.json index 3ba0eebcd2..ff09e3e67c 100644 --- a/apps/mobile/src/i18n/locales/it.json +++ b/apps/mobile/src/i18n/locales/it.json @@ -2957,7 +2957,7 @@ "credits_expired": "Crediti scaduti" }, "loadMore": "Carica altri", - "loadMoreFailed": "Impossibile caricare altri.", + "loadMoreFailed": "Impossibile caricare altri elementi", "truncated": "Sono disponibili attività di credito più vecchie." }, "hub": { @@ -2996,7 +2996,7 @@ "unknown": "Sconosciuto" }, "loadMore": "Carica altri", - "loadMoreFailed": "Impossibile caricare altri.", + "loadMoreFailed": "Impossibile caricare altri elementi", "truncated": "Sono disponibili fatture più vecchie." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/ja.json b/apps/mobile/src/i18n/locales/ja.json index 01a755aec3..afb79c5c2b 100644 --- a/apps/mobile/src/i18n/locales/ja.json +++ b/apps/mobile/src/i18n/locales/ja.json @@ -2937,7 +2937,7 @@ "credits_expired": "クレジット失効" }, "loadMore": "さらに読み込む", - "loadMoreFailed": "さらに読み込めませんでした。", + "loadMoreFailed": "これ以上読み込めませんでした", "truncated": "以前のクレジット利用状況が利用可能です。" }, "hub": { @@ -2976,7 +2976,7 @@ "unknown": "不明" }, "loadMore": "さらに読み込む", - "loadMoreFailed": "さらに読み込めませんでした。", + "loadMoreFailed": "これ以上読み込めませんでした", "truncated": "以前の請求書が利用可能です。" }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/ka.json b/apps/mobile/src/i18n/locales/ka.json index 8e05386464..b2b6408ce3 100644 --- a/apps/mobile/src/i18n/locales/ka.json +++ b/apps/mobile/src/i18n/locales/ka.json @@ -2951,7 +2951,7 @@ "credits_expired": "ვადაგასული კრედიტები" }, "loadMore": "მეტის ჩატვირთვა", - "loadMoreFailed": "მეტის ჩატვირთვა ვერ მოხერხდა.", + "loadMoreFailed": "მეტის ჩატვირთვა ვერ მოხერხდა", "truncated": "უფრო ძველი კრედიტის აქტივობა ხელმისაწვდომია." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "უცნობი" }, "loadMore": "მეტის ჩატვირთვა", - "loadMoreFailed": "მეტის ჩატვირთვა ვერ მოხერხდა.", + "loadMoreFailed": "მეტის ჩატვირთვა ვერ მოხერხდა", "truncated": "უფრო ძველი ინვოისები ხელმისაწვდომია." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/kk.json b/apps/mobile/src/i18n/locales/kk.json index d3de4bccd2..71d238796e 100644 --- a/apps/mobile/src/i18n/locales/kk.json +++ b/apps/mobile/src/i18n/locales/kk.json @@ -2951,7 +2951,7 @@ "credits_expired": "Кредиттердің мерзімі аяқталды" }, "loadMore": "Көбірек жүктеу", - "loadMoreFailed": "Көбірек жүктелмеді.", + "loadMoreFailed": "Қосымша жүктелмеді", "truncated": "Ескі несие белсенділігі қолжетімді." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "Белгісіз" }, "loadMore": "Көбірек жүктеу", - "loadMoreFailed": "Көбірек жүктелмеді.", + "loadMoreFailed": "Қосымша жүктелмеді", "truncated": "Ескі шот-фактуралар қолжетімді." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/km.json b/apps/mobile/src/i18n/locales/km.json index 328741d58c..14b7fbcea3 100644 --- a/apps/mobile/src/i18n/locales/km.json +++ b/apps/mobile/src/i18n/locales/km.json @@ -2951,7 +2951,7 @@ "credits_expired": "ឥណទានផុតកំណត់" }, "loadMore": "ផ្ទុកបន្ថែម", - "loadMoreFailed": "មិនអាចផ្ទុកបន្ថែមទៀតបានទេ។", + "loadMoreFailed": "មិនអាចផ្ទុកបន្ថែមបានទេ", "truncated": "សកម្មភាពឥណទានចាស់ៗអាចប្រើបាន។" }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "មិនស្គាល់" }, "loadMore": "ផ្ទុកបន្ថែម", - "loadMoreFailed": "មិនអាចផ្ទុកបន្ថែមទៀតបានទេ។", + "loadMoreFailed": "មិនអាចផ្ទុកបន្ថែមបានទេ", "truncated": "វិក្កយបត្រចាស់ៗអាចប្រើបាន។" }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/kn.json b/apps/mobile/src/i18n/locales/kn.json index 0ef23b2e77..5dacdcfa1f 100644 --- a/apps/mobile/src/i18n/locales/kn.json +++ b/apps/mobile/src/i18n/locales/kn.json @@ -2951,7 +2951,7 @@ "credits_expired": "ಕ್ರೆಡಿಟ್‌ಗಳು ಅವಧಿ ಮುಗಿದವು" }, "loadMore": "ಹೆಚ್ಚು ಲೋಡ್ ಮಾಡಿ", - "loadMoreFailed": "ಇನ್ನಷ್ಟು ಲೋಡ್ ಮಾಡಲಾಗಲಿಲ್ಲ.", + "loadMoreFailed": "ಹೆಚ್ಚು ಲೋಡ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ", "truncated": "ಹಳೆಯ ಕ್ರೆಡಿಟ್ ಚಟುವಟಿಕೆ ಲಭ್ಯವಿದೆ." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "ತಿಳಿದಿಲ್ಲ" }, "loadMore": "ಹೆಚ್ಚು ಲೋಡ್ ಮಾಡಿ", - "loadMoreFailed": "ಇನ್ನಷ್ಟು ಲೋಡ್ ಮಾಡಲಾಗಲಿಲ್ಲ.", + "loadMoreFailed": "ಹೆಚ್ಚು ಲೋಡ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ", "truncated": "ಹಳೆಯ ಇನ್ವಾಯ್ಸ್ಗಳು ಲಭ್ಯವಿದೆ." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/ko.json b/apps/mobile/src/i18n/locales/ko.json index 2b7a61c21d..14fee726c3 100644 --- a/apps/mobile/src/i18n/locales/ko.json +++ b/apps/mobile/src/i18n/locales/ko.json @@ -2937,7 +2937,7 @@ "credits_expired": "크레딧 만료" }, "loadMore": "더 불러오기", - "loadMoreFailed": "더 불러올 수 없습니다.", + "loadMoreFailed": "더 불러올 수 없습니다", "truncated": "이전 크레딧 활동을 확인할 수 있습니다." }, "hub": { @@ -2976,7 +2976,7 @@ "unknown": "알 수 없음" }, "loadMore": "더 불러오기", - "loadMoreFailed": "더 불러올 수 없습니다.", + "loadMoreFailed": "더 불러올 수 없습니다", "truncated": "이전 인보이스를 확인할 수 있습니다." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/lo.json b/apps/mobile/src/i18n/locales/lo.json index 6a421ca7f3..cdbe9e3bba 100644 --- a/apps/mobile/src/i18n/locales/lo.json +++ b/apps/mobile/src/i18n/locales/lo.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "ການປັບບັນຊີ", "credits_expired": "ເຄຣດິດໝົດອາຍຸ" }, - "loadMore": "ໂຫຼດເພີ່ມ", - "loadMoreFailed": "ບໍ່ສາມາດໂຫຼດເພີ່ມໄດ້.", + "loadMore": "ໂຫຼດເພີ່ມເຕີມ", + "loadMoreFailed": "ບໍ່ສາມາດໂຫຼດເພີ່ມເຕີມໄດ້", "truncated": "ກິດຈະກຳສິນເຊື່ອເກົ່າກວ່າມີໃຫ້." }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "ເກັບບໍ່ໄດ້", "unknown": "ບໍ່ຮູ້" }, - "loadMore": "ໂຫຼດເພີ່ມ", - "loadMoreFailed": "ບໍ່ສາມາດໂຫຼດເພີ່ມໄດ້.", + "loadMore": "ໂຫຼດເພີ່ມເຕີມ", + "loadMoreFailed": "ບໍ່ສາມາດໂຫຼດເພີ່ມເຕີມໄດ້", "truncated": "ໃບແຈ້ງໜີ້ເກົ່າກວ່າມີໃຫ້." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/lt.json b/apps/mobile/src/i18n/locales/lt.json index 6974b79796..2a756e6089 100644 --- a/apps/mobile/src/i18n/locales/lt.json +++ b/apps/mobile/src/i18n/locales/lt.json @@ -2991,7 +2991,7 @@ "credits_expired": "Kreditai pasibaigė" }, "loadMore": "Įkelti daugiau", - "loadMoreFailed": "Nepavyko įkelti daugiau.", + "loadMoreFailed": "Nepavyko įkelti daugiau", "truncated": "Yra ankstesnės kredito veiklos." }, "inviteMember": { @@ -3021,7 +3021,7 @@ "unknown": "Nežinomas" }, "loadMore": "Įkelti daugiau", - "loadMoreFailed": "Nepavyko įkelti daugiau.", + "loadMoreFailed": "Nepavyko įkelti daugiau", "truncated": "Yra ankstesnių sąskaitų faktūrų." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/lv.json b/apps/mobile/src/i18n/locales/lv.json index 6fe8281ce7..381c12eef7 100644 --- a/apps/mobile/src/i18n/locales/lv.json +++ b/apps/mobile/src/i18n/locales/lv.json @@ -2971,7 +2971,7 @@ "credits_expired": "Kredīti beigušies" }, "loadMore": "Ielādēt vairāk", - "loadMoreFailed": "Neizdevās ielādēt vairāk.", + "loadMoreFailed": "Neizdevās ielādēt vairāk", "truncated": "Ir pieejama vecāka kredīta aktivitāte." }, "inviteMember": { @@ -3001,7 +3001,7 @@ "unknown": "Nezināms" }, "loadMore": "Ielādēt vairāk", - "loadMoreFailed": "Neizdevās ielādēt vairāk.", + "loadMoreFailed": "Neizdevās ielādēt vairāk", "truncated": "Ir pieejami vecāki rēķini." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/mg.json b/apps/mobile/src/i18n/locales/mg.json index 203eeaac0f..76a82f7b7b 100644 --- a/apps/mobile/src/i18n/locales/mg.json +++ b/apps/mobile/src/i18n/locales/mg.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "Fanitsiana kaonty", "credits_expired": "Lany daty ny credits" }, - "loadMore": "Havao bebe kokoa", - "loadMoreFailed": "Tsy afaka ny havaozina bebe kokoa.", + "loadMore": "Hamaky bebe kokoa", + "loadMoreFailed": "Tsy afaka namaky fanampiny", "truncated": "Misy ny fiasana crédit taloha kokoa." }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "Tsy azo angonina", "unknown": "Tsy fantatra" }, - "loadMore": "Havao bebe kokoa", - "loadMoreFailed": "Tsy afaka ny havaozina bebe kokoa.", + "loadMore": "Hamaky bebe kokoa", + "loadMoreFailed": "Tsy afaka namaky fanampiny", "truncated": "Misy ny faktiora taloha kokoa." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/mi.json b/apps/mobile/src/i18n/locales/mi.json index 3fd043ce90..8075b99d98 100644 --- a/apps/mobile/src/i18n/locales/mi.json +++ b/apps/mobile/src/i18n/locales/mi.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "Te whakatikatika kaute", "credits_expired": "Kua pau ngā whiwhinga" }, - "loadMore": "Utaina ētahi atu", - "loadMoreFailed": "Kāore i taea te uta ētahi atu.", + "loadMore": "Uta anō", + "loadMoreFailed": "Kāore i taea te uta ētahi atu", "truncated": "E wātea ana ngā mahi kirimana tawhito." }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "Kāore e taea te kohi", "unknown": "Kāore e mōhiotia" }, - "loadMore": "Utaina ētahi atu", - "loadMoreFailed": "Kāore i taea te uta ētahi atu.", + "loadMore": "Uta anō", + "loadMoreFailed": "Kāore i taea te uta ētahi atu", "truncated": "E wātea ana ngā nama tawhito." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/mk.json b/apps/mobile/src/i18n/locales/mk.json index 0dbadb01be..d4ba4c7ec8 100644 --- a/apps/mobile/src/i18n/locales/mk.json +++ b/apps/mobile/src/i18n/locales/mk.json @@ -2951,7 +2951,7 @@ "credits_expired": "Истечени кредити" }, "loadMore": "Вчитај повеќе", - "loadMoreFailed": "Не можеше да се вчитаат повеќе.", + "loadMoreFailed": "Не можеше да се вчитаат повеќе", "truncated": "Постара активност на кредит е достапна." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "Непознато" }, "loadMore": "Вчитај повеќе", - "loadMoreFailed": "Не можеше да се вчитаат повеќе.", + "loadMoreFailed": "Не можеше да се вчитаат повеќе", "truncated": "Постари фактури се достапни." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/ml.json b/apps/mobile/src/i18n/locales/ml.json index 38f8b28585..f9712bb039 100644 --- a/apps/mobile/src/i18n/locales/ml.json +++ b/apps/mobile/src/i18n/locales/ml.json @@ -2951,7 +2951,7 @@ "credits_expired": "ക്രെഡിറ്റുകൾ കാലഹരണപ്പെട്ടു" }, "loadMore": "കൂടുതൽ ലോഡ് ചെയ്യുക", - "loadMoreFailed": "കൂടുതൽ ലോഡ് ചെയ്യാൻ കഴിഞ്ഞില്ല.", + "loadMoreFailed": "കൂടുതൽ ലോഡ് ചെയ്യാൻ കഴിഞ്ഞില്ല", "truncated": "പഴയ ക്രെഡിറ്റ് പ്രവർത്തനങ്ങൾ ലഭ്യമാണ്." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "അജ്ഞാതം" }, "loadMore": "കൂടുതൽ ലോഡ് ചെയ്യുക", - "loadMoreFailed": "കൂടുതൽ ലോഡ് ചെയ്യാൻ കഴിഞ്ഞില്ല.", + "loadMoreFailed": "കൂടുതൽ ലോഡ് ചെയ്യാൻ കഴിഞ്ഞില്ല", "truncated": "പഴയ ഇൻവോയ്സുകൾ ലഭ്യമാണ്." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/mn.json b/apps/mobile/src/i18n/locales/mn.json index 2d9ab28cff..2b9213f333 100644 --- a/apps/mobile/src/i18n/locales/mn.json +++ b/apps/mobile/src/i18n/locales/mn.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "Нягтлан бодох бүртгэлийн тохируулга", "credits_expired": "Кредит дууссан" }, - "loadMore": "Цааш ачаалах", - "loadMoreFailed": "Цааш ачаалах боломжгүй.", + "loadMore": "Нэмэлт ачаалах", + "loadMoreFailed": "Нэмэлт ачаалж чадсангүй", "truncated": "Хуучин кредит үйл ажиллагааг үзэх боломжтой." }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "Цуглуулах боломжгүй", "unknown": "Тодорхойгүй" }, - "loadMore": "Цааш ачаалах", - "loadMoreFailed": "Цааш ачаалах боломжгүй.", + "loadMore": "Нэмэлт ачаалах", + "loadMoreFailed": "Нэмэлт ачаалж чадсангүй", "truncated": "Хуучин нэхэмжлэлийг үзэх боломжтой." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/mr.json b/apps/mobile/src/i18n/locales/mr.json index a13ce038de..cb17dc27f4 100644 --- a/apps/mobile/src/i18n/locales/mr.json +++ b/apps/mobile/src/i18n/locales/mr.json @@ -2951,7 +2951,7 @@ "credits_expired": "क्रेडिट कालबाह्य झाले" }, "loadMore": "अधिक लोड करा", - "loadMoreFailed": "अधिक लोड करता आले नाही.", + "loadMoreFailed": "अधिक लोड करता आले नाही", "truncated": "जुनी क्रेडिट क्रियाकलाप उपलब्ध आहे." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "अज्ञात" }, "loadMore": "अधिक लोड करा", - "loadMoreFailed": "अधिक लोड करता आले नाही.", + "loadMoreFailed": "अधिक लोड करता आले नाही", "truncated": "जुने इनव्हॉइस उपलब्ध आहेत." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/ms.json b/apps/mobile/src/i18n/locales/ms.json index ff78c0da25..c733155f4a 100644 --- a/apps/mobile/src/i18n/locales/ms.json +++ b/apps/mobile/src/i18n/locales/ms.json @@ -2951,7 +2951,7 @@ "credits_expired": "Kredit tamat tempoh" }, "loadMore": "Muatkan lagi", - "loadMoreFailed": "Tidak dapat memuatkan lagi.", + "loadMoreFailed": "Tidak dapat memuatkan lagi", "truncated": "Aktiviti kredit yang lebih lama tersedia." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "Tidak diketahui" }, "loadMore": "Muatkan lagi", - "loadMoreFailed": "Tidak dapat memuatkan lagi.", + "loadMoreFailed": "Tidak dapat memuatkan lagi", "truncated": "Invois yang lebih lama tersedia." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/mt.json b/apps/mobile/src/i18n/locales/mt.json index 39bccff2c5..c98fab1ea5 100644 --- a/apps/mobile/src/i18n/locales/mt.json +++ b/apps/mobile/src/i18n/locales/mt.json @@ -3010,8 +3010,8 @@ "accounting_adjustment": "Aġġustament tal-kontabilità", "credits_expired": "Krediti skaduti" }, - "loadMore": "Tagħbija aktar", - "loadMoreFailed": "Ma setgħux jitgħabbew aktar.", + "loadMore": "Għabbi aktar", + "loadMoreFailed": "Ma stajniex nittellgħu aktar", "truncated": "Attività ta' kreditu aktar antika hija disponibbli." }, "inviteMember": { @@ -3040,8 +3040,8 @@ "uncollectible": "Ma jistax jinġabar", "unknown": "Mhux magħruf" }, - "loadMore": "Tagħbija aktar", - "loadMoreFailed": "Ma setgħux jitgħabbew aktar.", + "loadMore": "Għabbi aktar", + "loadMoreFailed": "Ma stajniex nittellgħu aktar", "truncated": "Fatturi aktar antiki huma disponibbli." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/my.json b/apps/mobile/src/i18n/locales/my.json index e0a0f004a0..43b7eaf7f1 100644 --- a/apps/mobile/src/i18n/locales/my.json +++ b/apps/mobile/src/i18n/locales/my.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "စာရင်းကိုင် ပြုပြင်မှု", "credits_expired": "သက်တမ်းကုန် ခရက်ဒစ်များ" }, - "loadMore": "နောက်ထပ်ဖတ်ပါ", - "loadMoreFailed": "နောက်ထပ်ဖတ်၍မရပါ။", + "loadMore": "နောက်ထပ်တင်ပါ", + "loadMoreFailed": "နောက်ထပ်တင်မရပါ", "truncated": "ပိုဟောင်းသော ခရက်ဒစ်လုပ်ဆောင်ချက်များ ရနိုင်ပါသည်။" }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "ကောက်ခံ၍မရသော", "unknown": "အမည်မသိ" }, - "loadMore": "နောက်ထပ်ဖတ်ပါ", - "loadMoreFailed": "နောက်ထပ်ဖတ်၍မရပါ။", + "loadMore": "နောက်ထပ်တင်ပါ", + "loadMoreFailed": "နောက်ထပ်တင်မရပါ", "truncated": "ပိုဟောင်းသော ငွေတောင်းခံလွှာများ ရနိုင်ပါသည်။" }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/nb.json b/apps/mobile/src/i18n/locales/nb.json index fd115ff262..faefe2a18a 100644 --- a/apps/mobile/src/i18n/locales/nb.json +++ b/apps/mobile/src/i18n/locales/nb.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "Regnskapsjustering", "credits_expired": "Kreditter utløpt" }, - "loadMore": "Last inn flere", - "loadMoreFailed": "Kunne ikke laste inn flere.", + "loadMore": "Last mer", + "loadMoreFailed": "Kunne ikke laste mer", "truncated": "Tidligere kredittaktivitet er tilgjengelig." }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "Uinnkrevbar", "unknown": "Ukjent" }, - "loadMore": "Last inn flere", - "loadMoreFailed": "Kunne ikke laste inn flere.", + "loadMore": "Last mer", + "loadMoreFailed": "Kunne ikke laste mer", "truncated": "Tidligere fakturaer er tilgjengelige." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/ne.json b/apps/mobile/src/i18n/locales/ne.json index 9a8ec1b972..d4e4eb434e 100644 --- a/apps/mobile/src/i18n/locales/ne.json +++ b/apps/mobile/src/i18n/locales/ne.json @@ -2951,7 +2951,7 @@ "credits_expired": "म्याद सकिएका क्रेडिटहरू" }, "loadMore": "थप लोड गर्नुहोस्", - "loadMoreFailed": "थप लोड गर्न सकिएन।", + "loadMoreFailed": "थप लोड गर्न सकिएन", "truncated": "पुरानो क्रेडिट गतिविधि उपलब्ध छ।" }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "अज्ञात" }, "loadMore": "थप लोड गर्नुहोस्", - "loadMoreFailed": "थप लोड गर्न सकिएन।", + "loadMoreFailed": "थप लोड गर्न सकिएन", "truncated": "पुराना इनभ्वाइसहरू उपलब्ध छन्।" }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/nl.json b/apps/mobile/src/i18n/locales/nl.json index b6262f1f33..b1efd3ed53 100644 --- a/apps/mobile/src/i18n/locales/nl.json +++ b/apps/mobile/src/i18n/locales/nl.json @@ -2937,7 +2937,7 @@ "credits_expired": "Credits verlopen" }, "loadMore": "Meer laden", - "loadMoreFailed": "Meer laden lukte niet.", + "loadMoreFailed": "Kon niet meer laden", "truncated": "Oudere creditactiviteit is beschikbaar." }, "hub": { @@ -2976,7 +2976,7 @@ "unknown": "Onbekend" }, "loadMore": "Meer laden", - "loadMoreFailed": "Meer laden lukte niet.", + "loadMoreFailed": "Kon niet meer laden", "truncated": "Oudere facturen zijn beschikbaar." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/om.json b/apps/mobile/src/i18n/locales/om.json index 3a1c4c066e..f84b2444c1 100644 --- a/apps/mobile/src/i18n/locales/om.json +++ b/apps/mobile/src/i18n/locales/om.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "Sirreeffama herregaa", "credits_expired": "Kiriiditiin xumuramte" }, - "loadMore": "Itti dabalii", - "loadMoreFailed": "Dabalataan baachuu hin dandeenye.", + "loadMore": "Dabalata fayyadami", + "loadMoreFailed": "Dabalataa fe'achuu hin dandeenye", "truncated": "Sochii kireedii durii argachuun ni danda'ama." }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "Kan walitti hin qabamne", "unknown": "Hin beekamu" }, - "loadMore": "Itti dabalii", - "loadMoreFailed": "Dabalataan baachuu hin dandeenye.", + "loadMore": "Dabalata fayyadami", + "loadMoreFailed": "Dabalataa fe'achuu hin dandeenye", "truncated": "Baallii durii argachuun ni danda'ama." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/or.json b/apps/mobile/src/i18n/locales/or.json index 731ae60315..3512c82347 100644 --- a/apps/mobile/src/i18n/locales/or.json +++ b/apps/mobile/src/i18n/locales/or.json @@ -2951,7 +2951,7 @@ "credits_expired": "କ୍ରେଡିଟ୍ ସମାପ୍ତ" }, "loadMore": "ଅଧିକ ଲୋଡ୍ କରନ୍ତୁ", - "loadMoreFailed": "ଅଧିକ ଲୋଡ୍ ହୋଇପାରିଲା ନାହିଁ।", + "loadMoreFailed": "ଅଧିକ ଲୋଡ୍ କରାଯାଇ ପାରିଲା ନାହିଁ", "truncated": "ପୁରୁଣା କ୍ରେଡିଟ୍ କାର୍ଯ୍ୟକଳାପ ଉପଲବ୍ଧ।" }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "ଅଜଣା" }, "loadMore": "ଅଧିକ ଲୋଡ୍ କରନ୍ତୁ", - "loadMoreFailed": "ଅଧିକ ଲୋଡ୍ ହୋଇପାରିଲା ନାହିଁ।", + "loadMoreFailed": "ଅଧିକ ଲୋଡ୍ କରାଯାଇ ପାରିଲା ନାହିଁ", "truncated": "ପୁରୁଣା ଇନଭଏସ୍ ଉପଲବ୍ଧ।" }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/pa.json b/apps/mobile/src/i18n/locales/pa.json index abdcfc08b1..b8022521bf 100644 --- a/apps/mobile/src/i18n/locales/pa.json +++ b/apps/mobile/src/i18n/locales/pa.json @@ -2951,7 +2951,7 @@ "credits_expired": "ਕ੍ਰੈਡਿਟ ਮਿਆਦ ਪੁੱਗ ਗਏ" }, "loadMore": "ਹੋਰ ਲੋਡ ਕਰੋ", - "loadMoreFailed": "ਹੋਰ ਲੋਡ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਿਆ।", + "loadMoreFailed": "ਹੋਰ ਲੋਡ ਨਹੀਂ ਹੋ ਸਕਿਆ", "truncated": "ਪੁਰਾਣੀ ਕ੍ਰੈਡਿਟ ਗਤੀਵਿਧੀ ਉਪਲਬਧ ਹੈ।" }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "ਅਣਜਾਣ" }, "loadMore": "ਹੋਰ ਲੋਡ ਕਰੋ", - "loadMoreFailed": "ਹੋਰ ਲੋਡ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਿਆ।", + "loadMoreFailed": "ਹੋਰ ਲੋਡ ਨਹੀਂ ਹੋ ਸਕਿਆ", "truncated": "ਪੁਰਾਣੇ ਇਨਵੌਇਸ ਉਪਲਬਧ ਹਨ।" }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/pl.json b/apps/mobile/src/i18n/locales/pl.json index 8a2b5edc35..bebbdb3fe7 100644 --- a/apps/mobile/src/i18n/locales/pl.json +++ b/apps/mobile/src/i18n/locales/pl.json @@ -2976,8 +2976,8 @@ "accounting_adjustment": "Korekta księgowa", "credits_expired": "Kredyty wygasły" }, - "loadMore": "Pokaż więcej", - "loadMoreFailed": "Nie udało się wczytać więcej.", + "loadMore": "Załaduj więcej", + "loadMoreFailed": "Nie udało się załadować więcej", "truncated": "Dostępna jest starsza historia kredytów." }, "hub": { @@ -3015,8 +3015,8 @@ "uncollectible": "Nieściągalna", "unknown": "Nieznana" }, - "loadMore": "Pokaż więcej", - "loadMoreFailed": "Nie udało się wczytać więcej.", + "loadMore": "Załaduj więcej", + "loadMoreFailed": "Nie udało się załadować więcej", "truncated": "Dostępne są starsze faktury." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/ps.json b/apps/mobile/src/i18n/locales/ps.json index 616e93a242..1adf7d4b17 100644 --- a/apps/mobile/src/i18n/locales/ps.json +++ b/apps/mobile/src/i18n/locales/ps.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "د محاسبې سمون", "credits_expired": "کریډیټونه منقضي شوي" }, - "loadMore": "نور مه پورته کړئ", - "loadMoreFailed": "نور پورته کېدل ناشوني شول.", + "loadMore": "نور بار کړئ", + "loadMoreFailed": "نور بار کیدی نشو", "truncated": "پخوانۍ کریډیټي کړنې شته دي." }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "نه راټولېدونکی", "unknown": "نامعلوم" }, - "loadMore": "نور مه پورته کړئ", - "loadMoreFailed": "نور پورته کېدل ناشوني شول.", + "loadMore": "نور بار کړئ", + "loadMoreFailed": "نور بار کیدی نشو", "truncated": "پخوانۍ رسیدونه شته دي." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/pt-BR.json b/apps/mobile/src/i18n/locales/pt-BR.json index f4171bfb20..82331464f7 100644 --- a/apps/mobile/src/i18n/locales/pt-BR.json +++ b/apps/mobile/src/i18n/locales/pt-BR.json @@ -2957,7 +2957,7 @@ "credits_expired": "Créditos expirados" }, "loadMore": "Carregar mais", - "loadMoreFailed": "Não foi possível carregar mais.", + "loadMoreFailed": "Não foi possível carregar mais", "truncated": "A atividade de crédito mais antiga está disponível." }, "hub": { @@ -2996,7 +2996,7 @@ "unknown": "Desconhecido" }, "loadMore": "Carregar mais", - "loadMoreFailed": "Não foi possível carregar mais.", + "loadMoreFailed": "Não foi possível carregar mais", "truncated": "As faturas mais antigas estão disponíveis." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/pt.json b/apps/mobile/src/i18n/locales/pt.json index cb872f9da9..5153ddc7ea 100644 --- a/apps/mobile/src/i18n/locales/pt.json +++ b/apps/mobile/src/i18n/locales/pt.json @@ -2971,7 +2971,7 @@ "credits_expired": "Créditos expirados" }, "loadMore": "Carregar mais", - "loadMoreFailed": "Não foi possível carregar mais.", + "loadMoreFailed": "Não foi possível carregar mais", "truncated": "Está disponível atividade de crédito mais antiga." }, "inviteMember": { @@ -3001,7 +3001,7 @@ "unknown": "Desconhecido" }, "loadMore": "Carregar mais", - "loadMoreFailed": "Não foi possível carregar mais.", + "loadMoreFailed": "Não foi possível carregar mais", "truncated": "Estão disponíveis faturas mais antigas." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/ro.json b/apps/mobile/src/i18n/locales/ro.json index 8478b9f2af..a2dcfa2baf 100644 --- a/apps/mobile/src/i18n/locales/ro.json +++ b/apps/mobile/src/i18n/locales/ro.json @@ -2970,8 +2970,8 @@ "accounting_adjustment": "Ajustare contabilă", "credits_expired": "Credite expirate" }, - "loadMore": "Încarcă mai multe", - "loadMoreFailed": "Nu s-a putut încărca mai mult.", + "loadMore": "Încarcă mai mult", + "loadMoreFailed": "Nu s-a putut încărca mai mult", "truncated": "Activitatea de credite mai veche este disponibilă." }, "inviteMember": { @@ -3000,8 +3000,8 @@ "uncollectible": "Neîncasabilă", "unknown": "Necunoscut" }, - "loadMore": "Încarcă mai multe", - "loadMoreFailed": "Nu s-a putut încărca mai mult.", + "loadMore": "Încarcă mai mult", + "loadMoreFailed": "Nu s-a putut încărca mai mult", "truncated": "Sunt disponibile facturi mai vechi." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/ru.json b/apps/mobile/src/i18n/locales/ru.json index 3cd7d30b9c..75bf29b7c4 100644 --- a/apps/mobile/src/i18n/locales/ru.json +++ b/apps/mobile/src/i18n/locales/ru.json @@ -2976,8 +2976,8 @@ "accounting_adjustment": "Бухгалтерская корректировка", "credits_expired": "Кредиты истекли" }, - "loadMore": "Загрузить ещё", - "loadMoreFailed": "Не удалось загрузить.", + "loadMore": "Загрузить больше", + "loadMoreFailed": "Не удалось загрузить больше", "truncated": "Доступна более ранняя активность по кредитам." }, "hub": { @@ -3015,8 +3015,8 @@ "uncollectible": "Не подлежит взысканию", "unknown": "Неизвестно" }, - "loadMore": "Загрузить ещё", - "loadMoreFailed": "Не удалось загрузить.", + "loadMore": "Загрузить больше", + "loadMoreFailed": "Не удалось загрузить больше", "truncated": "Доступны более ранние счета." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/si.json b/apps/mobile/src/i18n/locales/si.json index a5ab76410c..9132d15b51 100644 --- a/apps/mobile/src/i18n/locales/si.json +++ b/apps/mobile/src/i18n/locales/si.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "ගිණුම්කරණ ගැලපීම", "credits_expired": "ණය කල් ඉකුත් විය" }, - "loadMore": "තව බාගන්න", - "loadMoreFailed": "තව බාගත නොහැකි විය.", + "loadMore": "තව පූරණය කරන්න", + "loadMoreFailed": "තවත් පූරණය කළ නොහැකි විය", "truncated": "පැරණි ණය ක්රියාකාරකම් ලබා ගත හැක." }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "එකතු කළ නොහැකි", "unknown": "නොදන්නා" }, - "loadMore": "තව බාගන්න", - "loadMoreFailed": "තව බාගත නොහැකි විය.", + "loadMore": "තව පූරණය කරන්න", + "loadMoreFailed": "තවත් පූරණය කළ නොහැකි විය", "truncated": "පැරණි ඉන්වොයිස් ලබා ගත හැක." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/sk.json b/apps/mobile/src/i18n/locales/sk.json index 654e317a31..8ace435558 100644 --- a/apps/mobile/src/i18n/locales/sk.json +++ b/apps/mobile/src/i18n/locales/sk.json @@ -2991,7 +2991,7 @@ "credits_expired": "Kredity vypršali" }, "loadMore": "Načítať viac", - "loadMoreFailed": "Nepodarilo sa načítať viac.", + "loadMoreFailed": "Ďalšie sa nepodarilo načítať", "truncated": "Staršia kreditná aktivita je k dispozícii." }, "inviteMember": { @@ -3021,7 +3021,7 @@ "unknown": "Neznáme" }, "loadMore": "Načítať viac", - "loadMoreFailed": "Nepodarilo sa načítať viac.", + "loadMoreFailed": "Ďalšie sa nepodarilo načítať", "truncated": "Staršie faktúry sú k dispozícii." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/sl.json b/apps/mobile/src/i18n/locales/sl.json index 0fa2fe3ec8..ab6a6f398d 100644 --- a/apps/mobile/src/i18n/locales/sl.json +++ b/apps/mobile/src/i18n/locales/sl.json @@ -2991,7 +2991,7 @@ "credits_expired": "Potečeni krediti" }, "loadMore": "Naloži več", - "loadMoreFailed": "Ni bilo mogoče naložiti več.", + "loadMoreFailed": "Več ni bilo mogoče naložiti", "truncated": "Starejša dejavnost kredita je na voljo." }, "inviteMember": { @@ -3021,7 +3021,7 @@ "unknown": "Neznano" }, "loadMore": "Naloži več", - "loadMoreFailed": "Ni bilo mogoče naložiti več.", + "loadMoreFailed": "Več ni bilo mogoče naložiti", "truncated": "Starejši računi so na voljo." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/so.json b/apps/mobile/src/i18n/locales/so.json index d5175c0c68..19f4022334 100644 --- a/apps/mobile/src/i18n/locales/so.json +++ b/apps/mobile/src/i18n/locales/so.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "Hagaajinta xisaabta", "credits_expired": "Credits oo dhacay" }, - "loadMore": "Wax badan", - "loadMoreFailed": "Kuma soo shuban karin wax badan.", + "loadMore": "Soo qaad dheeri ah", + "loadMoreFailed": "In ka badan lama soo qaadin karin", "truncated": "Hawlaha credit ee hore waa la heli karaa." }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "Lama ururin karo", "unknown": "Lama garanayo" }, - "loadMore": "Wax badan", - "loadMoreFailed": "Kuma soo shuban karin wax badan.", + "loadMore": "Soo qaad dheeri ah", + "loadMoreFailed": "In ka badan lama soo qaadin karin", "truncated": "Qaansheegyada hore waa la heli karaa." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/sq.json b/apps/mobile/src/i18n/locales/sq.json index 186e1cf4d7..1f81cecb44 100644 --- a/apps/mobile/src/i18n/locales/sq.json +++ b/apps/mobile/src/i18n/locales/sq.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "Rregullim kontabël", "credits_expired": "Kredite të skaduara" }, - "loadMore": "Shkarko më shumë", - "loadMoreFailed": "Nuk u shkarkua dot më shumë.", + "loadMore": "Ngarko më shumë", + "loadMoreFailed": "Nuk u arrit të ngarkohej më shumë", "truncated": "Aktiviteti i vjetër i kredisë është i disponueshëm." }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "E pakolektueshme", "unknown": "E panjohur" }, - "loadMore": "Shkarko më shumë", - "loadMoreFailed": "Nuk u shkarkua dot më shumë.", + "loadMore": "Ngarko më shumë", + "loadMoreFailed": "Nuk u arrit të ngarkohej më shumë", "truncated": "Faturat më të vjetra janë të disponueshme." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/sr.json b/apps/mobile/src/i18n/locales/sr.json index 8db75edea1..753f1763d9 100644 --- a/apps/mobile/src/i18n/locales/sr.json +++ b/apps/mobile/src/i18n/locales/sr.json @@ -2971,7 +2971,7 @@ "credits_expired": "Istekli krediti" }, "loadMore": "Učitaj još", - "loadMoreFailed": "Ne mogu da učitam još.", + "loadMoreFailed": "Nije moguće učitati više", "truncated": "Starija kreditna aktivnost je dostupna." }, "inviteMember": { @@ -3001,7 +3001,7 @@ "unknown": "Nepoznato" }, "loadMore": "Učitaj još", - "loadMoreFailed": "Ne mogu da učitam još.", + "loadMoreFailed": "Nije moguće učitati više", "truncated": "Starije fakture su dostupne." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/sv.json b/apps/mobile/src/i18n/locales/sv.json index 2ec5851c27..5ed8120397 100644 --- a/apps/mobile/src/i18n/locales/sv.json +++ b/apps/mobile/src/i18n/locales/sv.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "Redovisningsjustering", "credits_expired": "Krediter upphörde" }, - "loadMore": "Ladda fler", - "loadMoreFailed": "Kunde inte ladda mer.", + "loadMore": "Läs in mer", + "loadMoreFailed": "Kunde inte läsa in mer", "truncated": "Äldre kreditaktivitet finns tillgänglig." }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "Oindrivbar", "unknown": "Okänd" }, - "loadMore": "Ladda fler", - "loadMoreFailed": "Kunde inte ladda mer.", + "loadMore": "Läs in mer", + "loadMoreFailed": "Kunde inte läsa in mer", "truncated": "Äldre fakturor finns tillgängliga." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/sw.json b/apps/mobile/src/i18n/locales/sw.json index d4562c1209..b0d55f6f9b 100644 --- a/apps/mobile/src/i18n/locales/sw.json +++ b/apps/mobile/src/i18n/locales/sw.json @@ -2951,7 +2951,7 @@ "credits_expired": "Mikopo imeisha" }, "loadMore": "Pakia zaidi", - "loadMoreFailed": "Imeshindwa kupakia zaidi.", + "loadMoreFailed": "Imeshindwa kupakia zaidi", "truncated": "Shughuli za zamani za mkopo zinapatikana." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "Isiyojulikana" }, "loadMore": "Pakia zaidi", - "loadMoreFailed": "Imeshindwa kupakia zaidi.", + "loadMoreFailed": "Imeshindwa kupakia zaidi", "truncated": "Ankara za zamani zinapatikana." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/ta.json b/apps/mobile/src/i18n/locales/ta.json index 89891ce590..2b54266418 100644 --- a/apps/mobile/src/i18n/locales/ta.json +++ b/apps/mobile/src/i18n/locales/ta.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "கணக்கியல் சரிசெய்தல்", "credits_expired": "கிரெடிட்கள் காலாவதியானது" }, - "loadMore": "மேலும் ஏற்றவும்", - "loadMoreFailed": "மேலும் ஏற்ற முடியவில்லை.", + "loadMore": "மேலும் ஏற்று", + "loadMoreFailed": "மேலும் ஏற்ற முடியவில்லை", "truncated": "பழைய கிரெடிட் செயல்பாடு கிடைக்கிறது." }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "வசூலிக்க முடியாதது", "unknown": "தெரியவில்லை" }, - "loadMore": "மேலும் ஏற்றவும்", - "loadMoreFailed": "மேலும் ஏற்ற முடியவில்லை.", + "loadMore": "மேலும் ஏற்று", + "loadMoreFailed": "மேலும் ஏற்ற முடியவில்லை", "truncated": "பழைய விலைப்பட்டியல்கள் கிடைக்கின்றன." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/te.json b/apps/mobile/src/i18n/locales/te.json index 3049cf51a5..947dea58f2 100644 --- a/apps/mobile/src/i18n/locales/te.json +++ b/apps/mobile/src/i18n/locales/te.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "అకౌంటింగ్ సర్దుబాటు", "credits_expired": "క్రెడిట్లు గడువు ముగిశాయి" }, - "loadMore": "మరిన్ని లోడ్ చేయండి", - "loadMoreFailed": "మరిన్ని లోడ్ చేయలేకపోయాము.", + "loadMore": "మరింత లోడ్ చేయండి", + "loadMoreFailed": "మరిన్ని లోడ్ చేయలేకపోయాము", "truncated": "పాత క్రెడిట్ కార్యకలాపం అందుబాటులో ఉంది." }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "వసూలు చేయలేనిది", "unknown": "తెలియదు" }, - "loadMore": "మరిన్ని లోడ్ చేయండి", - "loadMoreFailed": "మరిన్ని లోడ్ చేయలేకపోయాము.", + "loadMore": "మరింత లోడ్ చేయండి", + "loadMoreFailed": "మరిన్ని లోడ్ చేయలేకపోయాము", "truncated": "పాత ఇన్వాయిస్లు అందుబాటులో ఉన్నాయి." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/tr.json b/apps/mobile/src/i18n/locales/tr.json index c6518a7c69..d28c6f8553 100644 --- a/apps/mobile/src/i18n/locales/tr.json +++ b/apps/mobile/src/i18n/locales/tr.json @@ -2937,7 +2937,7 @@ "credits_expired": "Kredilerin süresi doldu" }, "loadMore": "Daha fazla yükle", - "loadMoreFailed": "Daha fazla yüklenemedi.", + "loadMoreFailed": "Daha fazlası yüklenemedi", "truncated": "Daha eski kredi hareketleri mevcut." }, "hub": { @@ -2976,7 +2976,7 @@ "unknown": "BİLİNMİYOR" }, "loadMore": "Daha fazla yükle", - "loadMoreFailed": "Daha fazla yüklenemedi.", + "loadMoreFailed": "Daha fazlası yüklenemedi", "truncated": "Daha eski faturalar mevcut." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/uk.json b/apps/mobile/src/i18n/locales/uk.json index 737225e9bf..2d1f659e15 100644 --- a/apps/mobile/src/i18n/locales/uk.json +++ b/apps/mobile/src/i18n/locales/uk.json @@ -2976,8 +2976,8 @@ "accounting_adjustment": "Бухгалтерське коригування", "credits_expired": "Термін дії кредитів минув" }, - "loadMore": "Завантажити ще", - "loadMoreFailed": "Не вдалося завантажити ще.", + "loadMore": "Завантажити більше", + "loadMoreFailed": "Не вдалося завантажити більше", "truncated": "Доступна старіша активність за кредитами." }, "hub": { @@ -3015,8 +3015,8 @@ "uncollectible": "Безнадійний", "unknown": "Невідомо" }, - "loadMore": "Завантажити ще", - "loadMoreFailed": "Не вдалося завантажити ще.", + "loadMore": "Завантажити більше", + "loadMoreFailed": "Не вдалося завантажити більше", "truncated": "Доступні старіші рахунки-фактури." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/ur.json b/apps/mobile/src/i18n/locales/ur.json index 1aa07466a5..f52a7ae4ef 100644 --- a/apps/mobile/src/i18n/locales/ur.json +++ b/apps/mobile/src/i18n/locales/ur.json @@ -2951,7 +2951,7 @@ "credits_expired": "کریڈٹس کی میعاد ختم" }, "loadMore": "مزید لوڈ کریں", - "loadMoreFailed": "مزید لوڈ نہیں ہو سکا۔", + "loadMoreFailed": "مزید لوڈ نہیں ہو سکا", "truncated": "پرانے کریڈٹ کی سرگرمی دستیاب ہے۔" }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "نامعلوم" }, "loadMore": "مزید لوڈ کریں", - "loadMoreFailed": "مزید لوڈ نہیں ہو سکا۔", + "loadMoreFailed": "مزید لوڈ نہیں ہو سکا", "truncated": "پرانے انوائس دستیاب ہیں۔" }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/uz.json b/apps/mobile/src/i18n/locales/uz.json index 9b95979267..7d54aa483c 100644 --- a/apps/mobile/src/i18n/locales/uz.json +++ b/apps/mobile/src/i18n/locales/uz.json @@ -2951,7 +2951,7 @@ "credits_expired": "Kreditlar muddati tugadi" }, "loadMore": "Ko'proq yuklash", - "loadMoreFailed": "Ko'proq yuklab bo'lmadi.", + "loadMoreFailed": "Ko'proq yuklab bo'lmadi", "truncated": "Eski kredit faoliyati mavjud." }, "inviteMember": { @@ -2981,7 +2981,7 @@ "unknown": "Noma'lum" }, "loadMore": "Ko'proq yuklash", - "loadMoreFailed": "Ko'proq yuklab bo'lmadi.", + "loadMoreFailed": "Ko'proq yuklab bo'lmadi", "truncated": "Eski hisob-fakturalar mavjud." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/vi.json b/apps/mobile/src/i18n/locales/vi.json index 4341abb5ac..e7d261ba74 100644 --- a/apps/mobile/src/i18n/locales/vi.json +++ b/apps/mobile/src/i18n/locales/vi.json @@ -2937,7 +2937,7 @@ "credits_expired": "Tín dụng đã hết hạn" }, "loadMore": "Tải thêm", - "loadMoreFailed": "Không thể 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": { @@ -2976,7 +2976,7 @@ "unknown": "Không xác định" }, "loadMore": "Tải thêm", - "loadMoreFailed": "Không thể tải thêm.", + "loadMoreFailed": "Không thể tải thêm", "truncated": "Hóa đơn cũ hơn có sẵn." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/yo.json b/apps/mobile/src/i18n/locales/yo.json index 39ad945a0b..147cded9b5 100644 --- a/apps/mobile/src/i18n/locales/yo.json +++ b/apps/mobile/src/i18n/locales/yo.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "Àtúnṣe ìṣirò-owó", "credits_expired": "Àwọn kíredìtì ti parí" }, - "loadMore": "Ṣàgbé síwájú síi", - "loadMoreFailed": "Kò lè ṣàgbé síwájú síi.", + "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": { @@ -2980,8 +2980,8 @@ "uncollectible": "Àìlègbà", "unknown": "Àìmọ̀" }, - "loadMore": "Ṣàgbé síwájú síi", - "loadMoreFailed": "Kò lè ṣàgbé síwájú síi.", + "loadMore": "Kojọ diẹ sii", + "loadMoreFailed": "Kò lè gbé síwájú síi", "truncated": "Àwọn ìwé-owó tó ti gbọjú wà." }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/zh-Hans.json b/apps/mobile/src/i18n/locales/zh-Hans.json index 88e99159b9..ca0fee4ee0 100644 --- a/apps/mobile/src/i18n/locales/zh-Hans.json +++ b/apps/mobile/src/i18n/locales/zh-Hans.json @@ -2937,7 +2937,7 @@ "credits_expired": "积分已过期" }, "loadMore": "加载更多", - "loadMoreFailed": "无法加载更多。", + "loadMoreFailed": "无法加载更多", "truncated": "可查看更早的信用活动。" }, "hub": { @@ -2976,7 +2976,7 @@ "unknown": "未知" }, "loadMore": "加载更多", - "loadMoreFailed": "无法加载更多。", + "loadMoreFailed": "无法加载更多", "truncated": "可查看更早的发票。" }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/zh-Hant.json b/apps/mobile/src/i18n/locales/zh-Hant.json index 590f705324..88db56fb46 100644 --- a/apps/mobile/src/i18n/locales/zh-Hant.json +++ b/apps/mobile/src/i18n/locales/zh-Hant.json @@ -2937,7 +2937,7 @@ "credits_expired": "點數已到期" }, "loadMore": "載入更多", - "loadMoreFailed": "無法載入更多。", + "loadMoreFailed": "無法載入更多", "truncated": "可查看較舊的額度活動。" }, "hub": { @@ -2976,7 +2976,7 @@ "unknown": "未知" }, "loadMore": "載入更多", - "loadMoreFailed": "無法載入更多。", + "loadMoreFailed": "無法載入更多", "truncated": "可查看較舊的發票。" }, "lowBalanceAlert": { diff --git a/apps/mobile/src/i18n/locales/zu.json b/apps/mobile/src/i18n/locales/zu.json index a8c90fa98b..418caa1c51 100644 --- a/apps/mobile/src/i18n/locales/zu.json +++ b/apps/mobile/src/i18n/locales/zu.json @@ -2950,8 +2950,8 @@ "accounting_adjustment": "Ukulungiswa kwezimali", "credits_expired": "Amakhredithi aphelelwe yisikhathi" }, - "loadMore": "Layisha okunye", - "loadMoreFailed": "Ayikwazanga ukulayisha okunye.", + "loadMore": "Layisha okwengeziwe", + "loadMoreFailed": "Asikwazanga ukulayisha okwengeziwe", "truncated": "Umsebenzi wekhredithi omdala uyatholakala." }, "inviteMember": { @@ -2980,8 +2980,8 @@ "uncollectible": "Okungakhokhiwa", "unknown": "Okungaziwa" }, - "loadMore": "Layisha okunye", - "loadMoreFailed": "Ayikwazanga ukulayisha okunye.", + "loadMore": "Layisha okwengeziwe", + "loadMoreFailed": "Asikwazanga ukulayisha okwengeziwe", "truncated": "Ama-invoyisi amadala ayatholakala." }, "lowBalanceAlert": { From 5fd5de0fa93d2ec32864576a4f96511dcd1b989b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 26 Aug 2026 16:53:05 +0200 Subject: [PATCH 17/22] fix(mobile): separate later-page failures from refetch failures Read isFetchNextPageError on the org ledger screens so a failed background refetch keeps the rows and the Load more button instead of showing a Retry that calls fetchNextPage and can never clear. Match the org tRPC prefix in the security finding list, so an org-scoped screen actually invalidates its findings on focus. --- .../credit-activity-screen.mounted.test.tsx | 18 ++++++++++++++++++ .../organization/credit-activity-screen.tsx | 6 ++++-- .../invoices-screen.mounted.test.tsx | 18 ++++++++++++++++++ .../organization/invoices-screen.tsx | 6 ++++-- .../security-agent/finding-list-screen.tsx | 8 +++++++- 5 files changed, 51 insertions(+), 5 deletions(-) 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 index 80b0cef88f..a5e81085e2 100644 --- a/apps/mobile/src/components/organization/credit-activity-screen.mounted.test.tsx +++ b/apps/mobile/src/components/organization/credit-activity-screen.mounted.test.tsx @@ -19,6 +19,7 @@ import { OrganizationCreditActivityScreen } from './credit-activity-screen'; const pageQuery = vi.hoisted(() => ({ isPending: false, isError: false, + isFetchNextPageError: false, isFetching: false, isFetchingNextPage: false, data: null as unknown, @@ -203,6 +204,7 @@ async function renderScreen(): Promise { beforeEach(() => { pageQuery.isPending = false; pageQuery.isError = false; + pageQuery.isFetchNextPageError = false; pageQuery.isFetching = false; pageQuery.isFetchingNextPage = false; pageQuery.data = null; @@ -313,6 +315,7 @@ describe('OrganizationCreditActivityScreen pagination', () => { 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]; @@ -332,4 +335,19 @@ describe('OrganizationCreditActivityScreen pagination', () => { }); 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 07bddd02b4..f34bfe166c 100644 --- a/apps/mobile/src/components/organization/credit-activity-screen.tsx +++ b/apps/mobile/src/components/organization/credit-activity-screen.tsx @@ -180,8 +180,10 @@ export function OrganizationCreditActivityScreen() { } // A later-page failure must keep the already-loaded rows and offer an inline - // retry instead of replacing the list. - const isLaterPageError = query.isError && hasLoadedPages; + // 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) { diff --git a/apps/mobile/src/components/organization/invoices-screen.mounted.test.tsx b/apps/mobile/src/components/organization/invoices-screen.mounted.test.tsx index af1e3bfef8..79241546a1 100644 --- a/apps/mobile/src/components/organization/invoices-screen.mounted.test.tsx +++ b/apps/mobile/src/components/organization/invoices-screen.mounted.test.tsx @@ -19,6 +19,7 @@ import { OrganizationInvoicesScreen } from './invoices-screen'; const pageQuery = vi.hoisted(() => ({ isPending: false, isError: false, + isFetchNextPageError: false, isFetching: false, isFetchingNextPage: false, data: null as unknown, @@ -190,6 +191,7 @@ async function renderScreen(): Promise { beforeEach(() => { pageQuery.isPending = false; pageQuery.isError = false; + pageQuery.isFetchNextPageError = false; pageQuery.isFetching = false; pageQuery.isFetchingNextPage = false; pageQuery.data = null; @@ -300,6 +302,7 @@ describe('OrganizationInvoicesScreen pagination', () => { 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]; @@ -319,4 +322,19 @@ describe('OrganizationInvoicesScreen pagination', () => { }); 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 3684f14129..6382efa16c 100644 --- a/apps/mobile/src/components/organization/invoices-screen.tsx +++ b/apps/mobile/src/components/organization/invoices-screen.tsx @@ -213,8 +213,10 @@ export function OrganizationInvoicesScreen() { } // A later-page failure must keep the already-loaded rows and offer an inline - // retry instead of replacing the list. - const isLaterPageError = query.isError && hasLoadedPages; + // 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) { 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 b29b60f4c2..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, @@ -68,7 +69,12 @@ export function FindingListScreen({ scope, routeParams }: Readonly toSecurityFindingQuery(filters), [filters]); const findings = useSecurityFindings(scope, query); const capacity = useSecurityAnalysisCapacity(scope); - useRouteForegroundRefresh([[['securityAgent']]]); + // 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 = From d27fe8bd0c94c1bb762b0a1eb31eefea27211187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 26 Aug 2026 17:29:58 +0200 Subject: [PATCH 18/22] fix(web): make ledger and invoice cursors honest Page the organization credit ledger by keyset ((created_at, id) of the last row) instead of by OFFSET. The ledger grows at the head, so a transaction inserted between two requests shifted every later page and page 2 repeated a row page 1 already showed. Return a Stripe invoice cursor only when Stripe reports has_more, so a full final page no longer advertises a next page that is always empty. --- .../src/lib/creditTransactions.page.test.ts | 36 +++++++++++++++- apps/web/src/lib/creditTransactions.ts | 41 ++++++++++++++++--- apps/web/src/lib/stripe/index.test.ts | 40 ++++++++++++++++++ apps/web/src/lib/stripe/index.ts | 5 ++- .../organizations/organization-router.ts | 2 +- 5 files changed, 115 insertions(+), 9 deletions(-) diff --git a/apps/web/src/lib/creditTransactions.page.test.ts b/apps/web/src/lib/creditTransactions.page.test.ts index ec3a94205c..b015ce3147 100644 --- a/apps/web/src/lib/creditTransactions.page.test.ts +++ b/apps/web/src/lib/creditTransactions.page.test.ts @@ -52,7 +52,7 @@ describe('getCreditTransactionsForOrganizationPage', () => { expect(page.entries).toHaveLength(25); expect(page.hasMore).toBe(true); - expect(page.nextCursor).toBe(25); + 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 ); @@ -114,4 +114,38 @@ describe('getCreditTransactionsForOrganizationPage', () => { 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 cdaf367ac3..d8e5d61c18 100644 --- a/apps/web/src/lib/creditTransactions.ts +++ b/apps/web/src/lib/creditTransactions.ts @@ -136,15 +136,39 @@ type OrganizationCreditTransaction = Awaited< export type CreditTransactionsPage = { entries: OrganizationCreditTransaction[]; - nextCursor: number | null; + 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: number = 0 + 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({ @@ -173,21 +197,26 @@ export async function getCreditTransactionsForOrganizationPage( 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) - .offset(cursor), + .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 ? cursor + CREDIT_TRANSACTIONS_PAGE_SIZE : null, + nextCursor: hasMore && lastEntry ? encodeLedgerCursor(lastEntry) : null, hasMore, summary, }; diff --git a/apps/web/src/lib/stripe/index.test.ts b/apps/web/src/lib/stripe/index.test.ts index ba5832f8cf..2ae88348b9 100644 --- a/apps/web/src/lib/stripe/index.test.ts +++ b/apps/web/src/lib/stripe/index.test.ts @@ -4024,6 +4024,46 @@ describe('getStripeInvoicesPage', () => { } }); + 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(); diff --git a/apps/web/src/lib/stripe/index.ts b/apps/web/src/lib/stripe/index.ts index 24995c7af7..dd6a131302 100644 --- a/apps/web/src/lib/stripe/index.ts +++ b/apps/web/src/lib/stripe/index.ts @@ -739,10 +739,13 @@ export async function getStripeInvoicesPage( 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: lastInvoice ? lastInvoice.id : null, + nextCursor: invoices.has_more ? (lastInvoice?.id ?? null) : null, }; } diff --git a/apps/web/src/routers/organizations/organization-router.ts b/apps/web/src/routers/organizations/organization-router.ts index ad14d12efb..cd26170b09 100644 --- a/apps/web/src/routers/organizations/organization-router.ts +++ b/apps/web/src/routers/organizations/organization-router.ts @@ -108,7 +108,7 @@ const OrganizationInvoicesInputSchema = OrganizationIdInputSchema.extend({ }); const OrganizationTransactionsPageInputSchema = OrganizationIdInputSchema.extend({ - cursor: z.number().int().min(0).default(0), + cursor: z.string().optional(), }); const OrganizationInvoicesPageInputSchema = OrganizationInvoicesInputSchema.extend({ From 88add6786b2418963643079d2391d1a8c391d131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 25 Aug 2026 05:07:56 +0200 Subject: [PATCH 19/22] feat(web): add org credit and invoice page procedures --- .../src/lib/creditTransactions.page.test.ts | 117 ++++++++++++++++++ apps/web/src/lib/creditTransactions.ts | 95 +++++++++++++- apps/web/src/lib/stripe/index.test.ts | 76 ++++++++++++ apps/web/src/lib/stripe/index.ts | 68 ++++++++++ .../organizations/organization-router.ts | 45 ++++++- 5 files changed, 398 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/lib/creditTransactions.page.test.ts 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..ec3a94205c --- /dev/null +++ b/apps/web/src/lib/creditTransactions.page.test.ts @@ -0,0 +1,117 @@ +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(25); + 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(); + }); +}); diff --git a/apps/web/src/lib/creditTransactions.ts b/apps/web/src/lib/creditTransactions.ts index 03b289786d..cdaf367ac3 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,71 @@ 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: number | null; + hasMore: boolean; + summary: CreditSummary; +}; + +export async function getCreditTransactionsForOrganizationPage( + organizationId: Organization['id'], + cursor: number = 0 +): Promise { + 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:%') + ) + ) + ) + .orderBy(desc(credit_transactions.created_at), desc(credit_transactions.id)) + .limit(CREDIT_TRANSACTIONS_PAGE_SIZE + 1) + .offset(cursor), + getCreditTransactionsSummaryForOrganization(organizationId), + ]); + + const hasMore = transactions.length > CREDIT_TRANSACTIONS_PAGE_SIZE; + const entries = transactions.slice(0, CREDIT_TRANSACTIONS_PAGE_SIZE); + + return { + entries, + nextCursor: hasMore ? cursor + CREDIT_TRANSACTIONS_PAGE_SIZE : 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..2f010834e7 100644 --- a/apps/web/src/lib/stripe/index.test.ts +++ b/apps/web/src/lib/stripe/index.test.ts @@ -62,6 +62,7 @@ import { processStripePaymentEventHook, handleSuccessfulChargeWithPayment, isCardFingerprintEligibleForFreeCredits, + getStripeInvoicesPage, } from '@/lib/stripe'; import { type User, @@ -3967,3 +3968,78 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', } ); }); + +describe('getStripeInvoicesPage', () => { + test('returns hasMore, entries, and nextCursor from the last invoice', async () => { + const { client } = await import('@/lib/stripe-client'); + + 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[]; + + const listSpy = jest.spyOn(client.invoices, 'list').mockResolvedValue({ + data: invoices, + has_more: true, + } as unknown as Awaited>); + + const result = await 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(); + }); + + test('passes starting_after and date threshold through to Stripe', async () => { + 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 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(); + }); +}); diff --git a/apps/web/src/lib/stripe/index.ts b/apps/web/src/lib/stripe/index.ts index 15dffb87a9..b0bb49ad54 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 @@ -703,6 +704,73 @@ export async function getStripeInvoices( }); } +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 => { + const hasSeatsMetadata = + line.metadata != null && Object.prototype.hasOwnProperty.call(line.metadata, 'seats'); + const priceId = line.pricing?.price_details?.price; + const hasSeatPriceId = priceId != null && KNOWN_SEAT_PRICE_IDS.has(priceId); + return hasSeatsMetadata || hasSeatPriceId; + }) ?? false; + + const firstLineDescription = invoice.lines?.data?.[0]?.description || null; + + return { + id: invoice.id || '', + number: invoice.number, + status: invoice.status || 'unknown', + amount_due: invoice.amount_due || 0, + currency: invoice.currency || 'usd', + created: invoice.created || 0, + hosted_invoice_url: invoice.hosted_invoice_url || null, + invoice_pdf: invoice.invoice_pdf || null, + invoice_type: isSeatInvoice ? 'seats' : 'topup', + description: firstLineDescription, + }; + }); +} + +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]; + + return { + entries, + hasMore: invoices.has_more, + nextCursor: lastInvoice ? lastInvoice.id : 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..6bc4a698c3 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.number().int().min(0).default(0), +}); + +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); + }), }); From 3fb80d492250acdc61aef9eacc76bbfa90df9649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 26 Aug 2026 00:44:34 +0200 Subject: [PATCH 20/22] test(web): fix stripe invoice page test spy isolation --- apps/web/src/lib/stripe/index.test.ts | 89 ++++++++++++++++----------- 1 file changed, 52 insertions(+), 37 deletions(-) diff --git a/apps/web/src/lib/stripe/index.test.ts b/apps/web/src/lib/stripe/index.test.ts index 2f010834e7..ba5832f8cf 100644 --- a/apps/web/src/lib/stripe/index.test.ts +++ b/apps/web/src/lib/stripe/index.test.ts @@ -62,7 +62,6 @@ import { processStripePaymentEventHook, handleSuccessfulChargeWithPayment, isCardFingerprintEligibleForFreeCredits, - getStripeInvoicesPage, } from '@/lib/stripe'; import { type User, @@ -3971,8 +3970,6 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', describe('getStripeInvoicesPage', () => { test('returns hasMore, entries, and nextCursor from the last invoice', async () => { - const { client } = await import('@/lib/stripe-client'); - const invoices = [ { id: 'in_page_1', @@ -4000,46 +3997,64 @@ describe('getStripeInvoicesPage', () => { }, ] as unknown as Stripe.Invoice[]; - const listSpy = jest.spyOn(client.invoices, 'list').mockResolvedValue({ - data: invoices, - has_more: true, - } as unknown as Awaited>); + try { + jest.resetModules(); + await jest.isolateModulesAsync(async () => { + const stripe = await import('@/lib/stripe'); + const { client } = await import('@/lib/stripe-client'); - const result = await getStripeInvoicesPage('cus_page_test'); + const listSpy = jest.spyOn(client.invoices, 'list').mockResolvedValue({ + data: invoices, + has_more: true, + } as unknown as Awaited>); - 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'); + 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(); + listSpy.mockRestore(); + }); + } finally { + jest.resetModules(); + } }); test('passes starting_after and date threshold through to Stripe', async () => { - 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 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(); + 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(); + listSpy.mockRestore(); + }); + } finally { + jest.resetModules(); + } }); }); From f791d1752a0a0faec8b1d4a027ef822b25b6b4c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 26 Aug 2026 06:23:51 +0200 Subject: [PATCH 21/22] refactor(web): reuse unified invoice mapping in array path --- apps/web/src/lib/stripe/index.ts | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/apps/web/src/lib/stripe/index.ts b/apps/web/src/lib/stripe/index.ts index b0bb49ad54..24995c7af7 100644 --- a/apps/web/src/lib/stripe/index.ts +++ b/apps/web/src/lib/stripe/index.ts @@ -676,32 +676,7 @@ export async function getStripeInvoices( const invoices = await client.invoices.list(listParams); const invoiceData: Stripe.Invoice[] = invoices.data; - return invoiceData.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 => { - const hasSeatsMetadata = - line.metadata != null && Object.prototype.hasOwnProperty.call(line.metadata, 'seats'); - const priceId = line.pricing?.price_details?.price; - const hasSeatPriceId = priceId != null && KNOWN_SEAT_PRICE_IDS.has(priceId); - return hasSeatsMetadata || hasSeatPriceId; - }) ?? false; - - const firstLineDescription = invoice.lines?.data?.[0]?.description || null; - - return { - id: invoice.id || '', - number: invoice.number, - status: invoice.status || 'unknown', - amount_due: invoice.amount_due || 0, - currency: invoice.currency || 'usd', - created: invoice.created || 0, - hosted_invoice_url: invoice.hosted_invoice_url || null, - invoice_pdf: invoice.invoice_pdf || null, - invoice_type: isSeatInvoice ? 'seats' : 'topup', - description: firstLineDescription, - }; - }); + return mapStripeInvoicesToUnified(invoiceData); } function mapStripeInvoicesToUnified(invoices: Stripe.Invoice[]): UnifiedInvoice[] { From 69835e4ba451e2e83337d5c09d41edea94154838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 26 Aug 2026 17:29:58 +0200 Subject: [PATCH 22/22] fix(web): make ledger and invoice cursors honest Page the organization credit ledger by keyset ((created_at, id) of the last row) instead of by OFFSET. The ledger grows at the head, so a transaction inserted between two requests shifted every later page and page 2 repeated a row page 1 already showed. Return a Stripe invoice cursor only when Stripe reports has_more, so a full final page no longer advertises a next page that is always empty. --- .../src/lib/creditTransactions.page.test.ts | 36 +++++++++++++++- apps/web/src/lib/creditTransactions.ts | 41 ++++++++++++++++--- apps/web/src/lib/stripe/index.test.ts | 40 ++++++++++++++++++ apps/web/src/lib/stripe/index.ts | 5 ++- .../organizations/organization-router.ts | 2 +- 5 files changed, 115 insertions(+), 9 deletions(-) diff --git a/apps/web/src/lib/creditTransactions.page.test.ts b/apps/web/src/lib/creditTransactions.page.test.ts index ec3a94205c..b015ce3147 100644 --- a/apps/web/src/lib/creditTransactions.page.test.ts +++ b/apps/web/src/lib/creditTransactions.page.test.ts @@ -52,7 +52,7 @@ describe('getCreditTransactionsForOrganizationPage', () => { expect(page.entries).toHaveLength(25); expect(page.hasMore).toBe(true); - expect(page.nextCursor).toBe(25); + 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 ); @@ -114,4 +114,38 @@ describe('getCreditTransactionsForOrganizationPage', () => { 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 cdaf367ac3..d8e5d61c18 100644 --- a/apps/web/src/lib/creditTransactions.ts +++ b/apps/web/src/lib/creditTransactions.ts @@ -136,15 +136,39 @@ type OrganizationCreditTransaction = Awaited< export type CreditTransactionsPage = { entries: OrganizationCreditTransaction[]; - nextCursor: number | null; + 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: number = 0 + 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({ @@ -173,21 +197,26 @@ export async function getCreditTransactionsForOrganizationPage( 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) - .offset(cursor), + .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 ? cursor + CREDIT_TRANSACTIONS_PAGE_SIZE : null, + nextCursor: hasMore && lastEntry ? encodeLedgerCursor(lastEntry) : null, hasMore, summary, }; diff --git a/apps/web/src/lib/stripe/index.test.ts b/apps/web/src/lib/stripe/index.test.ts index ba5832f8cf..2ae88348b9 100644 --- a/apps/web/src/lib/stripe/index.test.ts +++ b/apps/web/src/lib/stripe/index.test.ts @@ -4024,6 +4024,46 @@ describe('getStripeInvoicesPage', () => { } }); + 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(); diff --git a/apps/web/src/lib/stripe/index.ts b/apps/web/src/lib/stripe/index.ts index 24995c7af7..dd6a131302 100644 --- a/apps/web/src/lib/stripe/index.ts +++ b/apps/web/src/lib/stripe/index.ts @@ -739,10 +739,13 @@ export async function getStripeInvoicesPage( 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: lastInvoice ? lastInvoice.id : null, + nextCursor: invoices.has_more ? (lastInvoice?.id ?? null) : null, }; } diff --git a/apps/web/src/routers/organizations/organization-router.ts b/apps/web/src/routers/organizations/organization-router.ts index 6bc4a698c3..fec7c18a5b 100644 --- a/apps/web/src/routers/organizations/organization-router.ts +++ b/apps/web/src/routers/organizations/organization-router.ts @@ -109,7 +109,7 @@ const OrganizationInvoicesInputSchema = OrganizationIdInputSchema.extend({ }); const OrganizationTransactionsPageInputSchema = OrganizationIdInputSchema.extend({ - cursor: z.number().int().min(0).default(0), + cursor: z.string().optional(), }); const OrganizationInvoicesPageInputSchema = OrganizationInvoicesInputSchema.extend({