# PRD — MSP i18n: Credits Sub-batch - Slug: `2026-04-09-msp-i18n-credits` - Date: `2026-04-09` - Status: Implemented on branch `i18n/billing_credits` (22/22 features, 31/31 tests) — pending merge to `main`. See SCRATCHPAD "Status Recheck (2026-04-17)". - Parent plan: `/.ai/translation/MSP_i18n_plan.md` ## Summary Create a new `msp/credits` i18n namespace and wire `useTranslation('msp/credits')` into 10 credit management components across `packages/billing/src/components/credits/` and `packages/billing/src/components/billing-dashboard/Credit*.tsx`. None of these components currently use `useTranslation`. This is a greenfield namespace (no existing JSON to extend) covering ~150 user-visible strings across credit management, reconciliation, application, and expiration flows. ## Problem MSP users navigating to billing > credits see fully English UI while the surrounding navigation, sidebar, and dashboard chrome are already translated. The credits module spans 10 components with ~150 hardcoded English strings across credit listing, management dashboards, reconciliation reports, credit application, and expiration modification dialogs. No `msp/credits.json` namespace exists, and no `ROUTE_NAMESPACES` entry loads credit-specific translations for `/msp/billing/credits`. ## Goals 1. Create `server/public/locales/en/msp/credits.json` with all keys needed by the 10 components 2. Wire `useTranslation('msp/credits')` into all 8 client components (2 files are server-side or have zero visible strings) 3. Generate translations for 7 non-English locales (fr, es, de, nl, it, pl) + 2 pseudo-locales (xx, yy) 4. Add `/msp/billing/credits` route to `ROUTE_NAMESPACES` in `packages/core/src/lib/i18n/config.ts` loading `msp/credits` 5. Pass `node scripts/generate-pseudo-locales.cjs && node scripts/validate-translations.cjs` 6. Measurable: 0% -> 100% of credit components wired for i18n ## Non-goals - Translating credit amounts, dates, or tenant data (those are formatted via `useFormatters` or `formatCurrency`/`formatDateOnly` utilities already in use) - Translating server-side error strings in `actions.ts` that only appear in console logs (e.g., `'Unknown error occurred'` in catch blocks) -- these do not surface to the user - Moving credit components to a different package or restructuring the billing package - Adding new UI features to credit management - Translating Recharts axis tick formatters or tooltip formatters (they use `formatCurrency` which is locale-aware separately) ## File Inventory | # | File | LOC | Type | Est. Strings | Notes | |---|------|-----|------|-------------|-------| | 1 | `credits/CreditsPage.tsx` | 275 | Server component | ~30 | Column titles, statuses, tab labels, card titles, settings labels. **Server component** -- will need a client wrapper or inline `t()` via server-side i18n | | 2 | `credits/CreditsTabs.tsx` | 53 | Client | 0 | Pure tab-switching logic; labels passed in via props from CreditsPage. No visible strings to translate. **Skip.** | | 3 | `credits/AddCreditButton.tsx` | 53 | Client | ~6 | Button label, dialog title, placeholder text, cancel/submit buttons | | 4 | `credits/BackButton.tsx` | 20 | Client | ~1 | "Back to Credits" button text | | 5 | `credits/actions.ts` | 109 | Server action | ~5 | Error messages in catch blocks. Only `'Authentication required'` and `'Transfer amount must be greater than zero'` surface to callers. Others are console-only. | | 6 | `billing-dashboard/CreditManagement.tsx` | 642 | Client | ~45 | Page title, chart labels/legends, stat card labels, column titles, tab labels, button text, dialog text | | 7 | `billing-dashboard/CreditReconciliation.tsx` | 604 | Client | ~50 | Dashboard title, filter labels, stat cards, chart titles/legends, column titles, tab labels, status badges, button text, toast messages | | 8 | `billing-dashboard/CreditApplicationUI.tsx` | 273 | Client | ~20 | Card title/description, column titles, labels, button text, error/empty states | | 9 | `billing-dashboard/CreditExpirationInfo.tsx` | 129 | Client | ~10 | Card title/description, field labels, help text, empty/error states | | 10 | `billing-dashboard/CreditExpirationModificationDialog.tsx` | 172 | Client | ~15 | Dialog title/description, field labels, switch label, button text, validation errors | **Total estimated: ~150 unique strings across ~80 translation keys (some strings repeat across components).** ## Namespace Structure File: `server/public/locales/en/msp/credits.json` ``` { "page": { "title": "Credit Management", "creditsOverview": "Credits Overview", "overviewDescription": "Manage your client credits...", ... }, "columns": { "creditId": "Credit ID", "created": "Created", "description": "Description", "originalAmount": "Original Amount", "remaining": "Remaining", "expires": "Expires", "status": "Status", "actions": "Actions", "context": "Context", ... }, "status": { "active": "Active", "expired": "Expired", "expiringSoon": "Expiring Soon ({{days}} days)", "never": "Never", "na": "N/A", ... }, "actions": { "view": "View", "edit": "Edit", "expire": "Expire", "addCredit": "Add Credit", "cancel": "Cancel", "viewAllCredits": "View All Credits", "backToCredits": "Back to Credits", ... }, "tabs": { "activeCredits": "Active Credits", "allCredits": "All Credits", "expiredCredits": "Expired Credits" }, "settings": { "title": "Credit Expiration Settings", "creditExpiration": "Credit Expiration:", "enabled": "Enabled", "disabled": "Disabled", "expirationPeriod": "Expiration Period:", "daysUnit": "{{count}} days", "notificationDays": "Notification Days:", "none": "None" }, "charts": { "expirationSummary": "Credit Expiration Summary", "expirationSummaryDescription": "Overview of credits expiring soon", "usageTrends": "Credit Usage Trends", "usageTrendsDescription": "Historical credit usage patterns", "creditsIssued": "Credits Issued", "creditsApplied": "Credits Applied", "creditsExpired": "Credits Expired", ... }, "stats": { "totalActiveCredits": "Total Active Credits", "expiringIn30Days": "Expiring in 30 Days", "totalCreditsApplied": "Total Credits Applied", "totalCreditsExpired": "Total Credits Expired", ... }, "management": { "title": "Credit Management", "recentCredits": "Recent Credits", "recentCreditsDescription": "View and manage your client credits...", "addCreditPlaceholder": "Credit amount and details form would be implemented here." }, "reconciliation": { "title": "Credit Reconciliation Dashboard", "selectClient": "Select Client", "runReconciliation": "Run Reconciliation", "running": "Running...", "status": "Status", "allStatuses": "All Statuses", "open": "Open", "inReview": "In Review", "resolved": "Resolved", "fromDate": "From Date", "toDate": "To Date", "reset": "Reset", "totalDiscrepancies": "Total Discrepancies", "totalDiscrepancyAmount": "Total Discrepancy Amount", "openIssues": "Open Issues", "statusDistribution": "Status Distribution", "statusDistributionDescription": "Overview of reconciliation report statuses", "discrepancyTrends": "Discrepancy Trends", "discrepancyTrendsDescription": "Monthly trends in credit discrepancies", "numberOfDiscrepancies": "Number of Discrepancies", "totalAmount": "Total Amount", "reconciliationReports": "Reconciliation Reports", "reconciliationReportsDescription": "View and manage credit balance discrepancies", "resolve": "Resolve", "validationResult": "Validation completed: Found {{balanceCount}} balance discrepancies and {{trackingCount}} tracking issues.", ... }, "application": { "title": "Apply Credit", "applyToInvoice": "Apply available credits to this invoice", "applyToBalance": "Apply credits to reduce customer balance", "totalAvailableCredit": "Total Available Credit:", "invoiceAmount": "Invoice Amount:", "selectCreditToApply": "Select Credit to Apply", "amountToApply": "Amount to Apply", "creditOrderNote": "Credits are applied in order of expiration date (oldest first)", "noCreditsAvailable": "No credits available for this client", "failedToLoadCredits": "Failed to load available credits", "selectCreditError": "Please select a credit and enter a valid amount", "failedToApply": "Failed to apply credit", "applying": "Applying...", "applyCredit": "Apply Credit", "selected": "Selected", "select": "Select", ... }, "expiration": { "appliedCredits": "Applied Credits", "creditsAppliedToInvoice": "Credits applied to this invoice: {{amount}}", "creditAmount": "Credit Amount:", "noDetails": "No credit details available", "failedToLoad": "Failed to load credit details", ... }, "expirationDialog": { "title": "Modify Credit Expiration", "description": "Update the expiration date for this credit.", "creditAmount": "Credit Amount:", "remainingAmount": "Remaining Amount:", "created": "Created:", "currentExpiration": "Current Expiration:", "noExpiration": "No expiration", "removeExpiration": "Remove expiration date", "newExpirationDate": "New Expiration Date", "pastDateError": "Expiration date cannot be in the past", "updateError": "An error occurred while updating the expiration date", "saving": "Saving...", "saveChanges": "Save Changes" }, "context": { "lineageMissing": "Lineage Missing", "lineageMissingDescription": "Source invoice metadata could not be recovered...", "transferredRecurringCredit": "Transferred Recurring Credit", "recurringSource": "Recurring Source", "servicePeriod": "Service Period: {{period}}", "recurringLineagePreserved": "Recurring source lineage preserved", "financialOnly": "Financial Only", "noRecurringServicePeriod": "No recurring service period" } } ``` ## ROUTE_NAMESPACES Change In `packages/core/src/lib/i18n/config.ts`, add a new entry: ```typescript '/msp/billing/credits': ['common', 'msp/core', 'features/billing', 'msp/credits'], ``` This must appear **before** the existing `/msp/billing` entry since `getNamespacesForRoute` uses longest-prefix matching. ## Server Component Consideration `CreditsPage.tsx` is an async server component (no `'use client'` directive). Two approaches: 1. **Extract a client wrapper** that calls `useTranslation('msp/credits')` and passes translated strings as props to the server-rendered content. This is the recommended pattern for server components in Next.js with client-side i18n. 2. **Convert to client component** by adding `'use client'` and restructuring data fetching. This is more invasive and not recommended. The recommended approach is (1): create a thin `CreditsPageClient.tsx` wrapper that handles translation, and have the server component pass data to it. ## Acceptance Criteria - [ ] `server/public/locales/en/msp/credits.json` exists with all keys used by credit components - [ ] All 8 client components with visible strings import `useTranslation('msp/credits')` and wrap all user-visible strings via `t('key', { defaultValue: 'English fallback' })` - [ ] `CreditsPage.tsx` (server component) strings are translated via a client wrapper component - [ ] `CreditsTabs.tsx` confirmed to have zero visible strings (skip) - [ ] `actions.ts` server-side error strings assessed: user-facing ones translated, log-only ones left in English - [ ] `/msp/billing/credits` added to `ROUTE_NAMESPACES` loading `msp/credits` - [ ] Translations generated for fr, es, de, nl, it, pl (7 locales) - [ ] `node scripts/generate-pseudo-locales.cjs && node scripts/validate-translations.cjs` exits 0 - [ ] Italian translations preserve accents (e.g., "Crediti scaduti" not "Crediti scaduti") - [ ] `{{variable}}` interpolation tokens preserved across all 9 locale files - [ ] Currency values still formatted via `formatCurrency()` (not translated as strings) - [ ] Date values still formatted via `toLocaleDateString()` or `formatDateOnly()` (not translated) - [ ] Visual smoke test: `/msp/billing/credits` and credit management tab render correctly in `en` and at least one non-English locale; `xx` pseudo-locale shows pseudo-text for every visible string